Compare commits
No commits in common. "master" and "1.1.14" have entirely different histories.
|
|
@ -8,20 +8,12 @@ assignees: ''
|
|||
---
|
||||
|
||||
**Information**
|
||||
<!-- Make sure that your issue is not one of the known issues in the
|
||||
Solaar documentation at https://pwr-solaar.github.io/Solaar/ -->
|
||||
<!-- Make sure that Solaar's udev rule is running by executing
|
||||
`ls -l /dev/hidraw*` and looking for + as the last character of the permissions. -->
|
||||
<!-- Do not bother opening an issue for a version older than 1.1.14.
|
||||
Upgrade to the current version and see if your issue persists. -->
|
||||
<!-- If you are not running the current version of Solaar,
|
||||
strongly consider upgrading to the current version. -->
|
||||
<!-- Note that some distributions have very old versions of Solaar
|
||||
as their default version. -->
|
||||
|
||||
<!-- Make sure that your issue is not one of the known issues in the Solaar documentation at https://pwr-solaar.github.io/Solaar/ -->
|
||||
<!-- Do not bother opening an issue for a version older than 1.1.8. Upgrade to the latest version and see if your issue persists. -->
|
||||
<!-- If you are not running the current version of Solaar, strongly consider upgrading to the newest version. -->
|
||||
- Solaar version (`solaar --version` or `git describe --tags` if cloned from this repository):
|
||||
- Distribution:
|
||||
- Kernel version (ex. `uname -srmo`):
|
||||
- Kernel version (ex. `uname -srmo`): `KERNEL VERSION HERE`
|
||||
- Output of `solaar show`:
|
||||
|
||||
<details>
|
||||
|
|
@ -42,11 +34,11 @@ CONTENTS HERE
|
|||
|
||||
|
||||
- Errors or warrnings from Solaar:
|
||||
<!-- Under normal operation Solaar keeps a log of warning and error messages
|
||||
in ~/.tmp while it is running, as a file starting with 'Solaar'.
|
||||
<!-- Under normal operation Solaar keeps a log of warning and error messages in ~/.tmp
|
||||
while it is running as a file starting with 'Solaar'.
|
||||
If this file is not available or does not have useful information you can
|
||||
run Solaar as `solaar -ddd`, after killing any running Solaar processes to
|
||||
have Solaar log debug, informational, warning, and error messages to stdout. -->
|
||||
run Solaar as `solaar -dd`, after killing any running Solaar processes to
|
||||
have Solaar log informational, warning, and error messages to stdout. -->
|
||||
|
||||
|
||||
**Describe the bug**
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8, 3.13]
|
||||
fail-fast: false
|
||||
python-version: [3.8, 3.12]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -20,16 +19,10 @@ jobs:
|
|||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install Ubuntu dependencies for python 3.8
|
||||
if: matrix.python-version == '3.8'
|
||||
- name: Install Ubuntu dependencies
|
||||
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"]'
|
||||
|
|
@ -54,8 +47,7 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8, 3.13]
|
||||
fail-fast: false
|
||||
python-version: [3.8, 3.12]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -69,15 +61,12 @@ jobs:
|
|||
- name: Set up macOS dependencies
|
||||
run: |
|
||||
make install_brew
|
||||
- name: Add Homebrew's library directory to dyld search path
|
||||
run: |
|
||||
echo "DYLD_FALLBACK_LIBRARY_PATH=$(brew --prefix)/lib:$DYLD_FALLBACK_LIBRARY_PATH" >> $GITHUB_ENV
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
make install_pip PIP_ARGS='.["test"]'
|
||||
- name: Run tests on macOS
|
||||
run: |
|
||||
pytest --cov --cov-report=xml
|
||||
export DYLD_LIBRARY_PATH=$(brew --prefix hidapi)/lib:$DYLD_LIBRARY_PATH && pytest --cov --cov-report=xml
|
||||
- name: Upload coverage to Codecov
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: codecov/codecov-action@v4.5.0
|
||||
|
|
|
|||
|
|
@ -23,9 +23,3 @@ __pycache__/
|
|||
/po/*.po~
|
||||
|
||||
/.idea/
|
||||
|
||||
.DS_Store
|
||||
._*
|
||||
|
||||
Pipfile
|
||||
Pipfile.lock
|
||||
|
|
|
|||
80
CHANGELOG.md
80
CHANGELOG.md
|
|
@ -1,83 +1,3 @@
|
|||
# 1.1.16
|
||||
|
||||
* Add new flags for reprogrammable keys feature
|
||||
* Correctly handle missing battery feature
|
||||
|
||||
# 1.1.15
|
||||
|
||||
* Correctly re-raise permissions exception
|
||||
* Add several new special keys and tasks
|
||||
* Update several translations
|
||||
* Center labels and remove buggy entry resizing logic
|
||||
* Add shape keys from Key POP Icon
|
||||
* Device and Action rule conditions match on codename and name
|
||||
* Fix listing hidpp10 devices - bytes vs string concatenation (#2856)
|
||||
* Add present flag, unset when internal error occurs, set when notification appears
|
||||
* Pause setting up features when error occurs; use ADC message to signal connection and disconnection
|
||||
* Fix listing of hidpp10 peripherals
|
||||
* Complete DEVICE_FEATURES to DeviceFeature transition for hidpp10 devices
|
||||
* Fix NOTIFICATION_FLAG to NotificationFlag transition leftovers
|
||||
* Fix github workflow stopping all matrix jobs when one of them fails
|
||||
* Fix ubuntu github CI
|
||||
* Update index.md
|
||||
* Python documentation appears to be broken so don't set it up
|
||||
* Improve documentation on onboard profiles
|
||||
* Use correct LOD values for extended adjustable dpi
|
||||
* Better support RGB Effects - not readable
|
||||
* Fix crash when asking for help about config
|
||||
* Fix error when updating ChoiceControlBig box
|
||||
* Add uninstallation docs
|
||||
* Handle unknown power switch locations again
|
||||
* Correctly handle selection of [empty] in rule editor
|
||||
* Handle `HIDError` in `hidapi.hidapi_impl._match()` (#2804)
|
||||
* Give ghost devices a path
|
||||
* Guard against typeerror when setting the value of a control box
|
||||
* Recover from errors in ping
|
||||
* Replace spaces by underscores when looking up features
|
||||
* Rewrote string concatenation/format with f strings
|
||||
* Fix logo not showing in about dialog box
|
||||
* Make typing-extensions dependency mandatory
|
||||
* Properly ignore unsupported locale
|
||||
* hidapi: skip unsupported devices and handle exception on open
|
||||
* Ignore macOS junk files and pipenv config
|
||||
* Fix ui desktop notifications test
|
||||
* hidpp20: Remove dependency to NamedInts
|
||||
* Estimate accurate battery level for some rechargable devices (#2745)
|
||||
* Upgrade desktop notifications tests to take notifications availability into account
|
||||
* Update tests to run on Python 3.13
|
||||
* Remove outdated logger enabled checks
|
||||
* Introduce GTK signal types
|
||||
* Introduce error types
|
||||
* Remove alias for SupportedFeature
|
||||
* Refactor process_device_notification
|
||||
* Refactor process_receiver_notification
|
||||
* Refactor receiver event handling
|
||||
* Introduce custom logger
|
||||
* Refactor notifications
|
||||
* Rename variable to full name notification
|
||||
* Test notifications
|
||||
* Test extraction of serial and max. devices
|
||||
* Refactor extraction of serial and max. devices
|
||||
* macOS: Fix int.from_bytes, int.to_bytes for show.py
|
||||
* macOS: Remove udev rule warning
|
||||
* macOS: Add support for Bluetooth devices
|
||||
* Add back and forward mouseclick actions
|
||||
* Speedup lookup of known receivers
|
||||
* Refactor device filtering
|
||||
* Reorder private functions and variable definitions
|
||||
* Turn filter_products_of_interest into a public function
|
||||
* Improve tests of known receivers
|
||||
* Refactor: Remove NamedInts and move enums where used
|
||||
* Add docstrings and type hints
|
||||
* Enforce rules on RuleComponentUI subclasses
|
||||
* Simplify settings UI class
|
||||
* Remove diversion alias
|
||||
* Refactor: Convert Kind to IntEnum
|
||||
* Split up huge settings module
|
||||
* Remove Python 2 specific path handling
|
||||
* Delete logging temp file on exit
|
||||
* Update Swedish translation
|
||||
|
||||
# 1.1.14
|
||||
|
||||
* Handle fake feature enums in show
|
||||
|
|
|
|||
5
Makefile
5
Makefile
|
|
@ -19,11 +19,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ that are otherwise ignored by the Linux input system.
|
|||
<a href="https://pwr-solaar.github.io/Solaar/usage">Usage</a> -
|
||||
<a href="https://pwr-solaar.github.io/Solaar/capabilities">Capabilities</a> -
|
||||
<a href="https://pwr-solaar.github.io/Solaar/rules">Rules</a> -
|
||||
<a href="https://pwr-solaar.github.io/Solaar/installation">Manual Installation</a> -
|
||||
<a href="https://pwr-solaar.github.io/Solaar/issues">Known Issues</a>
|
||||
<a href="https://pwr-solaar.github.io/Solaar/installation">Manual Installation</a>
|
||||
|
||||
|
||||
[](https://codecov.io/gh/pwr-Solaar/Solaar)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
# Notes on Major Changes in Releases
|
||||
|
||||
## Version 1.1.16
|
||||
|
||||
* Two bugs that were affecting users in 1.1.15 are fixed.
|
||||
|
||||
## Version 1.1.15
|
||||
|
||||
* Some key names have been changed to match Logitech names. Rules that use removed names will no longer work and will end up with a key of 0.
|
||||
* Device and Action rule conditions match on device codename and name
|
||||
* Solaar supports configuration of Bluetooth devices on macOS.
|
||||
|
||||
## Version 1.1.13
|
||||
|
||||
* Solaar will drop support for Python 3.7 immediately after version 1.1.13.
|
||||
|
|
|
|||
15
bin/solaar
15
bin/solaar
|
|
@ -24,7 +24,20 @@ def init_paths():
|
|||
import os.path
|
||||
import sys
|
||||
|
||||
root = os.path.join(os.path.realpath(sys.path[0]), "..")
|
||||
# Python 3 might have problems converting back to UTF-8 in case of Unicode surrogates
|
||||
decoded_path = None
|
||||
try:
|
||||
decoded_path = sys.path[0]
|
||||
sys.path[0].encode(sys.getfilesystemencoding())
|
||||
|
||||
except UnicodeError:
|
||||
sys.stderr.write(
|
||||
"ERROR: Solaar cannot recognize encoding of filesystem path, "
|
||||
"this may happen due to non UTF-8 characters in the pathname.\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
root = os.path.join(os.path.realpath(decoded_path), "..")
|
||||
prefix = os.path.normpath(root)
|
||||
src_lib = os.path.join(prefix, "lib")
|
||||
share_lib = os.path.join(prefix, "share", "solaar", "lib")
|
||||
|
|
|
|||
132
docs/LICENSE.txt
132
docs/LICENSE.txt
|
|
@ -1,132 +0,0 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
||||
<https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change.
|
||||
b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License.
|
||||
c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
|
||||
b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
|
||||
c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
END OF TERMS AND CONDITIONS
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
one line to give the program's name and an idea of what it does.
|
||||
Copyright (C) yyyy name of author
|
||||
|
||||
This program is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU General Public License
|
||||
as published by the Free Software Foundation; either version 2
|
||||
of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program; if not, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details
|
||||
type `show w'. This is free software, and you are welcome
|
||||
to redistribute it under certain conditions; type `show c'
|
||||
for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright
|
||||
interest in the program `Gnomovision'
|
||||
(which makes passes at compilers) written
|
||||
by James Hacker.
|
||||
|
||||
signature of Moe Ghoul, 1 April 1989
|
||||
Moe Ghoul, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License.
|
||||
|
|
@ -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.
|
||||
|
||||
|
||||
### Onboard Profiles
|
||||
### Device Profiles
|
||||
|
||||
Some mice store one or more profiles onboard. An onboard profile controls certain aspects of the behavior of the mouse, including the rate at which the mouse reports movement, the resolution of the the movement reports, what the mouse buttons do, LED effects, and maybe more. Solaar has a setting that switches between profiles or disables all profiles.
|
||||
Some mice store one or more profiles, which control aspects of the behavior of the device.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 usually for the logo LEDs and the second usually for the side LEDs.
|
||||
Lighting information is a sequence of lighting effects, with the first for the logo LEDs and the second for the side LEDs.
|
||||
|
||||
The fields possible in an effect are:
|
||||
- ID: The kind of effect:
|
||||
|
|
|
|||
|
|
@ -209,7 +209,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
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%.
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
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%.
|
||||
|
|
@ -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` | Supported | `Backlight`
|
||||
`BACKLIGHT2` | `0x1982` | Supported | `Backlight2`, ...
|
||||
`BACKLIGHT` | `0x1981` | Unsupported |
|
||||
`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` | Supported | `ADCPower`
|
||||
`ADC_MEASUREMENT` | `0x1F20` | Unsupported |
|
||||
`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` | 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`
|
||||
`MKEYS` | `0x8020` | Unsupported |
|
||||
`MR` | `0x8030` | Unsupported |
|
||||
`BRIGHTNESS_CONTROL` | `0x8040` | Unsupported |
|
||||
`REPORT_RATE` | `0x8060` | Supported | `ReportRate`
|
||||
`COLOR_LED_EFFECTS` | `0x8070` | Unsupported |
|
||||
`RGB_EFFECTS` | `0X8071` | Unsupported |
|
||||
`PER_KEY_LIGHTING` | `0x8080` | Unsupported |
|
||||
`PER_KEY_LIGHTING_V2` | `0x8081` | Supported | `PerKeyLighting`
|
||||
`PER_KEY_LIGHTING_V2` | `0x8081` | Unsupported |
|
||||
`MODE_STATUS` | `0x8090` | Unsupported |
|
||||
`ONBOARD_PROFILES` | `0x8100` | Supported |
|
||||
`ONBOARD_PROFILES` | `0x8100` | Unsupported |
|
||||
`MOUSE_BUTTON_SPY` | `0x8110` | Unsupported |
|
||||
`LATENCY_MONITORING` | `0x8111` | Unsupported |
|
||||
`GAMING_ATTACHMENTS` | `0x8120` | Unsupported |
|
||||
`FORCE_FEEDBACK` | `0x8123` | Unsupported |
|
||||
`SIDETONE` | `0x8300` | Supported | `Sidetone`
|
||||
`EQUALIZER` | `0x8310` | Supported | `Equalizer`
|
||||
`SIDETONE` | `0x8300` | Unsupported |
|
||||
`EQUALIZER` | `0x8310` | Unsupported |
|
||||
`HEADSET_OUT` | `0x8320` | Unsupported |
|
||||
|
||||
A “read only” note means the feature is a read-only feature.
|
||||
|
|
|
|||
|
|
@ -62,12 +62,10 @@ Some of the languages Solaar has been translated to are listed below. A full lis
|
|||
- Portuguese-BR: [Drovetto][drovetto], [Josenivaldo Benito Jr.][jrbenito], Vinícius
|
||||
- Română: Daniel Pavel
|
||||
- Russian: [Dimitriy Ryazantcev][DJm00n], Anton Soroko
|
||||
- Serbian: [Renato Kaurić][renatoka]
|
||||
- Slovak: [Jose Riha][jose1711]
|
||||
- Spanish, Castilian: Jose Luis Tirado
|
||||
- Swedish: John Erling Blad, [Daniel Zippert][zipperten], Emelie Snecker, Jonatan Nyberg
|
||||
- Turkish: Osman Karagöz
|
||||
- Ukrainian: Олександр Афанасьєв
|
||||
|
||||
[Rongronggg9]: https://github.com/Rongronggg9
|
||||
[papoteur]: https://github.com/papoteur
|
||||
|
|
@ -82,4 +80,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -68,9 +68,9 @@ Many devices allow reprogramming some keys or buttons. One the main reasons for
|
|||
|
||||
Many pointing devices provide a facility for recognizing gestures and sending an HID message for the gesture. The `Gesture` class stores inforation for one gesture and the `Gestures` class stores information for all the gestures on a device. Functions in the Device class request `KeysArray` information and store it on devices. Functions in the Device class request `Gestures` information for a device when appropriate and store it on the device.
|
||||
|
||||
Many gaming devices provide an interface to controlling their LEDs by zone. The `LEDEffectSetting` class stores the current state of one zone of LEDs. This information can come directly from an LED feature but is also part of 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 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 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.
|
||||
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.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 that a particular device supports.
|
||||
Solaar is thus only able to make the changes to devices that devices implement.
|
||||
|
||||
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 how 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 to set up a system tray under Gnome see
|
||||
[the capabilities page](https://pwr-solaar.github.io/Solaar/capabilities).
|
||||
|
||||
Solaar's GUI can be started in several ways
|
||||
|
||||
|
|
@ -131,6 +131,62 @@ Solaar uses a standard system tray implementation; solaar-gnome3 is no longer re
|
|||
See [the installation page](https://pwr-solaar.github.io/Solaar/installation)
|
||||
for the step-by-step procedure for manual installation.
|
||||
|
||||
## Known Issues
|
||||
|
||||
- Onboard Profiles, when active, can prevent changes to other settings, such as Polling Rate, DPI, and various LED settings. Which settings are affected depends on the device. To make changes to affected settings, disable Onboard Profiles. If Onboard Profiles are later enabled the affected settings may change to the value in the profile.
|
||||
|
||||
- Solaar version 1.1.12 has a bug resulting in devices remaining in their default configuration after a system resume. This is fixed in 1.1.13.
|
||||
|
||||
- Bluez 5.73 does not remove Bluetooth devices when they disconnect.
|
||||
Solaar 1.1.12 processes the DBus disconnection and connection messages from Bluez and does re-initialize devices when they reconnect.
|
||||
The HID++ driver does not re-initialize devices, which causes problems with smooth scrolling.
|
||||
Until the problem is resolved having Scroll Wheel Resolution set to true (and not ignored) may be helpful.
|
||||
|
||||
- The Linux HID++ driver modifies the Scroll Wheel Resolution setting to
|
||||
implement smooth scrolling. If Solaar changes this setting, scrolling
|
||||
can be either very fast or very slow. To fix this problem
|
||||
click on the icon at the right edge of the setting to set it to
|
||||
"Ignore this setting", which is the default for new devices.
|
||||
The mouse has to be reset (e.g., by turning it off and on again) before this fix will take effect.
|
||||
|
||||
- Solaar expects that it has exclusive control over settings that are not ignored.
|
||||
Running other programs that modify these settings, such as logiops,
|
||||
will likely result in unexpected device behavior.
|
||||
|
||||
- The driver also sets the scrolling direction to its normal setting when implementing smooth scrolling.
|
||||
This can interfere with the Scroll Wheel Direction setting, requiring flipping this setting back and forth
|
||||
to restore reversed scrolling.
|
||||
|
||||
- The driver sends messages to devices that do not conform with the Logitech HID++ specification
|
||||
resulting in responses being sent back that look like other messages. For some devices this causes
|
||||
Solaar to report incorrect battery levels.
|
||||
|
||||
- Solaar normally uses icon names for its icons, which in some system tray implementations
|
||||
results in missing or wrong-sized icons.
|
||||
The `--tray-icon-size` option forces Solaar to use icon files of appropriate size
|
||||
for tray icons instead, which produces better results in some system tray implementations.
|
||||
To use icon files close to 32 pixels in size use `--tray-icon-size=32`.
|
||||
|
||||
- The icon in the system tray can show up as 'black on black' in dark
|
||||
themes or as non-symbolic when the theme uses symbolic icons. This is due to problems
|
||||
in some system tray implementations. Changing to a different theme may help.
|
||||
The `--battery-icons=symbolic` option can be used to force symbolic icons.
|
||||
|
||||
- Solaar will try to use uinput to simulate input from rules under Wayland or if Xtest is not available
|
||||
but this needs write permission on /dev/uinput.
|
||||
For more information see [the rules page](https://pwr-solaar.github.io/Solaar/rules).
|
||||
|
||||
- Diverted keys remain diverted and so do not have their normal behavior when Solaar terminates
|
||||
or a device disconnects from a host that is running Solaar. If necessary, their normal behavior
|
||||
can be reestablished by turning the device off and on again. This is most important to restore
|
||||
the host switching behavior of a host switch key that was diverted, for example to switch away
|
||||
from a host that crashed or was turned off.
|
||||
|
||||
- When a receiver-connected device changes hosts Solaar remembers which diverted keys were down on it.
|
||||
When the device changes back the first time any of these diverted keys is depressed Solaar will not
|
||||
realize that the key was newly depressed. For this reason Solaar rules that can change hosts should
|
||||
trigger on key releasing.
|
||||
|
||||
## License
|
||||
|
||||
This software is distributed under the terms of the
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
so that Solaar rules can simulate input in Wayland.
|
||||
Finally, install Solaar via `make install_pip` or `make install_pipx`.
|
||||
so that Solaar rules can simulate input in Wayland.
|
||||
|
||||
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` and `python-typing-extensions'.
|
||||
Other system packages may be required depending on your distribution, such as `python-gobject-common-devel`.
|
||||
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,11 +92,10 @@ 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.
|
||||
|
||||
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 `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`.
|
||||
|
||||
If the `gitinfo` Python package is available, Solaar shows better information
|
||||
about which version of Solaar is running.
|
||||
|
|
@ -132,6 +131,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`](https://github.com/pwr-Solaar/Solaar/blob/master/share/autostart/solaar.desktop).
|
||||
[`share/autostart/solaar.desktop`](/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.
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
---
|
||||
title: Known Issues
|
||||
layout: page
|
||||
---
|
||||
|
||||
# Known Issues
|
||||
|
||||
- Some internal structures in Solaar have been updated to use more standard Python language features.
|
||||
This has caused some problems and introduced bugs are still being found.
|
||||
|
||||
- Onboard Profiles, when active, can prevent changes to other settings, such as Polling Rate, DPI, and various LED settings. Which settings are affected depends on the device. To make changes to affected settings, disable Onboard Profiles. If Onboard Profiles are later enabled the affected settings may change to the value in the profile.
|
||||
|
||||
- Bluez 5.73 does not remove Bluetooth devices when they disconnect.
|
||||
Solaar 1.1.12 processes the DBus disconnection and connection messages from Bluez and does re-initialize devices when they reconnect.
|
||||
The HID++ driver does not re-initialize devices, which causes problems with smooth scrolling.
|
||||
Until the problem is resolved having Scroll Wheel Resolution set to true (and not ignored) may be helpful.
|
||||
|
||||
- The Linux HID++ driver modifies the Scroll Wheel Resolution setting to
|
||||
implement smooth scrolling. If Solaar changes this setting, scrolling
|
||||
can be either very fast or very slow. To fix this problem
|
||||
click on the icon at the right edge of the setting to set it to
|
||||
"Ignore this setting", which is the default for new devices.
|
||||
The mouse has to be reset (e.g., by turning it off and on again) before this fix will take effect.
|
||||
|
||||
- Solaar expects that it has exclusive control over settings that are not ignored.
|
||||
Running other programs that modify these settings, such as logiops,
|
||||
will likely result in unexpected device behavior.
|
||||
|
||||
- The driver also sets the scrolling direction to its normal setting when implementing smooth scrolling.
|
||||
This can interfere with the Scroll Wheel Direction setting, requiring flipping this setting back and forth
|
||||
to restore reversed scrolling.
|
||||
|
||||
- The driver sends messages to devices that do not conform with the Logitech HID++ specification
|
||||
resulting in responses being sent back that look like other messages. For some devices this causes
|
||||
Solaar to report incorrect battery levels.
|
||||
|
||||
- Solaar normally uses icon names for its icons, which in some system tray implementations
|
||||
results in missing or wrong-sized icons.
|
||||
The `--tray-icon-size` option forces Solaar to use icon files of appropriate size
|
||||
for tray icons instead, which produces better results in some system tray implementations.
|
||||
To use icon files close to 32 pixels in size use `--tray-icon-size=32`.
|
||||
|
||||
- The icon in the system tray can show up as 'black on black' in dark
|
||||
themes or as non-symbolic when the theme uses symbolic icons. This is due to problems
|
||||
in some system tray implementations. Changing to a different theme may help.
|
||||
The `--battery-icons=symbolic` option can be used to force symbolic icons.
|
||||
|
||||
- Solaar will try to use uinput to simulate input from rules under Wayland or if Xtest is not available
|
||||
but this needs write permission on /dev/uinput.
|
||||
For more information see [the rules page](https://pwr-solaar.github.io/Solaar/rules).
|
||||
|
||||
- Diverted keys remain diverted and so do not have their normal behavior when Solaar terminates
|
||||
or a device disconnects from a host that is running Solaar. If necessary, their normal behavior
|
||||
can be reestablished by turning the device off and on again. This is most important to restore
|
||||
the host switching behavior of a host switch key that was diverted, for example to switch away
|
||||
from a host that crashed or was turned off.
|
||||
|
||||
- When a receiver-connected device changes hosts Solaar remembers which diverted keys were down on it.
|
||||
When the device changes back the first time any of these diverted keys is depressed Solaar will not
|
||||
realize that the key was newly depressed. For this reason Solaar rules that can change hosts should
|
||||
trigger on key releasing.
|
||||
|
|
@ -133,9 +133,8 @@ or the window's Window manager class or instance name starts with their string a
|
|||
`Device` conditions are true if a particular device originated the notification.
|
||||
`Active` conditions are true if a particular device is active.
|
||||
`Device` and `Active` conditions take one argument, which is the serial number or unit ID of a device,
|
||||
as shown in Solaar's detail pane, 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.
|
||||
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.
|
||||
|
||||
### Host
|
||||
`Host` conditions are true if the computers hostname starts with the condition's argument.
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -181,8 +181,15 @@ 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:
|
||||
|
|
@ -221,7 +228,7 @@ class _DeviceMonitor(Thread):
|
|||
|
||||
def _match(
|
||||
action: str,
|
||||
device: dict[str, Any],
|
||||
device,
|
||||
filter_func: Callable[[int, int, int, bool, bool], dict[str, Any]],
|
||||
):
|
||||
"""
|
||||
|
|
@ -233,7 +240,6 @@ def _match(
|
|||
|
||||
vid = device["vendor_id"]
|
||||
pid = device["product_id"]
|
||||
hid_bus_type = device["bus_type"]
|
||||
|
||||
# Translate hidapi bus_type to the bus_id values Solaar expects
|
||||
if device.get("bus_type") == 0x01:
|
||||
|
|
@ -242,55 +248,35 @@ def _match(
|
|||
bus_id = 0x05 # Bluetooth
|
||||
else:
|
||||
bus_id = None
|
||||
logger.info(f"Device {device['path']} has an unsupported bus type {hid_bus_type:02X}")
|
||||
return None
|
||||
|
||||
# Skip unlikely devices with all-zero VID PID or unsupported bus IDs
|
||||
if vid == 0 and pid == 0:
|
||||
logger.info(f"Device {device['path']} has all-zero VID and PID")
|
||||
logger.info(f"Skipping unlikely device {device['path']} ({bus_id}/{vid:04X}/{pid:04X})")
|
||||
return None
|
||||
|
||||
# Check for hidpp support
|
||||
device["hidpp_short"] = False
|
||||
device["hidpp_long"] = False
|
||||
device_handle = None
|
||||
|
||||
def check_hidpp_short():
|
||||
try:
|
||||
device_handle = open_path(device["path"])
|
||||
report = _get_input_report(device_handle, 0x10, 32)
|
||||
if len(report) == 1 + 6 and report[0] == 0x10:
|
||||
device["hidpp_short"] = True
|
||||
|
||||
def check_hidpp_long():
|
||||
report = _get_input_report(device_handle, 0x11, 32)
|
||||
if len(report) == 1 + 19 and report[0] == 0x11:
|
||||
device["hidpp_long"] = True
|
||||
|
||||
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}")
|
||||
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
|
||||
finally:
|
||||
if device_handle:
|
||||
close(device_handle)
|
||||
|
||||
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 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"],
|
||||
)
|
||||
|
||||
if not device["hidpp_short"] and not device["hidpp_long"]:
|
||||
return None
|
||||
|
|
@ -336,8 +322,6 @@ def _match(
|
|||
)
|
||||
return d_info
|
||||
|
||||
logger.info(f"Finished checking HIDPP support for device {device['path']} ({bus_id}/{vid:04X}/{pid:04X})")
|
||||
|
||||
|
||||
def find_paired_node(receiver_path: str, index: int, timeout: int):
|
||||
"""Find the node of a device paired with a receiver"""
|
||||
|
|
@ -409,7 +393,7 @@ def open(vendor_id, product_id, serial=None):
|
|||
return device_handle
|
||||
|
||||
|
||||
def open_path(device_path: str) -> int:
|
||||
def open_path(device_path) -> Any:
|
||||
"""Open a HID device by its path name.
|
||||
|
||||
:param device_path: the path of a ``DeviceInfo`` tuple returned by enumerate().
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ print_lock = Lock()
|
|||
def _print(marker, data, scroll=False):
|
||||
t = time.time() - start_time
|
||||
if isinstance(data, str):
|
||||
s = f"{marker} {data}"
|
||||
s = marker + " " + data
|
||||
else:
|
||||
hexs = strhex(data)
|
||||
s = "%s (% 8.3f) [%s %s %s %s] %s" % (marker, t, hexs[0:2], hexs[2:4], hexs[4:8], hexs[8:], repr(data))
|
||||
|
|
@ -90,7 +90,7 @@ def _continuous_read(handle, timeout=2000):
|
|||
try:
|
||||
reply = hidapi.read(handle, 128, timeout)
|
||||
except OSError as e:
|
||||
_error(f"Read failed, aborting: {str(e)}", True)
|
||||
_error("Read failed, aborting: " + str(e), True)
|
||||
break
|
||||
assert reply is not None
|
||||
if reply:
|
||||
|
|
@ -101,7 +101,7 @@ def _validate_input(line, hidpp=False):
|
|||
try:
|
||||
data = unhexlify(line.encode("ascii"))
|
||||
except Exception as e:
|
||||
_error(f"Invalid input: {str(e)}")
|
||||
_error("Invalid input: " + str(e))
|
||||
return None
|
||||
|
||||
if hidpp:
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
|
|||
interest to Solaar. It is given the bus id, vendor id, and product
|
||||
id and returns a dictionary with the required hid_driver and
|
||||
usb_interface and whether this is a receiver or device."""
|
||||
logger.debug(f"Dbus event {action} {device}")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
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
|
||||
|
|
@ -136,17 +137,18 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
|
|||
intf_device = device.find_parent("usb", "usb_interface")
|
||||
usb_interface = None if intf_device is None else intf_device.attributes.asint("bInterfaceNumber")
|
||||
# print('*** usb interface', action, device, 'usb_interface:', intf_device, usb_interface, interface_number)
|
||||
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 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,
|
||||
)
|
||||
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
|
||||
|
|
@ -266,7 +268,8 @@ def monitor_glib(glib: GLib, callback: Callable, filter_func: Callable):
|
|||
except Exception:
|
||||
glib.io_add_watch(m, glib.IO_IN, _process_udev_event, callback, filter_func)
|
||||
|
||||
logger.debug("Starting dbus monitoring")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Starting dbus monitoring")
|
||||
m.start()
|
||||
|
||||
|
||||
|
|
@ -279,7 +282,8 @@ def enumerate(filter_func: typing.Callable[[int, int, int, bool, bool], dict[str
|
|||
:returns: a list of matching ``DeviceInfo`` tuples.
|
||||
"""
|
||||
|
||||
logger.debug("Starting dbus enumeration")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
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:
|
||||
|
|
@ -323,7 +327,7 @@ def open_path(device_path):
|
|||
if e.errno == errno.EACCES:
|
||||
sleep(0.1)
|
||||
else:
|
||||
raise e
|
||||
raise
|
||||
|
||||
|
||||
def close(device_handle) -> None:
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ else:
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HIDProtocol(typing.Protocol):
|
||||
class HIDAPI(typing.Protocol):
|
||||
def find_paired_node_wpid(self, receiver_path: str, index: int):
|
||||
...
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ class HIDProtocol(typing.Protocol):
|
|||
def open(self, vendor_id, product_id, serial=None):
|
||||
...
|
||||
|
||||
def open_path(self, path) -> int:
|
||||
def open_path(self, path):
|
||||
...
|
||||
|
||||
def enumerate(self, filter_func: Callable[[int, int, int, bool, bool], dict[str, typing.Any]]) -> DeviceInfo:
|
||||
|
|
@ -87,6 +87,8 @@ class HIDProtocol(typing.Protocol):
|
|||
...
|
||||
|
||||
|
||||
hidapi = typing.cast(HIDAPI, hidapi)
|
||||
|
||||
SHORT_MESSAGE_SIZE = 7
|
||||
_LONG_MESSAGE_SIZE = 20
|
||||
_MEDIUM_MESSAGE_SIZE = 15
|
||||
|
|
@ -106,11 +108,6 @@ _DEVICE_REQUEST_TIMEOUT = DEFAULT_TIMEOUT
|
|||
# when pinging, be extra patient (no longer)
|
||||
_PING_TIMEOUT = DEFAULT_TIMEOUT
|
||||
|
||||
hidapi = typing.cast(HIDProtocol, hidapi)
|
||||
|
||||
request_lock = threading.Lock() # serialize all requests
|
||||
handles_lock = {}
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class HIDPPNotification:
|
||||
|
|
@ -149,68 +146,26 @@ for _ignore, d in descriptors.DEVICES.items():
|
|||
KNOWN_DEVICE_IDS.append(_bluetooth_device(d.btid))
|
||||
|
||||
|
||||
def _other_device_check(bus_id: int, vendor_id: int, product_id: int) -> dict[str, Any] | None:
|
||||
"""Check whether product is a Logitech USB-connected or Bluetooth device based on bus, vendor, and product IDs
|
||||
This allows Solaar to support receiverless HID++ 2.0 devices that it knows nothing about"""
|
||||
if vendor_id != LOGITECH_VENDOR_ID:
|
||||
return
|
||||
|
||||
device_info = None
|
||||
if bus_id == BusID.USB and (0xC07D <= product_id <= 0xC094 or 0xC32B <= product_id <= 0xC344):
|
||||
device_info = _usb_device(product_id, 2)
|
||||
elif bus_id == BusID.BLUETOOTH and (0xB012 <= product_id <= 0xB0FF or 0xB317 <= product_id <= 0xB3FF):
|
||||
device_info = _bluetooth_device(product_id)
|
||||
return device_info
|
||||
|
||||
|
||||
def product_information(usb_id: int) -> dict[str, Any]:
|
||||
"""Returns hardcoded information from USB receiver."""
|
||||
return base_usb.get_receiver_info(usb_id)
|
||||
|
||||
|
||||
def 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):
|
||||
def _match(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)
|
||||
|
|
@ -218,7 +173,7 @@ def _match_device(record: dict[str, Any], bus_id: int, vendor_id: int, product_i
|
|||
)
|
||||
|
||||
|
||||
def get_known_receiver_info(
|
||||
def _filter_receivers(
|
||||
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.
|
||||
|
|
@ -229,7 +184,7 @@ def get_known_receiver_info(
|
|||
"""
|
||||
try:
|
||||
record = base_usb.get_receiver_info(product_id)
|
||||
if _match_device(record, bus_id, vendor_id, product_id):
|
||||
if _match(record, bus_id, vendor_id, product_id):
|
||||
return record
|
||||
except ValueError:
|
||||
pass
|
||||
|
|
@ -239,9 +194,32 @@ def get_known_receiver_info(
|
|||
return None
|
||||
|
||||
|
||||
def receivers():
|
||||
"""Enumerate all the receivers attached to the machine."""
|
||||
yield from hidapi.enumerate(_filter_receivers)
|
||||
|
||||
|
||||
def _filter_products_of_interest(
|
||||
bus_id: int, vendor_id: int, product_id: int, hidpp_short: bool = False, hidpp_long: bool = False
|
||||
) -> dict[str, Any] | None:
|
||||
"""Check that this product is of interest and if so return the device record for further checking"""
|
||||
record = _filter_receivers(bus_id, vendor_id, product_id, hidpp_short, hidpp_long)
|
||||
if record: # known or unknown receiver
|
||||
return record
|
||||
|
||||
for record in KNOWN_DEVICE_IDS:
|
||||
if _match(record, bus_id, vendor_id, product_id):
|
||||
return record
|
||||
if hidpp_short or hidpp_long: # unknown devices that use HID++
|
||||
return {"vendor_id": vendor_id, "product_id": product_id, "bus_id": bus_id, "isDevice": True}
|
||||
elif hidpp_short is None and hidpp_long is None: # unknown devices in correct range of IDs
|
||||
return _other_device_check(bus_id, vendor_id, product_id)
|
||||
return None
|
||||
|
||||
|
||||
def receivers_and_devices():
|
||||
"""Enumerate all the receivers and devices directly attached to the machine."""
|
||||
yield from hidapi.enumerate(filter_products_of_interest)
|
||||
yield from hidapi.enumerate(_filter_products_of_interest)
|
||||
|
||||
|
||||
def notify_on_receivers_glib(glib: GLib, callback: Callable):
|
||||
|
|
@ -252,10 +230,10 @@ def notify_on_receivers_glib(glib: GLib, callback: Callable):
|
|||
glib
|
||||
GLib instance.
|
||||
"""
|
||||
return hidapi.monitor_glib(glib, callback, filter_products_of_interest)
|
||||
return hidapi.monitor_glib(glib, callback, _filter_products_of_interest)
|
||||
|
||||
|
||||
def open_path(path) -> int:
|
||||
def open_path(path):
|
||||
"""Checks if the given Linux device path points to the right UR device.
|
||||
|
||||
:param path: the Linux device path.
|
||||
|
|
@ -378,7 +356,7 @@ def _is_relevant_message(data: bytes) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _read(handle, timeout) -> tuple[int, int, bytes]:
|
||||
def _read(handle, timeout):
|
||||
"""Read an incoming packet from the receiver.
|
||||
|
||||
:returns: a tuple of (report_id, devnumber, data), or `None`.
|
||||
|
|
@ -415,6 +393,33 @@ def _read(handle, timeout) -> tuple[int, int, bytes]:
|
|||
return report_id, devnumber, data[2:]
|
||||
|
||||
|
||||
def _skip_incoming(handle, ihandle, notifications_hook):
|
||||
"""Read anything already in the input buffer.
|
||||
|
||||
Used by request() and ping() before their write.
|
||||
"""
|
||||
|
||||
while True:
|
||||
try:
|
||||
# read whatever is already in the buffer, if any
|
||||
data = hidapi.read(ihandle, _MAX_READ_SIZE, 0)
|
||||
except Exception as reason:
|
||||
logger.error("read failed, assuming receiver %s no longer available", handle)
|
||||
close(handle)
|
||||
raise exceptions.NoReceiver(reason=reason) from reason
|
||||
|
||||
if data:
|
||||
if _is_relevant_message(data): # only process messages that pass check
|
||||
# report_id = ord(data[:1])
|
||||
if notifications_hook:
|
||||
n = make_notification(ord(data[:1]), ord(data[1:2]), data[2:])
|
||||
if n:
|
||||
notifications_hook(n)
|
||||
else:
|
||||
# nothing in the input buffer, we're done
|
||||
return
|
||||
|
||||
|
||||
def make_notification(report_id: int, devnumber: int, data: bytes) -> HIDPPNotification | None:
|
||||
"""Guess if this is a notification (and not just a request reply), and
|
||||
return a Notification if it is."""
|
||||
|
|
@ -450,6 +455,10 @@ def make_notification(report_id: int, devnumber: int, data: bytes) -> HIDPPNotif
|
|||
return None
|
||||
|
||||
|
||||
request_lock = threading.Lock() # serialize all requests
|
||||
handles_lock = {}
|
||||
|
||||
|
||||
def handle_lock(handle):
|
||||
with request_lock:
|
||||
if handles_lock.get(handle) is None:
|
||||
|
|
@ -472,6 +481,22 @@ def acquire_timeout(lock, handle, timeout):
|
|||
lock.release()
|
||||
|
||||
|
||||
def _get_next_sw_id() -> int:
|
||||
"""Returns 'random' software ID to separate replies from different devices.
|
||||
|
||||
Cycle the HID++ 2.0 software ID from 0x2 to 0xF to separate
|
||||
results and notifications.
|
||||
"""
|
||||
if not hasattr(_get_next_sw_id, "software_id"):
|
||||
_get_next_sw_id.software_id = 0xF
|
||||
|
||||
if _get_next_sw_id.software_id < 0xF:
|
||||
_get_next_sw_id.software_id += 1
|
||||
else:
|
||||
_get_next_sw_id.software_id = 2
|
||||
return _get_next_sw_id.software_id
|
||||
|
||||
|
||||
def find_paired_node(receiver_path: str, index: int, timeout: int):
|
||||
"""Find the node of a device paired with a receiver."""
|
||||
return hidapi.find_paired_node(receiver_path, index, timeout)
|
||||
|
|
@ -526,7 +551,7 @@ def request(
|
|||
ihandle = int(handle)
|
||||
notifications_hook = getattr(handle, "notifications_hook", None)
|
||||
try:
|
||||
_read_input_buffer(handle, ihandle, notifications_hook)
|
||||
_skip_incoming(handle, ihandle, notifications_hook)
|
||||
except exceptions.NoReceiver:
|
||||
logger.warning("device or receiver disconnected")
|
||||
return None
|
||||
|
|
@ -623,7 +648,7 @@ def ping(handle, devnumber, long_message: bool = False):
|
|||
with acquire_timeout(handle_lock(handle), handle, 10.0):
|
||||
notifications_hook = getattr(handle, "notifications_hook", None)
|
||||
try:
|
||||
_read_input_buffer(handle, int(handle), notifications_hook)
|
||||
_skip_incoming(handle, int(handle), notifications_hook)
|
||||
except exceptions.NoReceiver:
|
||||
logger.warning("device or receiver disconnected")
|
||||
return
|
||||
|
|
@ -668,46 +693,3 @@ def ping(handle, devnumber, long_message: bool = False):
|
|||
delta = time() - request_started
|
||||
|
||||
logger.warning("(%s) timeout (%0.2f/%0.2f) on device %d ping", handle, delta, _PING_TIMEOUT, devnumber)
|
||||
|
||||
|
||||
def _read_input_buffer(handle, ihandle, notifications_hook):
|
||||
"""Consume anything already in the input buffer.
|
||||
|
||||
Used by request() and ping() before their write.
|
||||
"""
|
||||
|
||||
while True:
|
||||
try:
|
||||
# read whatever is already in the buffer, if any
|
||||
data = hidapi.read(ihandle, _MAX_READ_SIZE, 0)
|
||||
except Exception as reason:
|
||||
logger.error("read failed, assuming receiver %s no longer available", handle)
|
||||
close(handle)
|
||||
raise exceptions.NoReceiver(reason=reason) from reason
|
||||
|
||||
if data:
|
||||
if _is_relevant_message(data): # only process messages that pass check
|
||||
# report_id = ord(data[:1])
|
||||
if notifications_hook:
|
||||
n = make_notification(ord(data[:1]), ord(data[1:2]), data[2:])
|
||||
if n:
|
||||
notifications_hook(n)
|
||||
else:
|
||||
# nothing in the input buffer, we're done
|
||||
return
|
||||
|
||||
|
||||
def _get_next_sw_id() -> int:
|
||||
"""Returns 'random' software ID to separate replies from different devices.
|
||||
|
||||
Cycle the HID++ 2.0 software ID from 0x2 to 0xF to separate
|
||||
results and notifications.
|
||||
"""
|
||||
if not hasattr(_get_next_sw_id, "software_id"):
|
||||
_get_next_sw_id.software_id = 0xF
|
||||
|
||||
if _get_next_sw_id.software_id < 0xF:
|
||||
_get_next_sw_id.software_id += 1
|
||||
else:
|
||||
_get_next_sw_id.software_id = 2
|
||||
return _get_next_sw_id.software_id
|
||||
|
|
|
|||
|
|
@ -27,10 +27,6 @@ USB ids of Logitech wireless receivers.
|
|||
Only receivers supporting the HID++ protocol can go in here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from solaar.i18n import _
|
||||
|
||||
# max_devices is only used for receivers that do not support reading from Registers.RECEIVER_INFO offset 0x03, default
|
||||
|
|
@ -178,58 +174,49 @@ LIGHTSPEED_RECEIVER_C547 = _lightspeed_receiver(0xC547)
|
|||
# EX100 old style receiver pre-unifying protocol
|
||||
EX100_27MHZ_RECEIVER_C517 = _ex100_receiver(0xC517)
|
||||
|
||||
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,
|
||||
}
|
||||
KNOWN_RECEIVERS = (
|
||||
BOLT_RECEIVER_C548,
|
||||
UNIFYING_RECEIVER_C52B,
|
||||
UNIFYING_RECEIVER_C532,
|
||||
NANO_RECEIVER_ADVANCED,
|
||||
NANO_RECEIVER_C518,
|
||||
NANO_RECEIVER_C51A,
|
||||
NANO_RECEIVER_C51B,
|
||||
NANO_RECEIVER_C521,
|
||||
NANO_RECEIVER_C525,
|
||||
NANO_RECEIVER_C526,
|
||||
NANO_RECEIVER_C52E,
|
||||
NANO_RECEIVER_C531,
|
||||
NANO_RECEIVER_C534,
|
||||
NANO_RECEIVER_C535,
|
||||
NANO_RECEIVER_C537,
|
||||
NANO_RECEIVER_6042,
|
||||
LIGHTSPEED_RECEIVER_C539,
|
||||
LIGHTSPEED_RECEIVER_C53A,
|
||||
LIGHTSPEED_RECEIVER_C53D,
|
||||
LIGHTSPEED_RECEIVER_C53F,
|
||||
LIGHTSPEED_RECEIVER_C541,
|
||||
LIGHTSPEED_RECEIVER_C545,
|
||||
LIGHTSPEED_RECEIVER_C547,
|
||||
EX100_27MHZ_RECEIVER_C517,
|
||||
)
|
||||
|
||||
|
||||
def get_receiver_info(product_id: int) -> dict[str, Any]:
|
||||
"""Returns hardcoded information about a Logitech receiver.
|
||||
def get_receiver_info(product_id: int) -> dict:
|
||||
"""Returns hardcoded information about Logitech receiver.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
product_id
|
||||
Product ID (pid) of the receiver, e.g. 0xC548 for a Logitech
|
||||
Bolt receiver.
|
||||
Product ID of 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.
|
||||
dict
|
||||
Product info with mandatory vendor_id, product_id,
|
||||
usb_interface, name, receiver_kind
|
||||
"""
|
||||
try:
|
||||
return KNOWN_RECEIVERS[product_id]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
raise ValueError(f"Unknown product ID '0x{product_id:02X}'")
|
||||
for receiver in KNOWN_RECEIVERS:
|
||||
if product_id == receiver.get("product_id"):
|
||||
return receiver
|
||||
raise ValueError(f"Unknown product ID '0x{product_id:02X}")
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import binascii
|
|||
import dataclasses
|
||||
import typing
|
||||
|
||||
from enum import Flag
|
||||
from enum import IntEnum
|
||||
from typing import Generator
|
||||
from typing import Iterable
|
||||
|
|
@ -333,7 +332,7 @@ class NamedInt(int):
|
|||
return self.name.lower() == other.lower()
|
||||
# this should catch comparisons with bytes in Py3
|
||||
if other is not None:
|
||||
raise TypeError(f"Unsupported type {str(type(other))}")
|
||||
raise TypeError("Unsupported type " + str(type(other)))
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
|
@ -467,7 +466,7 @@ class NamedInts:
|
|||
def __setitem__(self, index, name):
|
||||
assert isinstance(index, int), type(index)
|
||||
if isinstance(name, NamedInt):
|
||||
assert int(index) == int(name), f"{repr(index)} {repr(name)}"
|
||||
assert int(index) == int(name), repr(index) + " " + repr(name)
|
||||
value = name
|
||||
elif isinstance(name, str):
|
||||
value = NamedInt(index, name)
|
||||
|
|
@ -590,7 +589,7 @@ class FirmwareInfo:
|
|||
extras: str | None
|
||||
|
||||
|
||||
class BatteryStatus(Flag):
|
||||
class BatteryStatus(IntEnum):
|
||||
DISCHARGING = 0x00
|
||||
RECHARGING = 0x01
|
||||
ALMOST_FULL = 0x02
|
||||
|
|
@ -650,7 +649,8 @@ class Battery:
|
|||
elif isinstance(self.level, int):
|
||||
status = self.status.name.lower().replace("_", " ") if self.status is not None else "Unknown"
|
||||
return _("Battery: %(percent)d%% (%(status)s)") % {"percent": self.level, "status": _(status)}
|
||||
return ""
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
class Alert(IntEnum):
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ if available:
|
|||
n.set_urgency(Notify.Urgency.NORMAL)
|
||||
n.set_hint("desktop-entry", GLib.Variant("s", "solaar")) # replace with better name late
|
||||
try:
|
||||
return n.show()
|
||||
n.show()
|
||||
except Exception:
|
||||
logger.exception(f"showing {n}")
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import threading
|
|||
import time
|
||||
import typing
|
||||
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import Optional
|
||||
from typing import Protocol
|
||||
|
|
@ -38,7 +39,6 @@ from . import settings
|
|||
from . import settings_templates
|
||||
from .common import Alert
|
||||
from .common import Battery
|
||||
from .hidpp10_constants import NotificationFlag
|
||||
from .hidpp20_constants import SupportedFeature
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
|
|
@ -51,7 +51,7 @@ _hidpp20 = hidpp20.Hidpp20()
|
|||
|
||||
|
||||
class LowLevelInterface(Protocol):
|
||||
def open_path(self, path) -> int:
|
||||
def open_path(self, path) -> Any:
|
||||
...
|
||||
|
||||
def find_paired_node(self, receiver_path: str, index: int, timeout: int):
|
||||
|
|
@ -87,10 +87,10 @@ def create_device(low_level: LowLevelInterface, device_info, setting_callback=No
|
|||
except OSError as e:
|
||||
logger.exception("open %s", device_info)
|
||||
if e.errno == errno.EACCES:
|
||||
raise e
|
||||
except Exception as e:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("open %s", device_info)
|
||||
raise e
|
||||
raise
|
||||
|
||||
|
||||
class Device:
|
||||
|
|
@ -147,7 +147,6 @@ 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()
|
||||
|
|
@ -207,10 +206,10 @@ class Device:
|
|||
|
||||
Device.instances.append(self)
|
||||
|
||||
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"
|
||||
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"
|
||||
for device in Device.instances:
|
||||
if device.online and (device.unitId == id or device.serial == id or device.name == id or device.codename == id):
|
||||
if device.online and (device.unitId == id or device.serial == id):
|
||||
return device
|
||||
|
||||
@property
|
||||
|
|
@ -232,14 +231,14 @@ class Device:
|
|||
self._codename = codename
|
||||
elif self.protocol < 2.0:
|
||||
self._codename = "? (%s)" % (self.wpid or self.product_id)
|
||||
return self._codename or f"?? ({self.wpid or self.product_id})"
|
||||
return self._codename or "?? (%s)" % (self.wpid or self.product_id)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
if not self._name:
|
||||
if self.online and self.protocol >= 2.0:
|
||||
self._name = _hidpp20.get_name(self)
|
||||
return self._name or self._codename or f"Unknown device {self.wpid or self.product_id}"
|
||||
return self._name or self._codename or "Unknown device %s" % (self.wpid or self.product_id)
|
||||
|
||||
def get_ids(self):
|
||||
ids = _hidpp20.get_ids(self)
|
||||
|
|
@ -397,8 +396,8 @@ class Device:
|
|||
self.persister["_battery"] = feature.value
|
||||
return battery
|
||||
except Exception:
|
||||
if self.persister and battery_feature is None and result is not None and result != 0:
|
||||
self.persister["_battery"] = result.value
|
||||
if self.persister and battery_feature is None:
|
||||
self.persister["_battery"] = result
|
||||
|
||||
def set_battery_info(self, info):
|
||||
"""Update battery information for device, calling changed callback if necessary"""
|
||||
|
|
@ -433,8 +432,6 @@ class Device:
|
|||
def changed(self, active=None, alert=Alert.NONE, reason=None, push=False):
|
||||
"""The status of the device had changed, so invoke the status callback.
|
||||
Also push notifications and settings to the device when necessary."""
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("device %d changing: active=%s %s present=%s", self.number, active, self._active, self.present)
|
||||
if active is not None:
|
||||
self.online = active
|
||||
was_active, self._active = self._active, active
|
||||
|
|
@ -470,7 +467,11 @@ class Device:
|
|||
return False
|
||||
|
||||
if enable:
|
||||
set_flag_bits = NotificationFlag.BATTERY_STATUS | NotificationFlag.UI | NotificationFlag.CONFIGURATION_COMPLETE
|
||||
set_flag_bits = (
|
||||
hidpp10_constants.NOTIFICATION_FLAG.battery_status
|
||||
| hidpp10_constants.NOTIFICATION_FLAG.ui
|
||||
| hidpp10_constants.NOTIFICATION_FLAG.configuration_complete
|
||||
)
|
||||
else:
|
||||
set_flag_bits = 0
|
||||
ok = _hidpp10.set_notification_flags(self, set_flag_bits)
|
||||
|
|
@ -479,12 +480,8 @@ class Device:
|
|||
|
||||
flag_bits = _hidpp10.get_notification_flags(self)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
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}")
|
||||
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)
|
||||
return flag_bits if ok else None
|
||||
|
||||
def add_notification_handler(self, id: str, fn):
|
||||
|
|
@ -522,7 +519,7 @@ class Device:
|
|||
self.hidpp_long is None and (self.bluetooth or self._protocol is not None and self._protocol >= 2.0)
|
||||
)
|
||||
return self.low_level.request(
|
||||
self.handle or (self.receiver.handle if self.receiver else None),
|
||||
self.handle or self.receiver.handle,
|
||||
self.number,
|
||||
request_id,
|
||||
*params,
|
||||
|
|
@ -536,21 +533,15 @@ class Device:
|
|||
return hidpp20.feature_request(self, feature, function, *params, no_reply=no_reply)
|
||||
|
||||
def ping(self):
|
||||
"""Checks if the device is online and present, returns True of False.
|
||||
Some devices are integral with their receiver but may not be present even if the receiver responds to ping."""
|
||||
"""Checks if the device is online, returns True of False"""
|
||||
long = self.hidpp_long is True or (
|
||||
self.hidpp_long is None and (self.bluetooth or self._protocol is not None and self._protocol >= 2.0)
|
||||
)
|
||||
handle = self.handle or self.receiver.handle
|
||||
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
|
||||
protocol = self.low_level.ping(handle, self.number, long_message=long)
|
||||
self.online = protocol is not None
|
||||
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
|
||||
|
|
|
|||
|
|
@ -239,8 +239,6 @@ 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
|
||||
|
|
@ -366,7 +364,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 evdev and simulate_uinput(evdev.ecodes.EV_KEY, code - 8, event):
|
||||
if simulate_uinput(evdev.ecodes.EV_KEY, code - 8, event):
|
||||
return True
|
||||
logger.warning("no way to simulate key input")
|
||||
|
||||
|
|
@ -543,7 +541,7 @@ class Rule(RuleComponent):
|
|||
self.source = source
|
||||
|
||||
def __str__(self):
|
||||
source = f"({self.source})" if self.source else ""
|
||||
source = "(" + self.source + ")" if self.source else ""
|
||||
return f"Rule{source}[{', '.join([c.__str__() for c in self.components])}]"
|
||||
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
|
|
@ -580,7 +578,7 @@ class Not(Condition):
|
|||
self.component = self.compile(op)
|
||||
|
||||
def __str__(self):
|
||||
return f"Not: {str(self.component)}"
|
||||
return "Not: " + str(self.component)
|
||||
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
|
|
@ -693,7 +691,7 @@ class Process(Condition):
|
|||
self.process = str(process)
|
||||
|
||||
def __str__(self):
|
||||
return f"Process: {str(self.process)}"
|
||||
return "Process: " + str(self.process)
|
||||
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
|
|
@ -724,7 +722,7 @@ class MouseProcess(Condition):
|
|||
self.process = str(process)
|
||||
|
||||
def __str__(self):
|
||||
return f"MouseProcess: {str(self.process)}"
|
||||
return "MouseProcess: " + str(self.process)
|
||||
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
|
|
@ -742,14 +740,14 @@ class MouseProcess(Condition):
|
|||
class Feature(Condition):
|
||||
def __init__(self, feature: str, warn: bool = True):
|
||||
try:
|
||||
self.feature = SupportedFeature[feature.replace(" ", "_")]
|
||||
self.feature = SupportedFeature[feature]
|
||||
except KeyError:
|
||||
self.feature = None
|
||||
if warn:
|
||||
logger.warning("rule Feature argument not name of a feature: %s", feature)
|
||||
|
||||
def __str__(self):
|
||||
return f"Feature: {str(self.feature)}"
|
||||
return "Feature: " + str(self.feature)
|
||||
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
|
|
@ -770,7 +768,7 @@ class Report(Condition):
|
|||
self.report = report
|
||||
|
||||
def __str__(self):
|
||||
return f"Report: {str(self.report)}"
|
||||
return "Report: " + str(self.report)
|
||||
|
||||
def evaluate(self, report, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
|
|
@ -843,7 +841,7 @@ class Modifiers(Condition):
|
|||
logger.warning("unknown rule Modifier value: %s", k)
|
||||
|
||||
def __str__(self):
|
||||
return f"Modifiers: {str(self.desired)}"
|
||||
return "Modifiers: " + str(self.desired)
|
||||
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
|
|
@ -989,7 +987,7 @@ class Test(Condition):
|
|||
logger.warning("rule Test argument not valid %s", test)
|
||||
|
||||
def __str__(self):
|
||||
return f"Test: {str(self.test)}"
|
||||
return "Test: " + str(self.test)
|
||||
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
|
|
@ -1017,7 +1015,7 @@ class TestBytes(Condition):
|
|||
logger.warning("rule TestBytes argument not valid %s", test)
|
||||
|
||||
def __str__(self):
|
||||
return f"TestBytes: {str(self.test)}"
|
||||
return "TestBytes: " + str(self.test)
|
||||
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
|
|
@ -1092,7 +1090,7 @@ class Active(Condition):
|
|||
self.devID = devID
|
||||
|
||||
def __str__(self):
|
||||
return f"Active: {str(self.devID)}"
|
||||
return "Active: " + str(self.devID)
|
||||
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
|
|
@ -1113,17 +1111,12 @@ class Device(Condition):
|
|||
self.devID = devID
|
||||
|
||||
def __str__(self):
|
||||
return f"Device: {str(self.devID)}"
|
||||
return "Device: " + str(self.devID)
|
||||
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
return (
|
||||
device.unitId == self.devID
|
||||
or device.serial == self.devID
|
||||
or device.codename == self.devID
|
||||
or device.name == self.devID
|
||||
)
|
||||
return device.unitId == self.devID or device.serial == self.devID
|
||||
|
||||
def data(self):
|
||||
return {"Device": self.devID}
|
||||
|
|
@ -1138,7 +1131,7 @@ class Host(Condition):
|
|||
self.host = host
|
||||
|
||||
def __str__(self):
|
||||
return f"Host: {str(self.host)}"
|
||||
return "Host: " + str(self.host)
|
||||
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
|
|
@ -1430,7 +1423,7 @@ class Later(Action):
|
|||
self.components = self.rule.components
|
||||
|
||||
def __str__(self):
|
||||
return f"Later: [{str(self.delay)}, " + ", ".join(str(c) for c in self.components) + "]"
|
||||
return "Later: [" + str(self.delay) + ", " + ", ".join(str(c) for c in self.components) + "]"
|
||||
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if self.delay and self.rule:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ 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__)
|
||||
|
|
@ -191,7 +190,7 @@ class Hidpp10:
|
|||
def get_notification_flags(self, device: Device):
|
||||
return self._get_register(device, Registers.NOTIFICATIONS)
|
||||
|
||||
def set_notification_flags(self, device: Device, *flag_bits: NotificationFlag):
|
||||
def set_notification_flags(self, device: Device, *flag_bits):
|
||||
assert device is not None
|
||||
|
||||
# Avoid a call if the device is not online,
|
||||
|
|
@ -201,7 +200,7 @@ class Hidpp10:
|
|||
if device.protocol and device.protocol >= 2.0:
|
||||
return
|
||||
|
||||
flag_bits = sum(int(b.value) for b in flag_bits)
|
||||
flag_bits = sum(int(b) for b in flag_bits)
|
||||
assert flag_bits & 0x00FFFFFF == flag_bits
|
||||
result = write_register(device, Registers.NOTIFICATIONS, common.int2bytes(flag_bits, 3))
|
||||
return result is not None
|
||||
|
|
|
|||
|
|
@ -14,11 +14,7 @@
|
|||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Flag
|
||||
from enum import IntEnum
|
||||
from typing import List
|
||||
|
||||
from .common import NamedInts
|
||||
|
||||
|
|
@ -45,94 +41,51 @@ 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,
|
||||
)
|
||||
|
||||
class PowerSwitchLocation(IntEnum):
|
||||
UNKNOWN = 0x00
|
||||
BASE = 0x01
|
||||
TOP_CASE = 0x02
|
||||
EDGE_OF_TOP_RIGHT_CORNER = 0x03
|
||||
TOP_LEFT_CORNER = 0x05
|
||||
BOTTOM_LEFT_CORNER = 0x06
|
||||
TOP_RIGHT_CORNER = 0x07
|
||||
BOTTOM_RIGHT_CORNER = 0x08
|
||||
TOP_EDGE = 0x09
|
||||
RIGHT_EDGE = 0x0A
|
||||
LEFT_EDGE = 0x0B
|
||||
BOTTOM_EDGE = 0x0C
|
||||
|
||||
@classmethod
|
||||
def location(cls, loc: int) -> PowerSwitchLocation:
|
||||
try:
|
||||
return cls(loc)
|
||||
except ValueError:
|
||||
return cls.UNKNOWN
|
||||
|
||||
|
||||
class NotificationFlag(Flag):
|
||||
"""Some flags are used both by devices and receivers.
|
||||
|
||||
The Logitech documentation mentions that the first and last (third)
|
||||
byte are used for devices while the second is used for the receiver.
|
||||
In practise, the second byte is also used for some device-specific
|
||||
notifications (keyboard illumination level). Do not simply set all
|
||||
notification bits if the software does not support it. For example,
|
||||
enabling keyboard_sleep_raw makes the Sleep key a no-operation
|
||||
unless the software is updated to handle that event.
|
||||
|
||||
Observations:
|
||||
- wireless and software present seen on receivers,
|
||||
reserved_r1b4 as well
|
||||
- the rest work only on devices as far as we can tell right now
|
||||
In the future would be useful to have separate enums for receiver
|
||||
and device notification flags, but right now we don't know enough.
|
||||
Additional flags taken from https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def flag_names(cls, 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))
|
||||
# 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 ErrorCode(IntEnum):
|
||||
|
|
@ -202,56 +155,33 @@ class Registers(IntEnum):
|
|||
|
||||
|
||||
# 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
|
||||
INFO_SUBREGISTERS = NamedInts(
|
||||
serial_number=0x01, # not found on many receivers
|
||||
fw_version=0x02,
|
||||
receiver_information=0x03,
|
||||
pairing_information=0x20, # 0x2N, by connected device
|
||||
extended_pairing_information=0x30, # 0x3N, by connected device
|
||||
device_name=0x40, # 0x4N, by connected device
|
||||
bolt_pairing_information=0x50, # 0x5N, by connected device
|
||||
bolt_device_name=0x60, # 0x6N01, by connected device,
|
||||
)
|
||||
|
||||
|
||||
class 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
|
||||
# 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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,11 +21,10 @@ import socket
|
|||
import struct
|
||||
import threading
|
||||
|
||||
from enum import Flag
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import Generator
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
|
|
@ -43,6 +42,7 @@ from .common import BatteryLevelApproximation
|
|||
from .common import BatteryStatus
|
||||
from .common import FirmwareKind
|
||||
from .common import NamedInt
|
||||
from .hidpp20_constants import CHARGE_STATUS
|
||||
from .hidpp20_constants import DEVICE_KIND
|
||||
from .hidpp20_constants import ChargeLevel
|
||||
from .hidpp20_constants import ChargeType
|
||||
|
|
@ -79,57 +79,6 @@ class Device(Protocol):
|
|||
...
|
||||
|
||||
|
||||
# pfps: Consider adding a class method that sanitizes inputs by removing unknown bits.
|
||||
|
||||
|
||||
class KeyFlag(Flag):
|
||||
"""Capabilities and desired software handling for a control.
|
||||
|
||||
Ref: https://drive.google.com/file/d/10imcbmoxTJ1N510poGdsviEhoFfB_Ua4/view
|
||||
We treat bytes 4 and 8 of `getCidInfo` as a single bitfield.
|
||||
"""
|
||||
|
||||
UNUSED_8000 = 0x8000
|
||||
UNUSED_4000 = 0x4000
|
||||
UNUSED_2000 = 0x2000
|
||||
UNUSED_1000 = 0x1000
|
||||
RAW_WHEEL = 0x800
|
||||
ANALYTICS_KEY_EVENTS = 0x400
|
||||
FORCE_RAW_XY = 0x200
|
||||
RAW_XY = 0x100
|
||||
VIRTUAL = 0x80
|
||||
PERSISTENTLY_DIVERTABLE = 0x40
|
||||
DIVERTABLE = 0x20
|
||||
REPROGRAMMABLE = 0x10
|
||||
FN_SENSITIVE = 0x08
|
||||
NONSTANDARD = 0x04
|
||||
IS_FN = 0x02
|
||||
MSE = 0x01
|
||||
|
||||
|
||||
class MappingFlag(Flag):
|
||||
"""Flags describing the reporting method of a control.
|
||||
|
||||
We treat bytes 2 and 5 of `get/setCidReporting` as a single bitfield
|
||||
"""
|
||||
|
||||
UNUSED_4000 = 0x4000
|
||||
UNUSED_1000 = 0x1000
|
||||
RAW_WHEEL = 0x400
|
||||
ANALYTICS_KEY_EVENTS_REPORTING = 0x100
|
||||
FORCE_RAW_XY_DIVERTED = 0x40
|
||||
RAW_XY_DIVERTED = 0x10
|
||||
PERSISTENTLY_DIVERTED = 0x04
|
||||
DIVERTED = 0x01
|
||||
|
||||
|
||||
class ChargeStatus(Flag):
|
||||
CHARGING = 0x00
|
||||
FULL = 0x01
|
||||
NOT_CHARGING = 0x02
|
||||
ERROR = 0x07
|
||||
|
||||
|
||||
class FeaturesArray(dict):
|
||||
def __init__(self, device):
|
||||
assert device is not None
|
||||
|
|
@ -235,21 +184,19 @@ class FeaturesArray(dict):
|
|||
|
||||
class ReprogrammableKey:
|
||||
"""Information about a control present on a device with the `REPROG_CONTROLS` feature.
|
||||
|
||||
Read-only properties:
|
||||
- index -- index in the control ID table
|
||||
- key -- the name of this control
|
||||
- default_task -- the native function of this control
|
||||
- flags -- capabilities and desired software handling of the control
|
||||
|
||||
Ref: https://drive.google.com/file/d/0BxbRzx7vEV7eU3VfMnRuRXktZ3M/view
|
||||
Read-only properties:
|
||||
- index {int} -- index in the control ID table
|
||||
- key {NamedInt} -- the name of this control
|
||||
- default_task {NamedInt} -- the native function of this control
|
||||
- flags {List[str]} -- capabilities and desired software handling of the control
|
||||
"""
|
||||
|
||||
def __init__(self, device: Device, index: int, cid: int, task_id: int, flags: int):
|
||||
def __init__(self, device: Device, index, cid, tid, flags):
|
||||
self._device = device
|
||||
self.index = index
|
||||
self._cid = cid
|
||||
self._tid = task_id
|
||||
self._tid = tid
|
||||
self._flags = flags
|
||||
|
||||
@property
|
||||
|
|
@ -262,15 +209,12 @@ class ReprogrammableKey:
|
|||
while the name is the Control ID's native task. But this makes more sense
|
||||
than presenting details of controls vs tasks in the interface. The same
|
||||
convention applies to `mapped_to`, `remappable_to`, `remap` in `ReprogrammableKeyV4`."""
|
||||
try:
|
||||
task = str(special_keys.Task(self._tid))
|
||||
except ValueError:
|
||||
task = f"unknown:{self._tid:04X}"
|
||||
task = str(special_keys.TASK[self._tid])
|
||||
return NamedInt(self._cid, task)
|
||||
|
||||
@property
|
||||
def flags(self) -> KeyFlag:
|
||||
return KeyFlag(self._flags)
|
||||
def flags(self) -> List[str]:
|
||||
return special_keys.KEY_FLAG.flag_names(self._flags)
|
||||
|
||||
|
||||
class ReprogrammableKeyV4(ReprogrammableKey):
|
||||
|
|
@ -290,8 +234,8 @@ class ReprogrammableKeyV4(ReprogrammableKey):
|
|||
- mapping_flags {List[str]} -- mapping flags set on the control
|
||||
"""
|
||||
|
||||
def __init__(self, device: Device, index, cid, task_id, flags, pos, group, gmask):
|
||||
ReprogrammableKey.__init__(self, device, index, cid, task_id, flags)
|
||||
def __init__(self, device: Device, index, cid, tid, flags, pos, group, gmask):
|
||||
ReprogrammableKey.__init__(self, device, index, cid, tid, flags)
|
||||
self.pos = pos
|
||||
self.group = group
|
||||
self._gmask = gmask
|
||||
|
|
@ -307,14 +251,11 @@ class ReprogrammableKeyV4(ReprogrammableKey):
|
|||
if self._mapped_to is None:
|
||||
self._getCidReporting()
|
||||
self._device.keys._ensure_all_keys_queried()
|
||||
try:
|
||||
task = str(special_keys.Task(self._device.keys.cid_to_tid[self._mapped_to]))
|
||||
except ValueError:
|
||||
task = f"Unknown_{self._mapped_to:x}"
|
||||
task = str(special_keys.TASK[self._device.keys.cid_to_tid[self._mapped_to]])
|
||||
return NamedInt(self._mapped_to, task)
|
||||
|
||||
@property
|
||||
def remappable_to(self):
|
||||
def remappable_to(self) -> common.NamedInts:
|
||||
self._device.keys._ensure_all_keys_queried()
|
||||
ret = common.UnsortedNamedInts()
|
||||
if self.group_mask: # only keys with a non-zero gmask are remappable
|
||||
|
|
@ -322,35 +263,31 @@ class ReprogrammableKeyV4(ReprogrammableKey):
|
|||
for g in self.group_mask:
|
||||
g = special_keys.CidGroup[str(g)]
|
||||
for tgt_cid in self._device.keys.group_cids[g]:
|
||||
cid = self._device.keys.cid_to_tid[tgt_cid]
|
||||
try:
|
||||
tgt_task = str(special_keys.Task(cid))
|
||||
except ValueError:
|
||||
tgt_task = f"unknown:{cid:04X}"
|
||||
tgt_task = str(special_keys.TASK[self._device.keys.cid_to_tid[tgt_cid]])
|
||||
tgt_task = NamedInt(tgt_cid, tgt_task)
|
||||
if tgt_task != self.default_task: # don't put itself in twice
|
||||
ret[tgt_task] = tgt_task
|
||||
return ret
|
||||
|
||||
@property
|
||||
def mapping_flags(self) -> MappingFlag:
|
||||
def mapping_flags(self) -> List[str]:
|
||||
if self._mapping_flags is None:
|
||||
self._getCidReporting()
|
||||
return MappingFlag(self._mapping_flags)
|
||||
return special_keys.MAPPING_FLAG.flag_names(self._mapping_flags)
|
||||
|
||||
def set_diverted(self, value: bool) -> None:
|
||||
def set_diverted(self, value: bool):
|
||||
"""If set, the control is diverted temporarily and reports presses as HID++ events."""
|
||||
flags = {MappingFlag.DIVERTED: value}
|
||||
flags = {special_keys.MAPPING_FLAG.diverted: value}
|
||||
self._setCidReporting(flags=flags)
|
||||
|
||||
def set_persistently_diverted(self, value: bool) -> None:
|
||||
def set_persistently_diverted(self, value: bool):
|
||||
"""If set, the control is diverted permanently and reports presses as HID++ events."""
|
||||
flags = {MappingFlag.PERSISTENTLY_DIVERTED: value}
|
||||
flags = {special_keys.MAPPING_FLAG.persistently_diverted: value}
|
||||
self._setCidReporting(flags=flags)
|
||||
|
||||
def set_rawXY_reporting(self, value: bool) -> None:
|
||||
def set_rawXY_reporting(self, value: bool):
|
||||
"""If set, the mouse temporarily reports all its raw XY events while this control is pressed as HID++ events."""
|
||||
flags = {MappingFlag.RAW_XY_DIVERTED: value}
|
||||
flags = {special_keys.MAPPING_FLAG.raw_XY_diverted: value}
|
||||
self._setCidReporting(flags=flags)
|
||||
|
||||
def remap(self, to: NamedInt):
|
||||
|
|
@ -402,30 +339,35 @@ class ReprogrammableKeyV4(ReprogrammableKey):
|
|||
"""
|
||||
flags = flags if flags else {} # See flake8 B006
|
||||
|
||||
# if special_keys.MAPPING_FLAG.raw_XY_diverted in flags and flags[special_keys.MAPPING_FLAG.raw_XY_diverted]:
|
||||
# We need diversion to report raw XY, so divert temporarily (since XY reporting is also temporary)
|
||||
# flags[special_keys.MAPPING_FLAG.diverted] = True
|
||||
# if special_keys.MAPPING_FLAG.diverted in flags and not flags[special_keys.MAPPING_FLAG.diverted]:
|
||||
# flags[special_keys.MAPPING_FLAG.raw_XY_diverted] = False
|
||||
|
||||
# The capability required to set a given reporting flag.
|
||||
FLAG_TO_CAPABILITY = {
|
||||
MappingFlag.DIVERTED: KeyFlag.DIVERTABLE,
|
||||
MappingFlag.PERSISTENTLY_DIVERTED: KeyFlag.PERSISTENTLY_DIVERTABLE,
|
||||
MappingFlag.ANALYTICS_KEY_EVENTS_REPORTING: KeyFlag.ANALYTICS_KEY_EVENTS,
|
||||
MappingFlag.FORCE_RAW_XY_DIVERTED: KeyFlag.FORCE_RAW_XY,
|
||||
MappingFlag.RAW_XY_DIVERTED: KeyFlag.RAW_XY,
|
||||
special_keys.MAPPING_FLAG.diverted: special_keys.KEY_FLAG.divertable,
|
||||
special_keys.MAPPING_FLAG.persistently_diverted: special_keys.KEY_FLAG.persistently_divertable,
|
||||
special_keys.MAPPING_FLAG.analytics_key_events_reporting: special_keys.KEY_FLAG.analytics_key_events,
|
||||
special_keys.MAPPING_FLAG.force_raw_XY_diverted: special_keys.KEY_FLAG.force_raw_XY,
|
||||
special_keys.MAPPING_FLAG.raw_XY_diverted: special_keys.KEY_FLAG.raw_XY,
|
||||
}
|
||||
|
||||
bfield = 0
|
||||
for mapping_flag, activated in flags.items():
|
||||
key_flag = FLAG_TO_CAPABILITY[mapping_flag]
|
||||
if activated and key_flag not in self.flags:
|
||||
for f, v in flags.items():
|
||||
if v and FLAG_TO_CAPABILITY[f] not in self.flags:
|
||||
raise exceptions.FeatureNotSupported(
|
||||
msg=f'Tried to set mapping flag "{mapping_flag}" on control "{self.key}" '
|
||||
+ f'which does not support "{key_flag}" on device {self._device}.'
|
||||
msg=f'Tried to set mapping flag "{f}" on control "{self.key}" '
|
||||
+ f'which does not support "{FLAG_TO_CAPABILITY[f]}" on device {self._device}.'
|
||||
)
|
||||
bfield |= mapping_flag.value if activated else 0
|
||||
bfield |= mapping_flag.value << 1 # The 'Xvalid' bit
|
||||
bfield |= int(f) if v else 0
|
||||
bfield |= int(f) << 1 # The 'Xvalid' bit
|
||||
if self._mapping_flags: # update flags if already read
|
||||
if activated:
|
||||
self._mapping_flags |= mapping_flag.value
|
||||
if v:
|
||||
self._mapping_flags |= int(f)
|
||||
else:
|
||||
self._mapping_flags &= ~mapping_flag.value
|
||||
self._mapping_flags &= ~int(f)
|
||||
|
||||
if remap != 0 and remap not in self.remappable_to:
|
||||
raise exceptions.FeatureNotSupported(
|
||||
|
|
@ -466,23 +408,23 @@ class PersistentRemappableAction:
|
|||
if self.actionId == special_keys.ACTIONID.Empty:
|
||||
return None
|
||||
elif self.actionId == special_keys.ACTIONID.Key:
|
||||
return f"Key: {str(self.modifiers)}{str(self.remapped)}"
|
||||
return "Key: " + str(self.modifiers) + str(self.remapped)
|
||||
elif self.actionId == special_keys.ACTIONID.Mouse:
|
||||
return f"Mouse Button: {str(self.remapped)}"
|
||||
return "Mouse Button: " + str(self.remapped)
|
||||
elif self.actionId == special_keys.ACTIONID.Xdisp:
|
||||
return f"X Displacement {str(self.remapped)}"
|
||||
return "X Displacement " + str(self.remapped)
|
||||
elif self.actionId == special_keys.ACTIONID.Ydisp:
|
||||
return f"Y Displacement {str(self.remapped)}"
|
||||
return "Y Displacement " + str(self.remapped)
|
||||
elif self.actionId == special_keys.ACTIONID.Vscroll:
|
||||
return f"Vertical Scroll {str(self.remapped)}"
|
||||
return "Vertical Scroll " + str(self.remapped)
|
||||
elif self.actionId == special_keys.ACTIONID.Hscroll:
|
||||
return f"Horizontal Scroll: {str(self.remapped)}"
|
||||
return "Horizontal Scroll: " + str(self.remapped)
|
||||
elif self.actionId == special_keys.ACTIONID.Consumer:
|
||||
return f"Consumer: {str(self.remapped)}"
|
||||
return "Consumer: " + str(self.remapped)
|
||||
elif self.actionId == special_keys.ACTIONID.Internal:
|
||||
return f"Internal Action {str(self.remapped)}"
|
||||
return "Internal Action " + str(self.remapped)
|
||||
elif self.actionId == special_keys.ACTIONID.Internal:
|
||||
return f"Power {str(self.remapped)}"
|
||||
return "Power " + str(self.remapped)
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
|
|
@ -582,9 +524,9 @@ class KeysArrayV2(KeysArray):
|
|||
raise IndexError(index)
|
||||
keydata = self.device.feature_request(SupportedFeature.REPROG_CONTROLS, 0x10, index)
|
||||
if keydata:
|
||||
cid, task_id, flags = struct.unpack("!HHB", keydata[:5])
|
||||
self.keys[index] = ReprogrammableKey(self.device, index, cid, task_id, flags)
|
||||
self.cid_to_tid[cid] = task_id
|
||||
cid, tid, flags = struct.unpack("!HHB", keydata[:5])
|
||||
self.keys[index] = ReprogrammableKey(self.device, index, cid, tid, flags)
|
||||
self.cid_to_tid[cid] = tid
|
||||
elif logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning(f"Key with index {index} was expected to exist but device doesn't report it.")
|
||||
|
||||
|
|
@ -598,10 +540,10 @@ class KeysArrayV4(KeysArrayV2):
|
|||
raise IndexError(index)
|
||||
keydata = self.device.feature_request(SupportedFeature.REPROG_CONTROLS_V4, 0x10, index)
|
||||
if keydata:
|
||||
cid, task_id, flags1, pos, group, gmask, flags2 = struct.unpack("!HHBBBBB", keydata[:9])
|
||||
cid, tid, flags1, pos, group, gmask, flags2 = struct.unpack("!HHBBBBB", keydata[:9])
|
||||
flags = flags1 | (flags2 << 8)
|
||||
self.keys[index] = ReprogrammableKeyV4(self.device, index, cid, task_id, flags, pos, group, gmask)
|
||||
self.cid_to_tid[cid] = task_id
|
||||
self.keys[index] = ReprogrammableKeyV4(self.device, index, cid, tid, flags, pos, group, gmask)
|
||||
self.cid_to_tid[cid] = tid
|
||||
if group != 0: # 0 = does not belong to a group
|
||||
self.group_cids[special_keys.CidGroup(group)].append(cid)
|
||||
elif logger.isEnabledFor(logging.WARNING):
|
||||
|
|
@ -645,10 +587,7 @@ class KeysArrayPersistent(KeysArray):
|
|||
elif actionId == special_keys.ACTIONID.Mouse:
|
||||
remapped = special_keys.MOUSE_BUTTONS[remapped]
|
||||
elif actionId == special_keys.ACTIONID.Hscroll:
|
||||
try:
|
||||
remapped = special_keys.HorizontalScroll(remapped)
|
||||
except ValueError:
|
||||
remapped = f"unknown horizontal scroll:{remapped:04X}"
|
||||
remapped = special_keys.HORIZONTAL_SCROLL[remapped]
|
||||
elif actionId == special_keys.ACTIONID.Consumer:
|
||||
remapped = special_keys.HID_CONSUMERCODES[remapped]
|
||||
elif actionId == special_keys.ACTIONID.Empty: # purge data from empty value
|
||||
|
|
@ -700,40 +639,37 @@ SUB_PARAM = { # (byte count, minimum, maximum)
|
|||
ParamId.SCALE_FACTOR: (SubParam("scale", 2, 0x002E, 0x01FF, "Scale"),),
|
||||
}
|
||||
|
||||
# Spec Ids for feature GESTURE_2
|
||||
SPEC = common.NamedInts(
|
||||
DVI_field_width=1,
|
||||
field_widths=2,
|
||||
period_unit=3,
|
||||
resolution=4,
|
||||
multiplier=5,
|
||||
sensor_size=6,
|
||||
finger_width_and_height=7,
|
||||
finger_major_minor_axis=8,
|
||||
finger_force=9,
|
||||
zone=10,
|
||||
)
|
||||
SPEC._fallback = lambda x: f"unknown:{x:04X}"
|
||||
|
||||
class SpecGesture(IntEnum):
|
||||
"""Spec IDs for feature GESTURE_2."""
|
||||
|
||||
DVI_FIELD_WIDTH = 1
|
||||
FIELD_WIDTHS = 2
|
||||
PERIOD_UNIT = 3
|
||||
RESOLUTION = 4
|
||||
MULTIPLIER = 5
|
||||
SENSOR_SIZE = 6
|
||||
FINGER_WIDTH_AND_HEIGHT = 7
|
||||
FINGER_MAJOR_MINOR_AXIS = 8
|
||||
FINGER_FORCE = 9
|
||||
ZONE = 10
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name.replace('_', ' ').lower()}"
|
||||
|
||||
|
||||
class ActionId(IntEnum):
|
||||
"""Action IDs for feature GESTURE_2."""
|
||||
|
||||
MOVE_POINTER = 1
|
||||
SCROLL_HORIZONTAL = 2
|
||||
WHEEL_SCROLLING = 3
|
||||
SCROLL_VERTICAL = 4
|
||||
SCROLL_OR_PAGE_XY = 5
|
||||
SCROLL_OR_PAGE_HORIZONTAL = 6
|
||||
PAGE_SCREEN = 7
|
||||
DRAG = 8
|
||||
SECONDARY_DRAG = 9
|
||||
ZOOM = 10
|
||||
SCROLL_HORIZONTAL_ONLY = 11
|
||||
SCROLL_VERTICAL_ONLY = 12
|
||||
# Action Ids for feature GESTURE_2
|
||||
ACTION_ID = common.NamedInts(
|
||||
MovePointer=1,
|
||||
ScrollHorizontal=2,
|
||||
WheelScrolling=3,
|
||||
ScrollVertial=4,
|
||||
ScrollOrPageXY=5,
|
||||
ScrollOrPageHorizontal=6,
|
||||
PageScreen=7,
|
||||
Drag=8,
|
||||
SecondaryDrag=9,
|
||||
Zoom=10,
|
||||
ScrollHorizontalOnly=11,
|
||||
ScrollVerticalOnly=12,
|
||||
)
|
||||
ACTION_ID._fallback = lambda x: f"unknown:{x:04X}"
|
||||
|
||||
|
||||
class Gesture:
|
||||
|
|
@ -868,13 +804,10 @@ class Param:
|
|||
|
||||
|
||||
class Spec:
|
||||
def __init__(self, device, low: int, high):
|
||||
def __init__(self, device, low, high):
|
||||
self._device = device
|
||||
self.id = low
|
||||
try:
|
||||
self.spec = SpecGesture(low)
|
||||
except ValueError:
|
||||
self.spec = f"unknown:{low:04X}"
|
||||
self.spec = SPEC[low]
|
||||
self.byte_count = high & 0x0F
|
||||
self._value = None
|
||||
|
||||
|
|
@ -1002,22 +935,8 @@ class LEDParam:
|
|||
saturation = "saturation"
|
||||
|
||||
|
||||
class LedRampChoice(IntEnum):
|
||||
DEFAULT = 0
|
||||
YES = 1
|
||||
NO = 2
|
||||
|
||||
|
||||
class LedFormChoices(IntEnum):
|
||||
DEFAULT = 0
|
||||
SINE = 1
|
||||
SQUARE = 2
|
||||
TRIANGLE = 3
|
||||
SAWTOOTH = 4
|
||||
SHARKFIN = 5
|
||||
EXPONENTIAL = 6
|
||||
|
||||
|
||||
LEDRampChoices = common.NamedInts(default=0, yes=1, no=2)
|
||||
LEDFormChoices = common.NamedInts(default=0, sine=1, square=2, triangle=3, sawtooth=4, sharkfin=5, exponential=6)
|
||||
LEDParamSize = {
|
||||
LEDParam.color: 3,
|
||||
LEDParam.speed: 1,
|
||||
|
|
@ -1173,42 +1092,28 @@ class RGBEffectsInfo(LEDEffectsInfo): # effects that the LEDs can do using RGB_
|
|||
self.zones.append(LEDZoneInfo(SupportedFeature.RGB_EFFECTS, 0x00, 1, 0x00, device, i))
|
||||
|
||||
|
||||
class ButtonBehavior(IntEnum):
|
||||
MACRO_EXECUTE = 0x0
|
||||
MACRO_STOP = 0x1
|
||||
MACRO_STOP_ALL = 0x2
|
||||
SEND = 0x8
|
||||
FUNCTION = 0x9
|
||||
|
||||
|
||||
class ButtonMappingType(IntEnum):
|
||||
NO_ACTION = 0x0
|
||||
BUTTON = 0x1
|
||||
MODIFIER_AND_KEY = 0x2
|
||||
CONSUMER_KEY = 0x3
|
||||
|
||||
|
||||
class ButtonFunctions(IntEnum):
|
||||
NO_ACTION = 0x0
|
||||
TILT_LEFT = 0x1
|
||||
TILT_RIGHT = 0x2
|
||||
NEXT_DPI = 0x3
|
||||
PREVIOUS_DPI = 0x4
|
||||
CYCLE_DPI = 0x5
|
||||
DEFAULT_DPI = 0x6
|
||||
SHIFT_DPI = 0x7
|
||||
NEXT_PROFILE = 0x8
|
||||
PREVIOUS_PROFILE = 0x9
|
||||
CYCLE_PROFILE = 0xA
|
||||
G_SHIFT = 0xB
|
||||
BATTERY_STATUS = 0xC
|
||||
PROFILE_SELECT = 0xD
|
||||
MODE_SWITCH = 0xE
|
||||
HOST_BUTTON = 0xF
|
||||
SCROLL_DOWN = 0x10
|
||||
SCROLL_UP = 0x11
|
||||
|
||||
|
||||
ButtonBehaviors = common.NamedInts(MacroExecute=0x0, MacroStop=0x1, MacroStopAll=0x2, Send=0x8, Function=0x9)
|
||||
ButtonMappingTypes = common.NamedInts(No_Action=0x0, Button=0x1, Modifier_And_Key=0x2, Consumer_Key=0x3)
|
||||
ButtonFunctions = common.NamedInts(
|
||||
No_Action=0x0,
|
||||
Tilt_Left=0x1,
|
||||
Tilt_Right=0x2,
|
||||
Next_DPI=0x3,
|
||||
Previous_DPI=0x4,
|
||||
Cycle_DPI=0x5,
|
||||
Default_DPI=0x6,
|
||||
Shift_DPI=0x7,
|
||||
Next_Profile=0x8,
|
||||
Previous_Profile=0x9,
|
||||
Cycle_Profile=0xA,
|
||||
G_Shift=0xB,
|
||||
Battery_Status=0xC,
|
||||
Profile_Select=0xD,
|
||||
Mode_Switch=0xE,
|
||||
Host_Button=0xF,
|
||||
Scroll_Down=0x10,
|
||||
Scroll_Up=0x11,
|
||||
)
|
||||
ButtonButtons = special_keys.MOUSE_BUTTONS
|
||||
ButtonModifiers = special_keys.modifiers
|
||||
ButtonKeys = special_keys.USB_HID_KEYCODES
|
||||
|
|
@ -1233,57 +1138,50 @@ class Button:
|
|||
return dumper.represent_mapping("!Button", data.__dict__, flow_style=True)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, bytes_) -> Button:
|
||||
behavior = bytes_[0] >> 4
|
||||
if behavior == ButtonBehavior.MACRO_EXECUTE or behavior == ButtonBehavior.MACRO_STOP:
|
||||
sector = ((bytes_[0] & 0x0F) << 8) + bytes_[1]
|
||||
address = (bytes_[2] << 8) + bytes_[3]
|
||||
def from_bytes(cls, bytes):
|
||||
behavior = ButtonBehaviors[bytes[0] >> 4]
|
||||
if behavior == ButtonBehaviors.MacroExecute or behavior == ButtonBehaviors.MacroStop:
|
||||
sector = ((bytes[0] & 0x0F) << 8) + bytes[1]
|
||||
address = (bytes[2] << 8) + bytes[3]
|
||||
result = cls(behavior=behavior, sector=sector, address=address)
|
||||
elif behavior == ButtonBehavior.SEND:
|
||||
try:
|
||||
mapping_type = ButtonMappingType(bytes_[1]).value
|
||||
if mapping_type == ButtonMappingType.BUTTON:
|
||||
value = ButtonButtons[(bytes_[2] << 8) + bytes_[3]]
|
||||
result = cls(behavior=behavior, type=mapping_type, value=value)
|
||||
elif mapping_type == ButtonMappingType.MODIFIER_AND_KEY:
|
||||
modifiers = bytes_[2]
|
||||
value = ButtonKeys[bytes_[3]]
|
||||
result = cls(behavior=behavior, type=mapping_type, modifiers=modifiers, value=value)
|
||||
elif mapping_type == ButtonMappingType.CONSUMER_KEY:
|
||||
value = ButtonConsumerKeys[(bytes_[2] << 8) + bytes_[3]]
|
||||
result = cls(behavior=behavior, type=mapping_type, value=value)
|
||||
elif mapping_type == ButtonMappingType.NO_ACTION:
|
||||
result = cls(behavior=behavior, type=mapping_type)
|
||||
except Exception:
|
||||
pass
|
||||
elif behavior == ButtonBehavior.FUNCTION:
|
||||
second_byte = bytes_[1]
|
||||
try:
|
||||
btn_func = ButtonFunctions(second_byte).value
|
||||
except ValueError:
|
||||
btn_func = second_byte
|
||||
data = bytes_[3]
|
||||
result = cls(behavior=behavior, value=btn_func, data=data)
|
||||
elif behavior == ButtonBehaviors.Send:
|
||||
mapping_type = ButtonMappingTypes[bytes[1]]
|
||||
if mapping_type == ButtonMappingTypes.Button:
|
||||
value = ButtonButtons[(bytes[2] << 8) + bytes[3]]
|
||||
result = cls(behavior=behavior, type=mapping_type, value=value)
|
||||
elif mapping_type == ButtonMappingTypes.Modifier_And_Key:
|
||||
modifiers = bytes[2]
|
||||
value = ButtonKeys[bytes[3]]
|
||||
result = cls(behavior=behavior, type=mapping_type, modifiers=modifiers, value=value)
|
||||
elif mapping_type == ButtonMappingTypes.Consumer_Key:
|
||||
value = ButtonConsumerKeys[(bytes[2] << 8) + bytes[3]]
|
||||
result = cls(behavior=behavior, type=mapping_type, value=value)
|
||||
elif mapping_type == ButtonMappingTypes.No_Action:
|
||||
result = cls(behavior=behavior, type=mapping_type)
|
||||
elif behavior == ButtonBehaviors.Function:
|
||||
value = ButtonFunctions[bytes[1]] if ButtonFunctions[bytes[1]] is not None else bytes[1]
|
||||
data = bytes[3]
|
||||
result = cls(behavior=behavior, value=value, data=data)
|
||||
else:
|
||||
result = cls(behavior=bytes_[0] >> 4, bytes=bytes_)
|
||||
result = cls(behavior=bytes[0] >> 4, bytes=bytes)
|
||||
return result
|
||||
|
||||
def to_bytes(self):
|
||||
bytes = common.int2bytes(self.behavior << 4, 1) if self.behavior is not None else None
|
||||
if self.behavior == ButtonBehavior.MACRO_EXECUTE.value or self.behavior == ButtonBehavior.MACRO_STOP.value:
|
||||
if self.behavior == ButtonBehaviors.MacroExecute or self.behavior == ButtonBehaviors.MacroStop:
|
||||
bytes = common.int2bytes((self.behavior << 12) + self.sector, 2) + common.int2bytes(self.address, 2)
|
||||
elif self.behavior == ButtonBehavior.SEND.value:
|
||||
elif self.behavior == ButtonBehaviors.Send:
|
||||
bytes += common.int2bytes(self.type, 1)
|
||||
if self.type == ButtonMappingType.BUTTON:
|
||||
if self.type == ButtonMappingTypes.Button:
|
||||
bytes += common.int2bytes(self.value, 2)
|
||||
elif self.type == ButtonMappingType.MODIFIER_AND_KEY:
|
||||
elif self.type == ButtonMappingTypes.Modifier_And_Key:
|
||||
bytes += common.int2bytes(self.modifiers, 1)
|
||||
bytes += common.int2bytes(self.value, 1)
|
||||
elif self.type == ButtonMappingType.CONSUMER_KEY:
|
||||
elif self.type == ButtonMappingTypes.Consumer_Key:
|
||||
bytes += common.int2bytes(self.value, 2)
|
||||
elif self.type == ButtonMappingType.NO_ACTION:
|
||||
elif self.type == ButtonMappingTypes.No_Action:
|
||||
bytes += b"\xff\xff"
|
||||
elif self.behavior == ButtonBehavior.FUNCTION:
|
||||
elif self.behavior == ButtonBehaviors.Function:
|
||||
data = common.int2bytes(self.data, 1) if self.data else b"\x00"
|
||||
bytes += common.int2bytes(self.value, 1) + b"\xff" + data
|
||||
else:
|
||||
|
|
@ -1293,7 +1191,7 @@ class Button:
|
|||
def __repr__(self):
|
||||
return "%s{%s}" % (
|
||||
self.__class__.__name__,
|
||||
", ".join([f"{str(key)}:{str(val)}" for key, val in self.__dict__.items()]),
|
||||
", ".join([str(key) + ":" + str(val) for key, val in self.__dict__.items()]),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1408,14 +1306,7 @@ class OnboardProfiles:
|
|||
return dumper.represent_mapping("!OnboardProfiles", data.__dict__)
|
||||
|
||||
@classmethod
|
||||
def get_profile_headers(cls, device) -> list[tuple[int, int]]:
|
||||
"""Returns profile headers.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[tuple[int, int]]
|
||||
Tuples contain (sector, enabled).
|
||||
"""
|
||||
def get_profile_headers(cls, device):
|
||||
i = 0
|
||||
headers = []
|
||||
chunk = device.feature_request(SupportedFeature.ONBOARD_PROFILES, 0x50, 0, 0, 0, i)
|
||||
|
|
@ -1442,8 +1333,10 @@ class OnboardProfiles:
|
|||
gbuttons = buttons if (shift & 0x3 == 0x2) else 0
|
||||
headers = OnboardProfiles.get_profile_headers(device)
|
||||
profiles = {}
|
||||
for i, (sector, enabled) in enumerate(headers, start=1):
|
||||
profiles[i] = OnboardProfile.from_dev(device, i, sector, size, enabled, buttons, gbuttons)
|
||||
i = 0
|
||||
for sector, enabled in headers:
|
||||
profiles[i + 1] = OnboardProfile.from_dev(device, i, sector, size, enabled, buttons, gbuttons)
|
||||
i += 1
|
||||
return cls(
|
||||
version=OnboardProfilesVersion,
|
||||
name=device.name,
|
||||
|
|
@ -1530,6 +1423,25 @@ def feature_request(device, feature, function=0x00, *params, no_reply=False):
|
|||
return device.request((feature_index << 8) + (function & 0xFF), *params, no_reply=no_reply)
|
||||
|
||||
|
||||
# voltage to remaining charge from Logitech
|
||||
battery_voltage_remaining = (
|
||||
(4186, 100),
|
||||
(4067, 90),
|
||||
(3989, 80),
|
||||
(3922, 70),
|
||||
(3859, 60),
|
||||
(3811, 50),
|
||||
(3778, 40),
|
||||
(3751, 30),
|
||||
(3717, 20),
|
||||
(3671, 10),
|
||||
(3646, 5),
|
||||
(3579, 2),
|
||||
(3500, 0),
|
||||
(-1000, 0),
|
||||
)
|
||||
|
||||
|
||||
class Hidpp20:
|
||||
def get_firmware(self, device) -> tuple[common.FirmwareInfo] | None:
|
||||
"""Reads a device's firmware info.
|
||||
|
|
@ -1856,7 +1768,7 @@ class Hidpp20:
|
|||
state = device.feature_request(SupportedFeature.REPORT_RATE, 0x10)
|
||||
if state:
|
||||
rate = struct.unpack("!B", state[:1])[0]
|
||||
return f"{str(rate)}ms"
|
||||
return str(rate) + "ms"
|
||||
else:
|
||||
rates = ["8ms", "4ms", "2ms", "1ms", "500us", "250us", "125us"]
|
||||
state = device.feature_request(SupportedFeature.EXTENDED_ADJUSTABLE_REPORT_RATE, 0x20)
|
||||
|
|
@ -1907,10 +1819,10 @@ def decipher_battery_voltage(report: bytes):
|
|||
charge_type = ChargeType.STANDARD
|
||||
if flags & (1 << 7):
|
||||
status = BatteryStatus.RECHARGING
|
||||
charge_sts = ChargeStatus(flags & 0x03)
|
||||
charge_sts = CHARGE_STATUS[flags & 0x03]
|
||||
if charge_sts is None:
|
||||
charge_sts = ErrorCode.UNKNOWN
|
||||
elif isinstance(charge_sts, ChargeStatus) and ChargeStatus.FULL in charge_sts:
|
||||
elif charge_sts == CHARGE_STATUS.full:
|
||||
charge_lvl = ChargeLevel.FULL
|
||||
status = BatteryStatus.FULL
|
||||
if flags & (1 << 3):
|
||||
|
|
@ -1920,9 +1832,10 @@ def decipher_battery_voltage(report: bytes):
|
|||
status = BatteryStatus.SLOW_RECHARGE
|
||||
elif flags & (1 << 5):
|
||||
charge_lvl = ChargeLevel.CRITICAL
|
||||
charge_level = estimate_battery_level_percentage(voltage)
|
||||
if charge_level:
|
||||
charge_lvl = charge_level
|
||||
for level in battery_voltage_remaining:
|
||||
if level[0] < voltage:
|
||||
charge_lvl = level[1]
|
||||
break
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"battery voltage %d mV, charging %s, status %d = %s, level %s, type %s",
|
||||
|
|
@ -1936,7 +1849,7 @@ def decipher_battery_voltage(report: bytes):
|
|||
return SupportedFeature.BATTERY_VOLTAGE, Battery(charge_lvl, None, status, voltage)
|
||||
|
||||
|
||||
def decipher_battery_unified(report) -> tuple[SupportedFeature, Battery]:
|
||||
def decipher_battery_unified(report):
|
||||
discharge, level, status_byte, _ignore = struct.unpack("!BBBB", report[:4])
|
||||
try:
|
||||
status = BatteryStatus(status_byte)
|
||||
|
|
@ -1947,64 +1860,27 @@ def decipher_battery_unified(report) -> tuple[SupportedFeature, Battery]:
|
|||
logger.debug("battery unified %s%% charged, level %s, charging %s", discharge, level, status)
|
||||
|
||||
if level == 8:
|
||||
approx_level = BatteryLevelApproximation.FULL
|
||||
level = BatteryLevelApproximation.FULL
|
||||
elif level == 4:
|
||||
approx_level = BatteryLevelApproximation.GOOD
|
||||
level = BatteryLevelApproximation.GOOD
|
||||
elif level == 2:
|
||||
approx_level = BatteryLevelApproximation.LOW
|
||||
level = BatteryLevelApproximation.LOW
|
||||
elif level == 1:
|
||||
approx_level = BatteryLevelApproximation.CRITICAL
|
||||
level = BatteryLevelApproximation.CRITICAL
|
||||
else:
|
||||
approx_level = BatteryLevelApproximation.EMPTY
|
||||
level = BatteryLevelApproximation.EMPTY
|
||||
|
||||
return SupportedFeature.UNIFIED_BATTERY, Battery(discharge if discharge else approx_level, None, status, None)
|
||||
return SupportedFeature.UNIFIED_BATTERY, Battery(discharge if discharge else level, None, status, None)
|
||||
|
||||
|
||||
def decipher_adc_measurement(report) -> tuple[SupportedFeature, Battery]:
|
||||
def decipher_adc_measurement(report):
|
||||
# partial implementation - needs mapping to levels
|
||||
adc_voltage, flags = struct.unpack("!HB", report[:3])
|
||||
charge_level = estimate_battery_level_percentage(adc_voltage)
|
||||
charge_level = None
|
||||
adc, flags = struct.unpack("!HB", report[:3])
|
||||
for level in battery_voltage_remaining:
|
||||
if level[0] < adc:
|
||||
charge_level = level[1]
|
||||
break
|
||||
if flags & 0x01:
|
||||
status = BatteryStatus.RECHARGING if flags & 0x02 else BatteryStatus.DISCHARGING
|
||||
return SupportedFeature.ADC_MEASUREMENT, Battery(charge_level, None, status, adc_voltage)
|
||||
|
||||
|
||||
def estimate_battery_level_percentage(value_millivolt: int) -> int | None:
|
||||
"""Estimate battery level percentage based on battery voltage.
|
||||
|
||||
Uses linear approximation to estimate the battery level in percent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
value_millivolt
|
||||
Measured battery voltage in millivolt.
|
||||
"""
|
||||
battery_voltage_to_percentage = [
|
||||
(4186, 100),
|
||||
(4067, 90),
|
||||
(3989, 80),
|
||||
(3922, 70),
|
||||
(3859, 60),
|
||||
(3811, 50),
|
||||
(3778, 40),
|
||||
(3751, 30),
|
||||
(3717, 20),
|
||||
(3671, 10),
|
||||
(3646, 5),
|
||||
(3579, 2),
|
||||
(3500, 0),
|
||||
]
|
||||
|
||||
if value_millivolt >= battery_voltage_to_percentage[0][0]:
|
||||
return battery_voltage_to_percentage[0][1]
|
||||
if value_millivolt <= battery_voltage_to_percentage[-1][0]:
|
||||
return battery_voltage_to_percentage[-1][1]
|
||||
|
||||
for i in range(len(battery_voltage_to_percentage) - 1):
|
||||
v_high, p_high = battery_voltage_to_percentage[i]
|
||||
v_low, p_low = battery_voltage_to_percentage[i + 1]
|
||||
if v_low <= value_millivolt <= v_high:
|
||||
# Linear interpolation
|
||||
percent = p_low + (p_high - p_low) * (value_millivolt - v_low) / (v_high - v_low)
|
||||
return round(percent)
|
||||
return 0
|
||||
return SupportedFeature.ADC_MEASUREMENT, Battery(charge_level, None, status, adc)
|
||||
|
|
|
|||
|
|
@ -179,6 +179,16 @@ class OnboardMode(IntEnum):
|
|||
MODE_HOST = 0x02
|
||||
|
||||
|
||||
CHARGE_STATUS = NamedInts(charging=0x00, full=0x01, not_charging=0x02, error=0x07)
|
||||
|
||||
|
||||
class ChargeStatus(IntEnum):
|
||||
CHARGING = 0x00
|
||||
FULL = 0x01
|
||||
NOT_CHARGING = 0x02
|
||||
ERROR = 0x07
|
||||
|
||||
|
||||
class ChargeLevel(IntEnum):
|
||||
AVERAGE = 50
|
||||
FULL = 90
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ class EventsListener(threading.Thread):
|
|||
path_name = receiver.path.split("/")[2]
|
||||
except IndexError:
|
||||
path_name = receiver.path
|
||||
super().__init__(name=f"{self.__class__.__name__}:{path_name}")
|
||||
super().__init__(name=self.__class__.__name__ + ":" + path_name)
|
||||
self.daemon = True
|
||||
self._active = False
|
||||
self.receiver = receiver
|
||||
|
|
|
|||
|
|
@ -34,87 +34,158 @@ from . import diversion
|
|||
from . import hidpp10
|
||||
from . import hidpp10_constants
|
||||
from . import hidpp20
|
||||
from . import hidpp20_constants
|
||||
from . import settings_templates
|
||||
from .common import Alert
|
||||
from .common import BatteryStatus
|
||||
from .common import Notification
|
||||
from .hidpp10_constants import Registers
|
||||
from .hidpp20_constants import SupportedFeature
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .base import HIDPPNotification
|
||||
from .device import Device
|
||||
from .receiver import Receiver
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NotificationHandler = typing.Callable[["Receiver", "HIDPPNotification"], bool]
|
||||
|
||||
_hidpp10 = hidpp10.Hidpp10()
|
||||
_hidpp20 = hidpp20.Hidpp20()
|
||||
_F = hidpp20_constants.SupportedFeature
|
||||
|
||||
|
||||
notification_lock = threading.Lock()
|
||||
|
||||
|
||||
def process(device: Device | Receiver, notification: HIDPPNotification):
|
||||
"""Handle incoming events (notification) from device or receiver."""
|
||||
def process(device, notification):
|
||||
assert device
|
||||
assert notification
|
||||
|
||||
if not device.isDevice:
|
||||
return process_receiver_notification(device, notification)
|
||||
return process_device_notification(device, notification)
|
||||
return _process_receiver_notification(device, notification)
|
||||
return _process_device_notification(device, notification)
|
||||
|
||||
|
||||
def process_receiver_notification(receiver: Receiver, notification: HIDPPNotification) -> bool | None:
|
||||
"""Process event messages from receivers."""
|
||||
event_handler_mapping: dict[int, NotificationHandler] = {
|
||||
Notification.PAIRING_LOCK: handle_pairing_lock,
|
||||
Registers.DEVICE_DISCOVERY_NOTIFICATION: handle_device_discovery,
|
||||
Registers.DISCOVERY_STATUS_NOTIFICATION: handle_discovery_status,
|
||||
Registers.PAIRING_STATUS_NOTIFICATION: handle_pairing_status,
|
||||
Registers.PASSKEY_PRESSED_NOTIFICATION: handle_passkey_pressed,
|
||||
Registers.PASSKEY_REQUEST_NOTIFICATION: handle_passkey_request,
|
||||
}
|
||||
|
||||
try:
|
||||
handler_func = event_handler_mapping[notification.sub_id]
|
||||
return handler_func(receiver, notification)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
assert notification.sub_id in [
|
||||
def _process_receiver_notification(receiver: Receiver, hidpp_notification: HIDPPNotification) -> bool | None:
|
||||
# supposedly only 0x4x notifications arrive for the receiver
|
||||
assert hidpp_notification.sub_id in [
|
||||
Notification.CONNECT_DISCONNECT,
|
||||
Notification.DJ_PAIRING,
|
||||
Notification.CONNECTED,
|
||||
Notification.RAW_INPUT,
|
||||
Notification.PAIRING_LOCK,
|
||||
Notification.POWER,
|
||||
Registers.DEVICE_DISCOVERY_NOTIFICATION,
|
||||
Registers.DISCOVERY_STATUS_NOTIFICATION,
|
||||
Registers.PAIRING_STATUS_NOTIFICATION,
|
||||
Registers.PASSKEY_PRESSED_NOTIFICATION,
|
||||
Registers.PASSKEY_REQUEST_NOTIFICATION,
|
||||
]
|
||||
|
||||
logger.warning(f"{receiver}: unhandled notification {notification}")
|
||||
if hidpp_notification.sub_id == Notification.PAIRING_LOCK:
|
||||
receiver.pairing.lock_open = bool(hidpp_notification.address & 0x01)
|
||||
reason = _("pairing lock is open") if receiver.pairing.lock_open else _("pairing lock is closed")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: %s", receiver, reason)
|
||||
receiver.pairing.error = None
|
||||
if receiver.pairing.lock_open:
|
||||
receiver.pairing.new_device = None
|
||||
pair_error = ord(hidpp_notification.data[:1])
|
||||
if pair_error:
|
||||
receiver.pairing.error = error_string = hidpp10_constants.PairingError(pair_error)
|
||||
receiver.pairing.new_device = None
|
||||
logger.warning("pairing error %d: %s", pair_error, error_string)
|
||||
receiver.changed(reason=reason)
|
||||
return True
|
||||
|
||||
elif hidpp_notification.sub_id == Registers.DISCOVERY_STATUS_NOTIFICATION: # Bolt pairing
|
||||
with notification_lock:
|
||||
receiver.pairing.discovering = hidpp_notification.address == 0x00
|
||||
reason = _("discovery lock is open") if receiver.pairing.discovering else _("discovery lock is closed")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: %s", receiver, reason)
|
||||
receiver.pairing.error = None
|
||||
if receiver.pairing.discovering:
|
||||
receiver.pairing.counter = receiver.pairing.device_address = None
|
||||
receiver.pairing.device_authentication = receiver.pairing.device_name = None
|
||||
receiver.pairing.device_passkey = None
|
||||
discover_error = ord(hidpp_notification.data[:1])
|
||||
if discover_error:
|
||||
receiver.pairing.error = discover_string = hidpp10_constants.BoltPairingError(discover_error)
|
||||
logger.warning("bolt discovering error %d: %s", discover_error, discover_string)
|
||||
receiver.changed(reason=reason)
|
||||
return True
|
||||
|
||||
elif hidpp_notification.sub_id == Registers.DEVICE_DISCOVERY_NOTIFICATION: # Bolt pairing
|
||||
with notification_lock:
|
||||
counter = hidpp_notification.address + hidpp_notification.data[0] * 256 # notification counter
|
||||
if receiver.pairing.counter is None:
|
||||
receiver.pairing.counter = counter
|
||||
else:
|
||||
if not receiver.pairing.counter == counter:
|
||||
return None
|
||||
if hidpp_notification.data[1] == 0:
|
||||
receiver.pairing.device_kind = hidpp_notification.data[3]
|
||||
receiver.pairing.device_address = hidpp_notification.data[6:12]
|
||||
receiver.pairing.device_authentication = hidpp_notification.data[14]
|
||||
elif hidpp_notification.data[1] == 1:
|
||||
receiver.pairing.device_name = hidpp_notification.data[3 : 3 + hidpp_notification.data[2]].decode("utf-8")
|
||||
return True
|
||||
|
||||
elif hidpp_notification.sub_id == Registers.PAIRING_STATUS_NOTIFICATION: # Bolt pairing
|
||||
with notification_lock:
|
||||
receiver.pairing.device_passkey = None
|
||||
receiver.pairing.lock_open = hidpp_notification.address == 0x00
|
||||
reason = _("pairing lock is open") if receiver.pairing.lock_open else _("pairing lock is closed")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: %s", receiver, reason)
|
||||
receiver.pairing.error = None
|
||||
if not receiver.pairing.lock_open:
|
||||
receiver.pairing.counter = None
|
||||
receiver.pairing.device_address = None
|
||||
receiver.pairing.device_authentication = None
|
||||
receiver.pairing.device_name = None
|
||||
pair_error = hidpp_notification.data[0]
|
||||
if receiver.pairing.lock_open:
|
||||
receiver.pairing.new_device = None
|
||||
elif hidpp_notification.address == 0x02 and not pair_error:
|
||||
receiver.pairing.new_device = receiver.register_new_device(hidpp_notification.data[7])
|
||||
if pair_error:
|
||||
receiver.pairing.error = error_string = hidpp10_constants.BoltPairingError(pair_error)
|
||||
receiver.pairing.new_device = None
|
||||
logger.warning("pairing error %d: %s", pair_error, error_string)
|
||||
receiver.changed(reason=reason)
|
||||
return True
|
||||
|
||||
elif hidpp_notification.sub_id == Registers.PASSKEY_REQUEST_NOTIFICATION: # Bolt pairing
|
||||
with notification_lock:
|
||||
receiver.pairing.device_passkey = hidpp_notification.data[0:6].decode("utf-8")
|
||||
return True
|
||||
|
||||
elif hidpp_notification.sub_id == Registers.PASSKEY_PRESSED_NOTIFICATION: # Bolt pairing
|
||||
return True
|
||||
|
||||
logger.warning("%s: unhandled notification %s", receiver, hidpp_notification)
|
||||
|
||||
|
||||
def process_device_notification(device: Device, notification: HIDPPNotification):
|
||||
"""Process event messages from devices."""
|
||||
|
||||
def _process_device_notification(device, n):
|
||||
# incoming packets with SubId >= 0x80 are supposedly replies from HID++ 1.0 requests, should never get here
|
||||
assert notification.sub_id & 0x80 == 0
|
||||
assert n.sub_id & 0x80 == 0
|
||||
|
||||
if notification.sub_id == Notification.NO_OPERATION:
|
||||
if n.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(notification)
|
||||
handling_ret = device.handle_notification(n)
|
||||
if handling_ret is not None:
|
||||
return handling_ret
|
||||
|
||||
# 0x40 to 0x7F appear to be HID++ 1.0 or DJ notifications
|
||||
if notification.sub_id >= 0x40:
|
||||
if notification.report_id == base.DJ_MESSAGE_ID:
|
||||
return _process_dj_notification(device, notification)
|
||||
if n.sub_id >= 0x40:
|
||||
if n.report_id == base.DJ_MESSAGE_ID:
|
||||
return _process_dj_notification(device, n)
|
||||
else:
|
||||
return _process_hidpp10_notification(device, notification)
|
||||
return _process_hidpp10_notification(device, n)
|
||||
|
||||
# These notifications are from the device itself, so it must be active
|
||||
device.online = True
|
||||
|
|
@ -123,58 +194,63 @@ def process_device_notification(device: Device, notification: HIDPPNotification)
|
|||
|
||||
# some custom battery events for HID++ 1.0 devices
|
||||
if device.protocol < 2.0:
|
||||
return _process_hidpp10_custom_notification(device, notification)
|
||||
return _process_hidpp10_custom_notification(device, n)
|
||||
|
||||
# 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, notification.sub_id, notification)
|
||||
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)
|
||||
return False
|
||||
|
||||
return _process_feature_notification(device, notification)
|
||||
return _process_feature_notification(device, n, feature)
|
||||
|
||||
|
||||
def _process_dj_notification(device: Device, notification: HIDPPNotification):
|
||||
def _process_dj_notification(device, n):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s (%s) DJ %s", device, device.protocol, notification)
|
||||
logger.debug("%s (%s) DJ %s", device, device.protocol, n)
|
||||
|
||||
if notification.sub_id == Notification.CONNECT_DISCONNECT:
|
||||
if n.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, notification)
|
||||
logger.info("%s: ignoring DJ unpaired: %s", device, n)
|
||||
return True
|
||||
|
||||
if notification.sub_id == Notification.DJ_PAIRING:
|
||||
if n.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, notification)
|
||||
logger.info("%s: ignoring DJ paired: %s", device, n)
|
||||
return True
|
||||
|
||||
if notification.sub_id == Notification.CONNECTED:
|
||||
connected = not notification.address & 0x01
|
||||
if n.sub_id == Notification.CONNECTED:
|
||||
connected = not n.address & 0x01
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: DJ connection: %s %s", device, connected, notification)
|
||||
logger.info("%s: DJ connection: %s %s", device, connected, n)
|
||||
device.changed(active=connected, alert=Alert.NONE, reason=_("connected") if connected else _("disconnected"))
|
||||
return True
|
||||
|
||||
logger.warning("%s: unrecognized DJ %s", device, notification)
|
||||
logger.warning("%s: unrecognized DJ %s", device, n)
|
||||
|
||||
|
||||
def _process_hidpp10_custom_notification(device: Device, notification: HIDPPNotification):
|
||||
def _process_hidpp10_custom_notification(device, n):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s (%s) custom notification %s", device, device.protocol, notification)
|
||||
logger.debug("%s (%s) custom notification %s", device, device.protocol, n)
|
||||
|
||||
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))
|
||||
if n.sub_id in (Registers.BATTERY_STATUS, Registers.BATTERY_CHARGE):
|
||||
assert n.data[-1:] == b"\x00"
|
||||
data = chr(n.address).encode() + n.data
|
||||
device.set_battery_info(hidpp10.parse_battery_status(n.sub_id, data))
|
||||
return True
|
||||
|
||||
logger.warning("%s: unrecognized %s", device, notification)
|
||||
logger.warning("%s: unrecognized %s", device, n)
|
||||
|
||||
|
||||
def _process_hidpp10_notification(device: Device, notification: HIDPPNotification):
|
||||
if notification.sub_id == Notification.CONNECT_DISCONNECT: # device unpairing
|
||||
if notification.address == 0x02:
|
||||
def _process_hidpp10_notification(device, n):
|
||||
if n.sub_id == Notification.CONNECT_DISCONNECT: # device unpairing
|
||||
if n.address == 0x02:
|
||||
# device un-paired
|
||||
device.wpid = None
|
||||
if device.number in device.receiver:
|
||||
|
|
@ -182,23 +258,21 @@ def _process_hidpp10_notification(device: Device, notification: HIDPPNotificatio
|
|||
device.changed(active=False, alert=Alert.ALL, reason=_("unpaired"))
|
||||
## device.status = None
|
||||
else:
|
||||
logger.warning("%s: disconnection with unknown type %02X: %s", device, notification.address, notification)
|
||||
logger.warning("%s: disconnection with unknown type %02X: %s", device, n.address, n)
|
||||
return True
|
||||
|
||||
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])
|
||||
if n.sub_id == Notification.DJ_PAIRING: # device connection (and disconnection)
|
||||
flags = ord(n.data[:1]) & 0xF0
|
||||
if n.address == 0x02: # very old 27 MHz protocol
|
||||
wpid = "00" + common.strhex(n.data[2:3])
|
||||
link_established = True
|
||||
link_encrypted = bool(flags & 0x80)
|
||||
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])
|
||||
elif n.address > 0x00: # all other protocols are supposed to be almost the same
|
||||
wpid = common.strhex(n.data[2:3] + n.data[1:2])
|
||||
link_established = not (flags & 0x40)
|
||||
link_encrypted = bool(flags & 0x20) or notification.address == 0x10 # Bolt protocol always encrypted
|
||||
link_encrypted = bool(flags & 0x20) or n.address == 0x10 # Bolt protocol always encrypted
|
||||
else:
|
||||
logger.warning(
|
||||
"%s: connection notification with unknown protocol %02X: %s", device.number, notification.address, notification
|
||||
)
|
||||
logger.warning("%s: connection notification with unknown protocol %02X: %s", device.number, n.address, n)
|
||||
return True
|
||||
if wpid != device.wpid:
|
||||
logger.warning("%s wpid mismatch, got %s", device, wpid)
|
||||
|
|
@ -206,7 +280,7 @@ def _process_hidpp10_notification(device: Device, notification: HIDPPNotificatio
|
|||
logger.debug(
|
||||
"%s: protocol %s connection notification: software=%s, encrypted=%s, link=%s, payload=%s",
|
||||
device,
|
||||
notification.address,
|
||||
n.address,
|
||||
bool(flags & 0x10),
|
||||
link_encrypted,
|
||||
link_established,
|
||||
|
|
@ -218,164 +292,151 @@ def _process_hidpp10_notification(device: Device, notification: HIDPPNotificatio
|
|||
device.changed(active=link_established)
|
||||
return True
|
||||
|
||||
if notification.sub_id == Notification.RAW_INPUT:
|
||||
if n.sub_id == Notification.RAW_INPUT:
|
||||
# raw input event? just ignore it
|
||||
# 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
|
||||
# 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
|
||||
return True
|
||||
|
||||
if notification.sub_id == Notification.POWER:
|
||||
if notification.address == 0x01:
|
||||
if n.sub_id == Notification.POWER:
|
||||
if n.address == 0x01:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: device powered on", device)
|
||||
reason = device.status_string() or _("powered on")
|
||||
device.changed(active=True, alert=Alert.NOTIFICATION, reason=reason)
|
||||
else:
|
||||
logger.warning("%s: unknown %s", device, notification)
|
||||
logger.warning("%s: unknown %s", device, n)
|
||||
return True
|
||||
|
||||
logger.warning("%s: unrecognized %s", device, notification)
|
||||
logger.warning("%s: unrecognized %s", device, n)
|
||||
|
||||
|
||||
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
|
||||
|
||||
def _process_feature_notification(device, n, feature):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"%s: notification for feature %s, report %s, data %s",
|
||||
device,
|
||||
feature,
|
||||
notification.address >> 4,
|
||||
common.strhex(notification.data),
|
||||
"%s: notification for feature %s, report %s, data %s", device, feature, n.address >> 4, common.strhex(n.data)
|
||||
)
|
||||
|
||||
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 feature == _F.BATTERY_STATUS:
|
||||
if n.address == 0x00:
|
||||
device.set_battery_info(hidpp20.decipher_battery_status(n.data)[1])
|
||||
elif n.address == 0x10:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: spurious BATTERY status %s", device, notification)
|
||||
logger.info("%s: spurious BATTERY status %s", device, n)
|
||||
else:
|
||||
logger.warning("%s: unknown BATTERY %s", device, notification)
|
||||
logger.warning("%s: unknown BATTERY %s", device, n)
|
||||
|
||||
elif feature == SupportedFeature.BATTERY_VOLTAGE:
|
||||
if notification.address == 0x00:
|
||||
device.set_battery_info(hidpp20.decipher_battery_voltage(notification.data)[1])
|
||||
elif feature == _F.BATTERY_VOLTAGE:
|
||||
if n.address == 0x00:
|
||||
device.set_battery_info(hidpp20.decipher_battery_voltage(n.data)[1])
|
||||
else:
|
||||
logger.warning("%s: unknown VOLTAGE %s", device, notification)
|
||||
logger.warning("%s: unknown VOLTAGE %s", device, n)
|
||||
|
||||
elif feature == SupportedFeature.UNIFIED_BATTERY:
|
||||
if notification.address == 0x00:
|
||||
device.set_battery_info(hidpp20.decipher_battery_unified(notification.data)[1])
|
||||
elif feature == _F.UNIFIED_BATTERY:
|
||||
if n.address == 0x00:
|
||||
device.set_battery_info(hidpp20.decipher_battery_unified(n.data)[1])
|
||||
else:
|
||||
logger.warning("%s: unknown UNIFIED BATTERY %s", device, notification)
|
||||
logger.warning("%s: unknown UNIFIED BATTERY %s", device, n)
|
||||
|
||||
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
|
||||
elif feature == _F.ADC_MEASUREMENT:
|
||||
if n.address == 0x00:
|
||||
result = hidpp20.decipher_adc_measurement(n.data)
|
||||
if result:
|
||||
device.set_battery_info(result[1])
|
||||
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
|
||||
else: # this feature is used to signal device becoming inactive
|
||||
device.changed(active=False)
|
||||
else:
|
||||
logger.warning("%s: unknown ADC MEASUREMENT %s", device, notification)
|
||||
logger.warning("%s: unknown ADC MEASUREMENT %s", device, n)
|
||||
|
||||
elif feature == SupportedFeature.SOLAR_DASHBOARD:
|
||||
if notification.data[5:9] == b"GOOD":
|
||||
charge, lux, adc = struct.unpack("!BHH", notification.data[:5])
|
||||
elif feature == _F.SOLAR_DASHBOARD:
|
||||
if n.data[5:9] == b"GOOD":
|
||||
charge, lux, adc = struct.unpack("!BHH", n.data[:5])
|
||||
# guesstimate the battery voltage, emphasis on 'guess'
|
||||
# status_text = '%1.2fV' % (adc * 2.67793237653 / 0x0672)
|
||||
status_text = BatteryStatus.DISCHARGING
|
||||
if notification.address == 0x00:
|
||||
if n.address == 0x00:
|
||||
device.set_battery_info(common.Battery(charge, None, status_text, None))
|
||||
elif notification.address == 0x10:
|
||||
elif n.address == 0x10:
|
||||
if lux > 200:
|
||||
status_text = BatteryStatus.RECHARGING
|
||||
device.set_battery_info(common.Battery(charge, None, status_text, None, lux))
|
||||
elif notification.address == 0x20:
|
||||
elif n.address == 0x20:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: Light Check button pressed", device)
|
||||
device.changed(alert=Alert.SHOW_WINDOW)
|
||||
# first cancel any reporting
|
||||
# device.feature_request(SupportedFeature.SOLAR_DASHBOARD)
|
||||
# device.feature_request(_F.SOLAR_DASHBOARD)
|
||||
# trigger a new report chain
|
||||
reports_count = 15
|
||||
reports_period = 2 # seconds
|
||||
device.feature_request(SupportedFeature.SOLAR_DASHBOARD, 0x00, reports_count, reports_period)
|
||||
device.feature_request(_F.SOLAR_DASHBOARD, 0x00, reports_count, reports_period)
|
||||
else:
|
||||
logger.warning("%s: unknown SOLAR CHARGE %s", device, notification)
|
||||
logger.warning("%s: unknown SOLAR CHARGE %s", device, n)
|
||||
else:
|
||||
logger.warning("%s: SOLAR CHARGE not GOOD? %s", device, notification)
|
||||
logger.warning("%s: SOLAR CHARGE not GOOD? %s", device, n)
|
||||
|
||||
elif feature == SupportedFeature.WIRELESS_DEVICE_STATUS:
|
||||
if notification.address == 0x00:
|
||||
elif feature == _F.WIRELESS_DEVICE_STATUS:
|
||||
if n.address == 0x00:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("wireless status: %s", notification)
|
||||
reason = "powered on" if notification.data[2] == 1 else None
|
||||
if notification.data[1] == 1: # device is asking for software reconfiguration so need to change status
|
||||
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
|
||||
device.changed(active=True, alert=alert, reason=reason, push=True)
|
||||
else:
|
||||
logger.warning("%s: unknown WIRELESS %s", device, notification)
|
||||
logger.warning("%s: unknown WIRELESS %s", device, n)
|
||||
|
||||
elif feature == SupportedFeature.TOUCHMOUSE_RAW_POINTS:
|
||||
if notification.address == 0x00:
|
||||
elif feature == _F.TOUCHMOUSE_RAW_POINTS:
|
||||
if n.address == 0x00:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: TOUCH MOUSE points %s", device, notification)
|
||||
elif notification.address == 0x10:
|
||||
touch = ord(notification.data[:1])
|
||||
logger.info("%s: TOUCH MOUSE points %s", device, n)
|
||||
elif n.address == 0x10:
|
||||
touch = ord(n.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, notification)
|
||||
logger.warning("%s: unknown TOUCH MOUSE %s", device, n)
|
||||
|
||||
# TODO: what are REPROG_CONTROLS_V{2,3}?
|
||||
elif feature == SupportedFeature.REPROG_CONTROLS:
|
||||
if notification.address == 0x00:
|
||||
elif feature == _F.REPROG_CONTROLS:
|
||||
if n.address == 0x00:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: reprogrammable key: %s", device, notification)
|
||||
logger.info("%s: reprogrammable key: %s", device, n)
|
||||
else:
|
||||
logger.warning("%s: unknown REPROG_CONTROLS %s", device, notification)
|
||||
logger.warning("%s: unknown REPROG_CONTROLS %s", device, n)
|
||||
|
||||
elif feature == SupportedFeature.BACKLIGHT2:
|
||||
if notification.address == 0x00:
|
||||
level = struct.unpack("!B", notification.data[1:2])[0]
|
||||
elif feature == _F.BACKLIGHT2:
|
||||
if n.address == 0x00:
|
||||
level = struct.unpack("!B", n.data[1:2])[0]
|
||||
if device.setting_callback:
|
||||
device.setting_callback(device, settings_templates.Backlight2Level, [level])
|
||||
|
||||
elif feature == SupportedFeature.REPROG_CONTROLS_V4:
|
||||
if notification.address == 0x00:
|
||||
elif feature == _F.REPROG_CONTROLS_V4:
|
||||
if n.address == 0x00:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
cid1, cid2, cid3, cid4 = struct.unpack("!HHHH", notification.data[:8])
|
||||
cid1, cid2, cid3, cid4 = struct.unpack("!HHHH", n.data[:8])
|
||||
logger.debug("%s: diverted controls pressed: 0x%x, 0x%x, 0x%x, 0x%x", device, cid1, cid2, cid3, cid4)
|
||||
elif notification.address == 0x10:
|
||||
elif n.address == 0x10:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
dx, dy = struct.unpack("!hh", notification.data[:4])
|
||||
dx, dy = struct.unpack("!hh", n.data[:4])
|
||||
logger.debug("%s: rawXY dx=%i dy=%i", device, dx, dy)
|
||||
elif notification.address == 0x20:
|
||||
elif n.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, notification)
|
||||
logger.info("%s: unknown REPROG_CONTROLS_V4 %s", device, n)
|
||||
|
||||
elif feature == SupportedFeature.HIRES_WHEEL:
|
||||
if notification.address == 0x00:
|
||||
elif feature == _F.HIRES_WHEEL:
|
||||
if n.address == 0x00:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
flags, delta_v = struct.unpack(">bh", notification.data[:3])
|
||||
flags, delta_v = struct.unpack(">bh", n.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 notification.address == 0x10:
|
||||
ratchet = notification.data[0]
|
||||
elif n.address == 0x10:
|
||||
ratchet = n.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
|
||||
|
|
@ -383,20 +444,20 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
|
|||
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, notification)
|
||||
logger.info("%s: unknown WHEEL %s", device, n)
|
||||
|
||||
elif feature == SupportedFeature.ONBOARD_PROFILES:
|
||||
if notification.address > 0x10:
|
||||
elif feature == _F.ONBOARD_PROFILES:
|
||||
if n.address > 0x10:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: unknown ONBOARD PROFILES %s", device, notification)
|
||||
logger.info("%s: unknown ONBOARD PROFILES %s", device, n)
|
||||
else:
|
||||
if notification.address == 0x00:
|
||||
profile_sector = struct.unpack("!H", notification.data[:2])[0]
|
||||
if n.address == 0x00:
|
||||
profile_sector = struct.unpack("!H", n.data[:2])[0]
|
||||
if profile_sector:
|
||||
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]
|
||||
elif n.address == 0x10:
|
||||
resolution_index = struct.unpack("!B", n.data[:1])[0]
|
||||
profile_sector = struct.unpack("!H", device.feature_request(_F.ONBOARD_PROFILES, 0x40)[:2])[0]
|
||||
if device.setting_callback:
|
||||
for profile in device.profiles.profiles.values() if device.profiles else []:
|
||||
if profile.sector == profile_sector:
|
||||
|
|
@ -405,108 +466,19 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
|
|||
)
|
||||
break
|
||||
|
||||
elif feature == SupportedFeature.BRIGHTNESS_CONTROL:
|
||||
if notification.address > 0x10:
|
||||
elif feature == _F.BRIGHTNESS_CONTROL:
|
||||
if n.address > 0x10:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: unknown BRIGHTNESS CONTROL %s", device, notification)
|
||||
logger.info("%s: unknown BRIGHTNESS CONTROL %s", device, n)
|
||||
else:
|
||||
if notification.address == 0x00:
|
||||
brightness = struct.unpack("!H", notification.data[:2])[0]
|
||||
if n.address == 0x00:
|
||||
brightness = struct.unpack("!H", n.data[:2])[0]
|
||||
device.setting_callback(device, settings_templates.BrightnessControl, [brightness])
|
||||
elif notification.address == 0x10:
|
||||
brightness = notification.data[0] & 0x01
|
||||
elif n.address == 0x10:
|
||||
brightness = n.data[0] & 0x01
|
||||
if brightness:
|
||||
brightness = struct.unpack("!H", device.feature_request(SupportedFeature.BRIGHTNESS_CONTROL, 0x10)[:2])[0]
|
||||
brightness = struct.unpack("!H", device.feature_request(_F.BRIGHTNESS_CONTROL, 0x10)[:2])[0]
|
||||
device.setting_callback(device, settings_templates.BrightnessControl, [brightness])
|
||||
|
||||
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:
|
||||
diversion.process_notification(device, n, feature)
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -36,18 +36,15 @@ from . import hidpp10_constants
|
|||
from .common import Alert
|
||||
from .common import Notification
|
||||
from .device import Device
|
||||
from .hidpp10_constants import InfoSubRegisters
|
||||
from .hidpp10_constants import NotificationFlag
|
||||
from .hidpp10_constants import Registers
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from logitech_receiver import common
|
||||
|
||||
from .base import HIDPPNotification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_hidpp10 = hidpp10.Hidpp10()
|
||||
_IR = hidpp10_constants.INFO_SUBREGISTERS
|
||||
|
||||
|
||||
class LowLevelInterface(Protocol):
|
||||
|
|
@ -83,53 +80,6 @@ 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.
|
||||
|
|
@ -174,11 +124,11 @@ class Receiver:
|
|||
|
||||
def initialize(self, product_info: dict):
|
||||
# read the receiver information subregister, so we can find out max_devices
|
||||
serial_reply = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.RECEIVER_INFORMATION)
|
||||
serial_reply = self.read_register(Registers.RECEIVER_INFO, _IR.receiver_information)
|
||||
if serial_reply:
|
||||
self.serial = extract_serial(serial_reply[1:5])
|
||||
self.max_devices = extract_max_devices(serial_reply)
|
||||
if not (1 <= self.max_devices <= 6):
|
||||
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.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
|
||||
|
|
@ -211,7 +161,8 @@ class Receiver:
|
|||
if self._remaining_pairings is None or not cache:
|
||||
ps = self.read_register(Registers.RECEIVER_CONNECTION)
|
||||
if ps is not None:
|
||||
self._remaining_pairings = extract_remaining_pairings(ps)
|
||||
ps = ord(ps[2:3])
|
||||
self._remaining_pairings = ps - 5 if ps >= 5 else -1
|
||||
return self._remaining_pairings
|
||||
|
||||
def enable_connection_notifications(self, enable=True):
|
||||
|
|
@ -221,7 +172,7 @@ class Receiver:
|
|||
return False
|
||||
|
||||
if enable:
|
||||
set_flag_bits = NotificationFlag.WIRELESS | NotificationFlag.SOFTWARE_PRESENT
|
||||
set_flag_bits = hidpp10_constants.NOTIFICATION_FLAG.wireless | hidpp10_constants.NOTIFICATION_FLAG.software_present
|
||||
else:
|
||||
set_flag_bits = 0
|
||||
ok = _hidpp10.set_notification_flags(self, set_flag_bits)
|
||||
|
|
@ -230,18 +181,16 @@ class Receiver:
|
|||
return None
|
||||
|
||||
flag_bits = _hidpp10.get_notification_flags(self)
|
||||
if flag_bits is None:
|
||||
flag_names = None
|
||||
else:
|
||||
flag_names = hidpp10_constants.NotificationFlag.flag_names(flag_bits)
|
||||
flag_names = None if flag_bits is None else tuple(hidpp10_constants.NOTIFICATION_FLAG.flag_names(flag_bits))
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: receiver notifications %s => %s", self, "enabled" if enable else "disabled", flag_names)
|
||||
return flag_bits
|
||||
|
||||
def device_codename(self, n):
|
||||
codename = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.DEVICE_NAME + n - 1)
|
||||
codename = self.read_register(Registers.RECEIVER_INFO, _IR.device_name + n - 1)
|
||||
if codename:
|
||||
return extract_codename(codename)
|
||||
codename = codename[2 : 2 + ord(codename[1:2])]
|
||||
return codename.decode("ascii")
|
||||
|
||||
def notify_devices(self):
|
||||
"""Scan all devices."""
|
||||
|
|
@ -249,13 +198,13 @@ class Receiver:
|
|||
if not self.write_register(Registers.RECEIVER_CONNECTION, 0x02):
|
||||
logger.warning("%s: failed to trigger device link notifications", self)
|
||||
|
||||
def notification_information(self, number, notification: HIDPPNotification) -> tuple[bool, bool, typing.Any, str]:
|
||||
def notification_information(self, number, notification):
|
||||
"""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 = extract_device_kind(notification.data[0] & 0x0F)
|
||||
wpid = extract_wpid(notification.data[2:3] + notification.data[1:2])
|
||||
kind = hidpp10_constants.DEVICE_KIND[notification.data[0] & 0x0F]
|
||||
wpid = (notification.data[2:3] + notification.data[1:2]).hex().upper()
|
||||
return online, encrypted, wpid, kind
|
||||
|
||||
def device_pairing_information(self, n: int) -> dict:
|
||||
|
|
@ -263,31 +212,30 @@ class Receiver:
|
|||
polling_rate = ""
|
||||
serial = None
|
||||
power_switch = "(unknown)"
|
||||
pair_info = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.PAIRING_INFORMATION + n - 1)
|
||||
pair_info = self.read_register(Registers.RECEIVER_INFO, _IR.pairing_information + n - 1)
|
||||
if pair_info: # a receiver that uses Unifying-style pairing registers
|
||||
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"
|
||||
wpid = pair_info[3:5].hex().upper()
|
||||
kind = hidpp10_constants.DEVICE_KIND[pair_info[7] & 0x0F]
|
||||
polling_rate = str(pair_info[2]) + "ms"
|
||||
elif not self.receiver_kind == "unifying": # may be an old Nano receiver
|
||||
device_info = self.read_register(Registers.RECEIVER_INFO, 0x04) # undocumented
|
||||
if device_info:
|
||||
logger.warning("using undocumented register for device wpid")
|
||||
wpid = extract_wpid(device_info[3:5])
|
||||
kind = extract_device_kind(0x00) # unknown kind
|
||||
wpid = device_info[3:5].hex().upper()
|
||||
kind = hidpp10_constants.DEVICE_KIND[0x00] # unknown kind
|
||||
else:
|
||||
raise exceptions.NoSuchDevice(number=n, receiver=self, error="read pairing information - non-unifying")
|
||||
else:
|
||||
raise exceptions.NoSuchDevice(number=n, receiver=self, error="read pairing information")
|
||||
pair_info = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.EXTENDED_PAIRING_INFORMATION + n - 1)
|
||||
pair_info = self.read_register(Registers.RECEIVER_INFO, _IR.extended_pairing_information + n - 1)
|
||||
if pair_info:
|
||||
power_switch = extract_power_switch_location(pair_info)
|
||||
serial = extract_serial(pair_info[1:5])
|
||||
power_switch = hidpp10_constants.POWER_SWITCH_LOCATION[pair_info[9] & 0x0F]
|
||||
serial = pair_info[1:5].hex().upper()
|
||||
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 = extract_serial(pair_info[1:5])
|
||||
serial = pair_info[1:5].hex().upper()
|
||||
return {"wpid": wpid, "kind": kind, "polling": polling_rate, "serial": serial, "power_switch": power_switch}
|
||||
|
||||
def register_new_device(self, number, notification=None):
|
||||
|
|
@ -333,9 +281,7 @@ class Receiver:
|
|||
|
||||
def count(self):
|
||||
count = self.read_register(Registers.RECEIVER_CONNECTION)
|
||||
if count is None:
|
||||
return 0
|
||||
return extract_connection_count(count)
|
||||
return 0 if count is None else ord(count[1:2])
|
||||
|
||||
def request(self, request_id, *params):
|
||||
if bool(self):
|
||||
|
|
@ -460,21 +406,21 @@ class BoltReceiver(Receiver):
|
|||
|
||||
def initialize(self, product_info: dict):
|
||||
serial_reply = self.read_register(Registers.BOLT_UNIQUE_ID)
|
||||
self.serial = extract_serial(serial_reply)
|
||||
self.serial = serial_reply.hex().upper()
|
||||
self.max_devices = product_info.get("max_devices", 1)
|
||||
|
||||
def device_codename(self, n):
|
||||
codename = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.BOLT_DEVICE_NAME + n, 0x01)
|
||||
codename = self.read_register(Registers.RECEIVER_INFO, _IR.bolt_device_name + n, 0x01)
|
||||
if codename:
|
||||
codename = codename[3 : 3 + min(14, ord(codename[2:3]))]
|
||||
return codename.decode("ascii")
|
||||
|
||||
def device_pairing_information(self, n: int) -> dict:
|
||||
pair_info = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.BOLT_PAIRING_INFORMATION + n)
|
||||
pair_info = self.read_register(Registers.RECEIVER_INFO, _IR.bolt_pairing_information + n)
|
||||
if pair_info:
|
||||
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])
|
||||
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()
|
||||
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")
|
||||
|
|
@ -532,8 +478,8 @@ class Ex100Receiver(Receiver):
|
|||
assert notification.address == 0x02
|
||||
online = True
|
||||
encrypted = bool(notification.data[0] & 0x80)
|
||||
kind = extract_device_kind(_get_kind_from_index(self, number))
|
||||
wpid = "00" + extract_wpid(notification.data[2:3])
|
||||
kind = hidpp10_constants.DEVICE_KIND[_get_kind_from_index(self, number)]
|
||||
wpid = "00" + notification.data[2:3].hex().upper()
|
||||
return online, encrypted, wpid, kind
|
||||
|
||||
def device_pairing_information(self, number: int) -> dict:
|
||||
|
|
@ -542,11 +488,11 @@ class Ex100Receiver(Receiver):
|
|||
if not wpid:
|
||||
logger.error("Unable to get wpid from udev for device %d of %s", number, self)
|
||||
raise exceptions.NoSuchDevice(number=number, receiver=self, error="Not present 27Mhz device")
|
||||
kind = extract_device_kind(_get_kind_from_index(self, number))
|
||||
kind = hidpp10_constants.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: int) -> int:
|
||||
def _get_kind_from_index(receiver, index):
|
||||
"""Get device kind from 27Mhz device index"""
|
||||
# From drivers/hid/hid-logitech-dj.c
|
||||
if index == 1: # mouse
|
||||
|
|
@ -599,6 +545,6 @@ def create_receiver(low_level: LowLevelInterface, device_info, setting_callback=
|
|||
except OSError as e:
|
||||
logger.exception("open %s", device_info)
|
||||
if e.errno == errno.EACCES:
|
||||
raise e
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("open %s", device_info)
|
||||
|
|
|
|||
|
|
@ -16,33 +16,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import struct
|
||||
import time
|
||||
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
from solaar.i18n import _
|
||||
|
||||
from . import common
|
||||
from . import hidpp20_constants
|
||||
from . import settings_validator
|
||||
from .common import NamedInt
|
||||
from .common import NamedInts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SENSITIVITY_IGNORE = "ignore"
|
||||
KIND = NamedInts(
|
||||
toggle=0x01,
|
||||
choice=0x02,
|
||||
range=0x04,
|
||||
map_choice=0x0A,
|
||||
multiple_toggle=0x10,
|
||||
packed_range=0x20,
|
||||
multiple_range=0x40,
|
||||
hetero=0x80,
|
||||
)
|
||||
|
||||
|
||||
class Kind(IntEnum):
|
||||
TOGGLE = 0x01
|
||||
CHOICE = 0x02
|
||||
RANGE = 0x04
|
||||
MAP_CHOICE = 0x0A
|
||||
MULTIPLE_TOGGLE = 0x10
|
||||
PACKED_RANGE = 0x20
|
||||
MULTIPLE_RANGE = 0x40
|
||||
HETERO = 0x80
|
||||
def bool_or_toggle(current: bool | str, new: bool | str) -> bool:
|
||||
if isinstance(new, bool):
|
||||
return new
|
||||
|
||||
try:
|
||||
return bool(int(new))
|
||||
except (TypeError, ValueError):
|
||||
new = str(new).lower()
|
||||
|
||||
if new in ("true", "yes", "on", "t", "y"):
|
||||
return True
|
||||
if new in ("false", "no", "off", "f", "n"):
|
||||
return False
|
||||
if new in ("~", "toggle"):
|
||||
return not current
|
||||
return None
|
||||
|
||||
|
||||
class Setting:
|
||||
|
|
@ -87,14 +103,14 @@ class Setting:
|
|||
assert hasattr(self, "_value")
|
||||
assert hasattr(self, "_device")
|
||||
|
||||
return self._validator.choices if self._validator and self._validator.kind & Kind.CHOICE else None
|
||||
return self._validator.choices if self._validator and self._validator.kind & KIND.choice else None
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
assert hasattr(self, "_value")
|
||||
assert hasattr(self, "_device")
|
||||
|
||||
if self._validator.kind == Kind.RANGE:
|
||||
if self._validator.kind == KIND.range:
|
||||
return self._validator.min_value, self._validator.max_value
|
||||
|
||||
def _pre_read(self, cached, key=None):
|
||||
|
|
@ -277,7 +293,7 @@ class Settings(Setting):
|
|||
self._value[int(key)] = value
|
||||
self._pre_write(save)
|
||||
|
||||
def write_key_value(self, key, value, save=True) -> Any | None:
|
||||
def write_key_value(self, key, value, save=True):
|
||||
assert hasattr(self, "_value")
|
||||
assert hasattr(self, "_device")
|
||||
assert key is not None
|
||||
|
|
@ -634,10 +650,7 @@ class FeatureRW:
|
|||
|
||||
def read(self, device, data_bytes=b""):
|
||||
assert self.feature is not None
|
||||
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""
|
||||
return device.feature_request(self.feature, self.read_fnid, self.prefix, self.read_prefix, data_bytes)
|
||||
|
||||
def write(self, device, data_bytes):
|
||||
assert self.feature is not None
|
||||
|
|
@ -679,6 +692,709 @@ class FeatureRWMap(FeatureRW):
|
|||
return reply if not self.no_reply else True
|
||||
|
||||
|
||||
class Validator:
|
||||
@classmethod
|
||||
def build(cls, setting_class, device, **kwargs):
|
||||
return cls(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def to_string(cls, value):
|
||||
return str(value)
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) != 1:
|
||||
return False
|
||||
return args[0] == current
|
||||
|
||||
|
||||
class BooleanValidator(Validator):
|
||||
__slots__ = ("true_value", "false_value", "read_skip_byte_count", "write_prefix_bytes", "mask", "needs_current_value")
|
||||
|
||||
kind = KIND.toggle
|
||||
default_true = 0x01
|
||||
default_false = 0x00
|
||||
# mask specifies all the affected bits in the value
|
||||
default_mask = 0xFF
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
true_value=default_true,
|
||||
false_value=default_false,
|
||||
mask=default_mask,
|
||||
read_skip_byte_count=0,
|
||||
write_prefix_bytes=b"",
|
||||
):
|
||||
if isinstance(true_value, int):
|
||||
assert isinstance(false_value, int)
|
||||
if mask is None:
|
||||
mask = self.default_mask
|
||||
else:
|
||||
assert isinstance(mask, int)
|
||||
assert true_value & false_value == 0
|
||||
assert true_value & mask == true_value
|
||||
assert false_value & mask == false_value
|
||||
self.needs_current_value = mask != self.default_mask
|
||||
elif isinstance(true_value, bytes):
|
||||
if false_value is None or false_value == self.default_false:
|
||||
false_value = b"\x00" * len(true_value)
|
||||
else:
|
||||
assert isinstance(false_value, bytes)
|
||||
if mask is None or mask == self.default_mask:
|
||||
mask = b"\xff" * len(true_value)
|
||||
else:
|
||||
assert isinstance(mask, bytes)
|
||||
assert len(mask) == len(true_value) == len(false_value)
|
||||
tv = common.bytes2int(true_value)
|
||||
fv = common.bytes2int(false_value)
|
||||
mv = common.bytes2int(mask)
|
||||
assert tv != fv # true and false might be something other than bit values
|
||||
assert tv & mv == tv
|
||||
assert fv & mv == fv
|
||||
self.needs_current_value = any(m != 0xFF for m in mask)
|
||||
else:
|
||||
raise Exception(f"invalid mask '{mask!r}', type {type(mask)}")
|
||||
|
||||
self.true_value = true_value
|
||||
self.false_value = false_value
|
||||
self.mask = mask
|
||||
self.read_skip_byte_count = read_skip_byte_count
|
||||
self.write_prefix_bytes = write_prefix_bytes
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
reply_bytes = reply_bytes[self.read_skip_byte_count :]
|
||||
if isinstance(self.mask, int):
|
||||
reply_value = ord(reply_bytes[:1]) & self.mask
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("BooleanValidator: validate read %r => %02X", reply_bytes, reply_value)
|
||||
if reply_value == self.true_value:
|
||||
return True
|
||||
if reply_value == self.false_value:
|
||||
return False
|
||||
logger.warning(
|
||||
"BooleanValidator: reply %02X mismatched %02X/%02X/%02X",
|
||||
reply_value,
|
||||
self.true_value,
|
||||
self.false_value,
|
||||
self.mask,
|
||||
)
|
||||
return False
|
||||
|
||||
count = len(self.mask)
|
||||
mask = common.bytes2int(self.mask)
|
||||
reply_value = common.bytes2int(reply_bytes[:count]) & mask
|
||||
|
||||
true_value = common.bytes2int(self.true_value)
|
||||
if reply_value == true_value:
|
||||
return True
|
||||
|
||||
false_value = common.bytes2int(self.false_value)
|
||||
if reply_value == false_value:
|
||||
return False
|
||||
|
||||
logger.warning(
|
||||
"BooleanValidator: reply %r mismatched %r/%r/%r", reply_bytes, self.true_value, self.false_value, self.mask
|
||||
)
|
||||
return False
|
||||
|
||||
def prepare_write(self, new_value, current_value=None):
|
||||
if new_value is None:
|
||||
new_value = False
|
||||
else:
|
||||
assert isinstance(new_value, bool), f"New value {new_value} for boolean setting is not a boolean"
|
||||
|
||||
to_write = self.true_value if new_value else self.false_value
|
||||
|
||||
if isinstance(self.mask, int):
|
||||
if current_value is not None and self.needs_current_value:
|
||||
to_write |= ord(current_value[:1]) & (0xFF ^ self.mask)
|
||||
if current_value is not None and to_write == ord(current_value[:1]):
|
||||
return None
|
||||
to_write = bytes([to_write])
|
||||
else:
|
||||
to_write = bytearray(to_write)
|
||||
count = len(self.mask)
|
||||
for i in range(0, count):
|
||||
b = ord(to_write[i : i + 1])
|
||||
m = ord(self.mask[i : i + 1])
|
||||
assert b & m == b
|
||||
# b &= m
|
||||
if current_value is not None and self.needs_current_value:
|
||||
b |= ord(current_value[i : i + 1]) & (0xFF ^ m)
|
||||
to_write[i] = b
|
||||
to_write = bytes(to_write)
|
||||
|
||||
if current_value is not None and to_write == current_value[: len(to_write)]:
|
||||
return None
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("BooleanValidator: prepare_write(%s, %s) => %r", new_value, current_value, to_write)
|
||||
|
||||
return self.write_prefix_bytes + to_write
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 1:
|
||||
return None
|
||||
val = bool_or_toggle(current, args[0])
|
||||
return [val] if val is not None else None
|
||||
|
||||
|
||||
class BitFieldValidator(Validator):
|
||||
__slots__ = ("byte_count", "options")
|
||||
|
||||
kind = KIND.multiple_toggle
|
||||
|
||||
def __init__(self, options, byte_count=None):
|
||||
assert isinstance(options, list)
|
||||
self.options = options
|
||||
self.byte_count = (max(x.bit_length() for x in options) + 7) // 8
|
||||
if byte_count:
|
||||
assert isinstance(byte_count, int) and byte_count >= self.byte_count
|
||||
self.byte_count = byte_count
|
||||
|
||||
def to_string(self, value):
|
||||
def element_to_string(key, val):
|
||||
k = next((k for k in self.options if int(key) == k), None)
|
||||
return str(k) + ":" + str(val) if k is not None else "?"
|
||||
|
||||
return "{" + ", ".join([element_to_string(k, value[k]) for k in value]) + "}"
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
r = common.bytes2int(reply_bytes[: self.byte_count])
|
||||
value = {int(k): False for k in self.options}
|
||||
m = 1
|
||||
for _ignore in range(8 * self.byte_count):
|
||||
if m in self.options:
|
||||
value[int(m)] = bool(r & m)
|
||||
m <<= 1
|
||||
return value
|
||||
|
||||
def prepare_write(self, new_value):
|
||||
assert isinstance(new_value, dict)
|
||||
w = 0
|
||||
for k, v in new_value.items():
|
||||
if v:
|
||||
w |= int(k)
|
||||
return common.int2bytes(w, self.byte_count)
|
||||
|
||||
def get_options(self):
|
||||
return self.options
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 2:
|
||||
return None
|
||||
key = next((key for key in self.options if key == args[0]), None)
|
||||
if key is None:
|
||||
return None
|
||||
val = bool_or_toggle(current[int(key)], args[1])
|
||||
return None if val is None else [int(key), val]
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) != 2:
|
||||
return False
|
||||
key = next((key for key in self.options if key == args[0]), None)
|
||||
if key is None:
|
||||
return False
|
||||
return args[1] == current[int(key)]
|
||||
|
||||
|
||||
class BitFieldWithOffsetAndMaskValidator(Validator):
|
||||
__slots__ = ("byte_count", "options", "_option_from_key", "_mask_from_offset", "_option_from_offset_mask")
|
||||
|
||||
kind = KIND.multiple_toggle
|
||||
sep = 0x01
|
||||
|
||||
def __init__(self, options, om_method=None, byte_count=None):
|
||||
assert isinstance(options, list)
|
||||
# each element of options is an instance of a class
|
||||
# that has an id (which is used as an index in other dictionaries)
|
||||
# and where om_method is a method that returns a byte offset and byte mask
|
||||
# that says how to access and modify the bit toggle for the option
|
||||
self.options = options
|
||||
self.om_method = om_method
|
||||
# to retrieve the options efficiently:
|
||||
self._option_from_key = {}
|
||||
self._mask_from_offset = {}
|
||||
self._option_from_offset_mask = {}
|
||||
for opt in options:
|
||||
offset, mask = om_method(opt)
|
||||
self._option_from_key[int(opt)] = opt
|
||||
try:
|
||||
self._mask_from_offset[offset] |= mask
|
||||
except KeyError:
|
||||
self._mask_from_offset[offset] = mask
|
||||
try:
|
||||
mask_to_opt = self._option_from_offset_mask[offset]
|
||||
except KeyError:
|
||||
mask_to_opt = {}
|
||||
self._option_from_offset_mask[offset] = mask_to_opt
|
||||
mask_to_opt[mask] = opt
|
||||
self.byte_count = (max(om_method(x)[1].bit_length() for x in options) + 7) // 8 # is this correct??
|
||||
if byte_count:
|
||||
assert isinstance(byte_count, int) and byte_count >= self.byte_count
|
||||
self.byte_count = byte_count
|
||||
|
||||
def prepare_read(self):
|
||||
r = []
|
||||
for offset, mask in self._mask_from_offset.items():
|
||||
b = offset << (8 * (self.byte_count + 1))
|
||||
b |= (self.sep << (8 * self.byte_count)) | mask
|
||||
r.append(common.int2bytes(b, self.byte_count + 2))
|
||||
return r
|
||||
|
||||
def prepare_read_key(self, key):
|
||||
option = self._option_from_key.get(key, None)
|
||||
if option is None:
|
||||
return None
|
||||
offset, mask = option.om_method(option)
|
||||
b = offset << (8 * (self.byte_count + 1))
|
||||
b |= (self.sep << (8 * self.byte_count)) | mask
|
||||
return common.int2bytes(b, self.byte_count + 2)
|
||||
|
||||
def validate_read(self, reply_bytes_dict):
|
||||
values = {int(k): False for k in self.options}
|
||||
for query, b in reply_bytes_dict.items():
|
||||
offset = common.bytes2int(query[0:1])
|
||||
b += (self.byte_count - len(b)) * b"\x00"
|
||||
value = common.bytes2int(b[: self.byte_count])
|
||||
mask_to_opt = self._option_from_offset_mask.get(offset, {})
|
||||
m = 1
|
||||
for _ignore in range(8 * self.byte_count):
|
||||
if m in mask_to_opt:
|
||||
values[int(mask_to_opt[m])] = bool(value & m)
|
||||
m <<= 1
|
||||
return values
|
||||
|
||||
def prepare_write(self, new_value):
|
||||
assert isinstance(new_value, dict)
|
||||
w = {}
|
||||
for k, v in new_value.items():
|
||||
option = self._option_from_key[int(k)]
|
||||
offset, mask = self.om_method(option)
|
||||
if offset not in w:
|
||||
w[offset] = 0
|
||||
if v:
|
||||
w[offset] |= mask
|
||||
return [
|
||||
common.int2bytes(
|
||||
(offset << (8 * (2 * self.byte_count + 1)))
|
||||
| (self.sep << (16 * self.byte_count))
|
||||
| (self._mask_from_offset[offset] << (8 * self.byte_count))
|
||||
| value,
|
||||
2 * self.byte_count + 2,
|
||||
)
|
||||
for offset, value in w.items()
|
||||
]
|
||||
|
||||
def get_options(self):
|
||||
return [int(opt) if isinstance(opt, int) else opt.as_int() for opt in self.options]
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 2:
|
||||
return None
|
||||
key = next((option.id for option in self.options if option.as_int() == args[0]), None)
|
||||
if key is None:
|
||||
return None
|
||||
val = bool_or_toggle(current[int(key)], args[1])
|
||||
return None if val is None else [int(key), val]
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) != 2:
|
||||
return False
|
||||
key = next((option.id for option in self.options if option.as_int() == args[0]), None)
|
||||
if key is None:
|
||||
return False
|
||||
return args[1] == current[int(key)]
|
||||
|
||||
|
||||
class ChoicesValidator(Validator):
|
||||
"""Translates between NamedInts and a byte sequence.
|
||||
:param choices: a list of NamedInts
|
||||
:param byte_count: the size of the derived byte sequence. If None, it
|
||||
will be calculated from the choices."""
|
||||
|
||||
kind = KIND.choice
|
||||
|
||||
def __init__(self, choices=None, byte_count=None, read_skip_byte_count=0, write_prefix_bytes=b""):
|
||||
assert choices is not None
|
||||
assert isinstance(choices, NamedInts)
|
||||
assert len(choices) > 1
|
||||
self.choices = choices
|
||||
self.needs_current_value = False
|
||||
|
||||
max_bits = max(x.bit_length() for x in choices)
|
||||
self._byte_count = (max_bits // 8) + (1 if max_bits % 8 else 0)
|
||||
if byte_count:
|
||||
assert self._byte_count <= byte_count
|
||||
self._byte_count = byte_count
|
||||
assert self._byte_count < 8
|
||||
self._read_skip_byte_count = read_skip_byte_count
|
||||
self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b""
|
||||
assert self._byte_count + self._read_skip_byte_count <= 14
|
||||
assert self._byte_count + len(self._write_prefix_bytes) <= 14
|
||||
|
||||
def to_string(self, value):
|
||||
return str(self.choices[value]) if isinstance(value, int) else str(value)
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
reply_value = common.bytes2int(reply_bytes[self._read_skip_byte_count : self._read_skip_byte_count + self._byte_count])
|
||||
valid_value = self.choices[reply_value]
|
||||
assert valid_value is not None, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
|
||||
return valid_value
|
||||
|
||||
def prepare_write(self, new_value, current_value=None):
|
||||
if new_value is None:
|
||||
value = self.choices[:][0]
|
||||
else:
|
||||
value = self.choice(new_value)
|
||||
if value is None:
|
||||
raise ValueError(f"invalid choice {new_value!r}")
|
||||
assert isinstance(value, NamedInt)
|
||||
return self._write_prefix_bytes + value.bytes(self._byte_count)
|
||||
|
||||
def choice(self, value):
|
||||
if isinstance(value, int):
|
||||
return self.choices[value]
|
||||
try:
|
||||
int(value)
|
||||
if int(value) in self.choices:
|
||||
return self.choices[int(value)]
|
||||
except Exception:
|
||||
pass
|
||||
if value in self.choices:
|
||||
return self.choices[value]
|
||||
else:
|
||||
return None
|
||||
|
||||
def acceptable(self, args, current):
|
||||
choice = self.choice(args[0]) if len(args) == 1 else None
|
||||
return None if choice is None else [choice]
|
||||
|
||||
|
||||
class ChoicesMapValidator(ChoicesValidator):
|
||||
kind = KIND.map_choice
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
choices_map,
|
||||
key_byte_count=0,
|
||||
key_postfix_bytes=b"",
|
||||
byte_count=0,
|
||||
read_skip_byte_count=0,
|
||||
write_prefix_bytes=b"",
|
||||
extra_default=None,
|
||||
mask=-1,
|
||||
activate=0,
|
||||
):
|
||||
assert choices_map is not None
|
||||
assert isinstance(choices_map, dict)
|
||||
max_key_bits = 0
|
||||
max_value_bits = 0
|
||||
for key, choices in choices_map.items():
|
||||
assert isinstance(key, NamedInt)
|
||||
assert isinstance(choices, NamedInts)
|
||||
max_key_bits = max(max_key_bits, key.bit_length())
|
||||
for key_value in choices:
|
||||
assert isinstance(key_value, NamedInt)
|
||||
max_value_bits = max(max_value_bits, key_value.bit_length())
|
||||
self._key_byte_count = (max_key_bits + 7) // 8
|
||||
if key_byte_count:
|
||||
assert self._key_byte_count <= key_byte_count
|
||||
self._key_byte_count = key_byte_count
|
||||
self._byte_count = (max_value_bits + 7) // 8
|
||||
if byte_count:
|
||||
assert self._byte_count <= byte_count
|
||||
self._byte_count = byte_count
|
||||
|
||||
self.choices = choices_map
|
||||
self.needs_current_value = False
|
||||
self.extra_default = extra_default
|
||||
self._key_postfix_bytes = key_postfix_bytes
|
||||
self._read_skip_byte_count = read_skip_byte_count if read_skip_byte_count else 0
|
||||
self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b""
|
||||
self.activate = activate
|
||||
self.mask = mask
|
||||
assert self._byte_count + self._read_skip_byte_count + self._key_byte_count <= 14
|
||||
assert self._byte_count + len(self._write_prefix_bytes) + self._key_byte_count <= 14
|
||||
|
||||
def to_string(self, value):
|
||||
def element_to_string(key, val):
|
||||
k, c = next(((k, c) for k, c in self.choices.items() if int(key) == k), (None, None))
|
||||
return str(k) + ":" + str(c[val]) if k is not None else "?"
|
||||
|
||||
return "{" + ", ".join([element_to_string(k, value[k]) for k in sorted(value)]) + "}"
|
||||
|
||||
def validate_read(self, reply_bytes, key):
|
||||
start = self._key_byte_count + self._read_skip_byte_count
|
||||
end = start + self._byte_count
|
||||
reply_value = common.bytes2int(reply_bytes[start:end]) & self.mask
|
||||
# reprogrammable keys starts out as 0, which is not a choice, so don't use assert here
|
||||
if self.extra_default is not None and self.extra_default == reply_value:
|
||||
return int(self.choices[key][0])
|
||||
if reply_value not in self.choices[key]:
|
||||
assert reply_value in self.choices[key], "%s: failed to validate read value %02X" % (
|
||||
self.__class__.__name__,
|
||||
reply_value,
|
||||
)
|
||||
return reply_value
|
||||
|
||||
def prepare_key(self, key):
|
||||
return key.to_bytes(self._key_byte_count, "big") + self._key_postfix_bytes
|
||||
|
||||
def prepare_write(self, key, new_value):
|
||||
choices = self.choices.get(key)
|
||||
if choices is None or (new_value not in choices and new_value != self.extra_default):
|
||||
logger.error("invalid choice %r for %s", new_value, key)
|
||||
return None
|
||||
new_value = new_value | self.activate
|
||||
return self._write_prefix_bytes + new_value.to_bytes(self._byte_count, "big")
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 2:
|
||||
return None
|
||||
key, choices = next(((key, item) for key, item in self.choices.items() if key == args[0]), (None, None))
|
||||
if choices is None or args[1] not in choices:
|
||||
return None
|
||||
choice = next((item for item in choices if item == args[1]), None)
|
||||
return [int(key), int(choice)] if choice is not None else None
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) != 2:
|
||||
return False
|
||||
key = next((key for key in self.choices if key == int(args[0])), None)
|
||||
if key is None:
|
||||
return False
|
||||
return args[1] == current[int(key)]
|
||||
|
||||
|
||||
class RangeValidator(Validator):
|
||||
kind = KIND.range
|
||||
"""Translates between integers and a byte sequence.
|
||||
:param min_value: minimum accepted value (inclusive)
|
||||
:param max_value: maximum accepted value (inclusive)
|
||||
:param byte_count: the size of the derived byte sequence. If None, it
|
||||
will be calculated from the range."""
|
||||
min_value = 0
|
||||
max_value = 255
|
||||
|
||||
@classmethod
|
||||
def build(cls, setting_class, device, **kwargs):
|
||||
kwargs["min_value"] = setting_class.min_value
|
||||
kwargs["max_value"] = setting_class.max_value
|
||||
return cls(**kwargs)
|
||||
|
||||
def __init__(self, min_value=0, max_value=255, byte_count=1):
|
||||
assert max_value > min_value
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
self.needs_current_value = True # read and check before write (needed for ADC power and probably a good idea anyway)
|
||||
|
||||
self._byte_count = math.ceil(math.log(max_value + 1, 256))
|
||||
if byte_count:
|
||||
assert self._byte_count <= byte_count
|
||||
self._byte_count = byte_count
|
||||
assert self._byte_count < 8
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
reply_value = common.bytes2int(reply_bytes[: self._byte_count])
|
||||
assert reply_value >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
|
||||
assert reply_value <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
|
||||
return reply_value
|
||||
|
||||
def prepare_write(self, new_value, current_value=None):
|
||||
if new_value < self.min_value or new_value > self.max_value:
|
||||
raise ValueError(f"invalid choice {new_value!r}")
|
||||
current_value = self.validate_read(current_value) if current_value is not None else None
|
||||
to_write = common.int2bytes(new_value, self._byte_count)
|
||||
# current value is known and same as value to be written return None to signal not to write it
|
||||
return None if current_value is not None and current_value == new_value else to_write
|
||||
|
||||
def acceptable(self, args, current):
|
||||
arg = args[0]
|
||||
# None if len(args) != 1 or type(arg) != int or arg < self.min_value or arg > self.max_value else args)
|
||||
return None if len(args) != 1 or isinstance(arg, int) or arg < self.min_value or arg > self.max_value else args
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) == 1:
|
||||
return args[0] == current
|
||||
elif len(args) == 2:
|
||||
return args[0] <= current <= args[1]
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class HeteroValidator(Validator):
|
||||
kind = KIND.hetero
|
||||
|
||||
@classmethod
|
||||
def build(cls, setting_class, device, **kwargs):
|
||||
return cls(**kwargs)
|
||||
|
||||
def __init__(self, data_class=None, options=None, readable=True):
|
||||
assert data_class is not None and options is not None
|
||||
self.data_class = data_class
|
||||
self.options = options
|
||||
self.readable = readable
|
||||
self.needs_current_value = False
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
if self.readable:
|
||||
reply_value = self.data_class.from_bytes(reply_bytes, options=self.options)
|
||||
return reply_value
|
||||
|
||||
def prepare_write(self, new_value, current_value=None):
|
||||
to_write = new_value.to_bytes(options=self.options)
|
||||
return to_write
|
||||
|
||||
def acceptable(self, args, current): # should this actually do some checking?
|
||||
return True
|
||||
|
||||
|
||||
class PackedRangeValidator(Validator):
|
||||
kind = KIND.packed_range
|
||||
"""Several range values, all the same size, all the same min and max"""
|
||||
min_value = 0
|
||||
max_value = 255
|
||||
count = 1
|
||||
rsbc = 0
|
||||
write_prefix_bytes = b""
|
||||
|
||||
def __init__(
|
||||
self, keys, min_value=0, max_value=255, count=1, byte_count=1, read_skip_byte_count=0, write_prefix_bytes=b""
|
||||
):
|
||||
assert max_value > min_value
|
||||
self.needs_current_value = True
|
||||
self.keys = keys
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
self.count = count
|
||||
self.bc = math.ceil(math.log(max_value + 1 - min(0, min_value), 256))
|
||||
if byte_count:
|
||||
assert self.bc <= byte_count
|
||||
self.bc = byte_count
|
||||
assert self.bc * self.count
|
||||
self.rsbc = read_skip_byte_count
|
||||
self.write_prefix_bytes = write_prefix_bytes
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
rvs = {
|
||||
n: common.bytes2int(reply_bytes[self.rsbc + n * self.bc : self.rsbc + (n + 1) * self.bc], signed=True)
|
||||
for n in range(self.count)
|
||||
}
|
||||
for n in range(self.count):
|
||||
assert rvs[n] >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}"
|
||||
assert rvs[n] <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}"
|
||||
return rvs
|
||||
|
||||
def prepare_write(self, new_values):
|
||||
if len(new_values) != self.count:
|
||||
raise ValueError(f"wrong number of values {new_values!r}")
|
||||
for new_value in new_values.values():
|
||||
if new_value < self.min_value or new_value > self.max_value:
|
||||
raise ValueError(f"invalid value {new_value!r}")
|
||||
bytes = self.write_prefix_bytes + b"".join(
|
||||
common.int2bytes(new_values[n], self.bc, signed=True) for n in range(self.count)
|
||||
)
|
||||
return bytes
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 2 or int(args[0]) < 0 or int(args[0]) >= self.count:
|
||||
return None
|
||||
return None if not isinstance(args[1], int) or args[1] < self.min_value or args[1] > self.max_value else args
|
||||
|
||||
def compare(self, args, current):
|
||||
logger.warning("compare not implemented for packed range settings")
|
||||
return False
|
||||
|
||||
|
||||
class MultipleRangeValidator(Validator):
|
||||
kind = KIND.multiple_range
|
||||
|
||||
def __init__(self, items, sub_items):
|
||||
assert isinstance(items, list) # each element must have .index and its __int__ must return its id (not its index)
|
||||
assert isinstance(sub_items, dict)
|
||||
# sub_items: items -> class with .minimum, .maximum, .length (in bytes), .id (a string) and .widget (e.g. 'Scale')
|
||||
self.items = items
|
||||
self.keys = NamedInts(**{str(item): int(item) for item in items})
|
||||
self._item_from_id = {int(k): k for k in items}
|
||||
self.sub_items = sub_items
|
||||
|
||||
def prepare_read_item(self, item):
|
||||
return common.int2bytes((self._item_from_id[int(item)].index << 1) | 0xFF, 2)
|
||||
|
||||
def validate_read_item(self, reply_bytes, item):
|
||||
item = self._item_from_id[int(item)]
|
||||
start = 0
|
||||
value = {}
|
||||
for sub_item in self.sub_items[item]:
|
||||
r = reply_bytes[start : start + sub_item.length]
|
||||
if len(r) < sub_item.length:
|
||||
r += b"\x00" * (sub_item.length - len(value))
|
||||
v = common.bytes2int(r)
|
||||
if not (sub_item.minimum < v < sub_item.maximum):
|
||||
logger.warning(
|
||||
f"{self.__class__.__name__}: failed to validate read value for {item}.{sub_item}: "
|
||||
+ f"{v} not in [{sub_item.minimum}..{sub_item.maximum}]"
|
||||
)
|
||||
value[str(sub_item)] = v
|
||||
start += sub_item.length
|
||||
return value
|
||||
|
||||
def prepare_write(self, value):
|
||||
seq = []
|
||||
w = b""
|
||||
for item in value.keys():
|
||||
_item = self._item_from_id[int(item)]
|
||||
b = common.int2bytes(_item.index, 1)
|
||||
for sub_item in self.sub_items[_item]:
|
||||
try:
|
||||
v = value[int(item)][str(sub_item)]
|
||||
except KeyError:
|
||||
return None
|
||||
if not (sub_item.minimum <= v <= sub_item.maximum):
|
||||
raise ValueError(
|
||||
f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]"
|
||||
)
|
||||
b += common.int2bytes(v, sub_item.length)
|
||||
if len(w) + len(b) > 15:
|
||||
seq.append(b + b"\xff")
|
||||
w = b""
|
||||
w += b
|
||||
seq.append(w + b"\xff")
|
||||
return seq
|
||||
|
||||
def prepare_write_item(self, item, value):
|
||||
_item = self._item_from_id[int(item)]
|
||||
w = common.int2bytes(_item.index, 1)
|
||||
for sub_item in self.sub_items[_item]:
|
||||
try:
|
||||
v = value[str(sub_item)]
|
||||
except KeyError:
|
||||
return None
|
||||
if not (sub_item.minimum <= v <= sub_item.maximum):
|
||||
raise ValueError(f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]")
|
||||
w += common.int2bytes(v, sub_item.length)
|
||||
return w + b"\xff"
|
||||
|
||||
def acceptable(self, args, current):
|
||||
# just one item, with at least one sub-item
|
||||
if not isinstance(args, list) or len(args) != 2 or not isinstance(args[1], dict):
|
||||
return None
|
||||
item = next((p for p in self.items if p.id == args[0] or str(p) == args[0]), None)
|
||||
if not item:
|
||||
return None
|
||||
for sub_key, value in args[1].items():
|
||||
sub_item = next((it for it in self.sub_items[item] if it.id == sub_key), None)
|
||||
if not sub_item:
|
||||
return None
|
||||
if not isinstance(value, int) or not (sub_item.minimum <= value <= sub_item.maximum):
|
||||
return None
|
||||
return [int(item), {**args[1]}]
|
||||
|
||||
def compare(self, args, current):
|
||||
logger.warning("compare not implemented for multiple range settings")
|
||||
return False
|
||||
|
||||
|
||||
class ActionSettingRW:
|
||||
"""Special RW class for settings that turn on and off special processing when a key or button is depressed"""
|
||||
|
||||
|
|
@ -862,4 +1578,4 @@ def apply_all_settings(device):
|
|||
s.apply()
|
||||
|
||||
|
||||
Setting.validator_class = settings_validator.BooleanValidator
|
||||
Setting.validator_class = BooleanValidator
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ import struct
|
|||
import traceback
|
||||
|
||||
from time import time
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import Protocol
|
||||
|
||||
from solaar.i18n import _
|
||||
|
||||
|
|
@ -32,21 +32,19 @@ from . import common
|
|||
from . import descriptors
|
||||
from . import desktop_notifications
|
||||
from . import diversion
|
||||
from . import exceptions
|
||||
from . import hidpp10_constants
|
||||
from . import hidpp20
|
||||
from . import hidpp20_constants
|
||||
from . import settings
|
||||
from . import settings_validator
|
||||
from . import special_keys
|
||||
from .hidpp10_constants import Registers
|
||||
from .hidpp20 import KeyFlag
|
||||
from .hidpp20 import MappingFlag
|
||||
from .hidpp20_constants import GestureId
|
||||
from .hidpp20_constants import ParamId
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_hidpp20 = hidpp20.Hidpp20()
|
||||
_DK = hidpp10_constants.DEVICE_KIND
|
||||
_F = hidpp20_constants.SupportedFeature
|
||||
|
||||
|
||||
|
|
@ -179,7 +177,7 @@ class RegisterDpi(settings.Setting):
|
|||
description = _("Mouse movement sensitivity")
|
||||
register = Registers.MOUSE_DPI
|
||||
choices_universe = common.NamedInts.range(0x81, 0x8F, lambda x: str((x - 0x80) * 100))
|
||||
validator_class = settings_validator.ChoicesValidator
|
||||
validator_class = settings.ChoicesValidator
|
||||
validator_options = {"choices": choices_universe}
|
||||
|
||||
|
||||
|
|
@ -253,7 +251,7 @@ class Backlight(settings.Setting):
|
|||
description = _("Set illumination time for keyboard.")
|
||||
feature = _F.BACKLIGHT
|
||||
choices_universe = common.NamedInts(Off=0, Varying=2, VeryShort=5, Short=10, Medium=20, Long=60, VeryLong=180)
|
||||
validator_class = settings_validator.ChoicesValidator
|
||||
validator_class = settings.ChoicesValidator
|
||||
validator_options = {"choices": choices_universe}
|
||||
|
||||
|
||||
|
|
@ -287,7 +285,7 @@ class Backlight2(settings.Setting):
|
|||
backlight.write()
|
||||
return True
|
||||
|
||||
class validator_class(settings_validator.ChoicesValidator):
|
||||
class validator_class(settings.ChoicesValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
backlight = device.backlight
|
||||
|
|
@ -324,7 +322,7 @@ class Backlight2Level(settings.Setting):
|
|||
device.backlight.write()
|
||||
return True
|
||||
|
||||
class validator_class(settings_validator.RangeValidator):
|
||||
class validator_class(settings.RangeValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
reply = device.feature_request(_F.BACKLIGHT2, 0x20)
|
||||
|
|
@ -336,7 +334,7 @@ class Backlight2Level(settings.Setting):
|
|||
class Backlight2Duration(settings.Setting):
|
||||
feature = _F.BACKLIGHT2
|
||||
min_version = 3
|
||||
validator_class = settings_validator.RangeValidator
|
||||
validator_class = settings.RangeValidator
|
||||
min_value = 1
|
||||
max_value = 600 # 10 minutes - actual maximum is 2 hours
|
||||
validator_options = {"byte_count": 2}
|
||||
|
|
@ -365,7 +363,7 @@ class Backlight2DurationHandsOut(Backlight2Duration):
|
|||
label = _("Backlight Delay Hands Out")
|
||||
description = _("Delay in seconds until backlight fades out with hands away from keyboard.")
|
||||
feature = _F.BACKLIGHT2
|
||||
validator_class = settings_validator.RangeValidator
|
||||
validator_class = settings.RangeValidator
|
||||
rw_options = {"field": "dho"}
|
||||
|
||||
|
||||
|
|
@ -374,7 +372,7 @@ class Backlight2DurationHandsIn(Backlight2Duration):
|
|||
label = _("Backlight Delay Hands In")
|
||||
description = _("Delay in seconds until backlight fades out with hands near keyboard.")
|
||||
feature = _F.BACKLIGHT2
|
||||
validator_class = settings_validator.RangeValidator
|
||||
validator_class = settings.RangeValidator
|
||||
rw_options = {"field": "dhi"}
|
||||
|
||||
|
||||
|
|
@ -383,7 +381,7 @@ class Backlight2DurationPowered(Backlight2Duration):
|
|||
label = _("Backlight Delay Powered")
|
||||
description = _("Delay in seconds until backlight fades out with external power.")
|
||||
feature = _F.BACKLIGHT2
|
||||
validator_class = settings_validator.RangeValidator
|
||||
validator_class = settings.RangeValidator
|
||||
rw_options = {"field": "dpow"}
|
||||
|
||||
|
||||
|
|
@ -393,7 +391,7 @@ class Backlight3(settings.Setting):
|
|||
description = _("Set illumination time for keyboard.")
|
||||
feature = _F.BACKLIGHT3
|
||||
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20, "suffix": b"\x09"}
|
||||
validator_class = settings_validator.RangeValidator
|
||||
validator_class = settings.RangeValidator
|
||||
min_value = 0
|
||||
max_value = 1000
|
||||
validator_options = {"byte_count": 2}
|
||||
|
|
@ -457,7 +455,7 @@ class PointerSpeed(settings.Setting):
|
|||
label = _("Sensitivity (Pointer Speed)")
|
||||
description = _("Speed multiplier for mouse (256 is normal multiplier).")
|
||||
feature = _F.POINTER_SPEED
|
||||
validator_class = settings_validator.RangeValidator
|
||||
validator_class = settings.RangeValidator
|
||||
min_value = 0x002E
|
||||
max_value = 0x01FF
|
||||
validator_options = {"byte_count": 2}
|
||||
|
|
@ -504,7 +502,7 @@ class OnboardProfiles(settings.Setting):
|
|||
for i in range(1, 16):
|
||||
choices_universe[i] = f"Profile {i}"
|
||||
choices_universe[i + 0x100] = f"Read-Only Profile {i}"
|
||||
validator_class = settings_validator.ChoicesValidator
|
||||
validator_class = settings.ChoicesValidator
|
||||
|
||||
class rw_class:
|
||||
def __init__(self, feature):
|
||||
|
|
@ -528,7 +526,7 @@ class OnboardProfiles(settings.Setting):
|
|||
profile_change(device, common.bytes2int(data_bytes))
|
||||
return result
|
||||
|
||||
class validator_class(settings_validator.ChoicesValidator):
|
||||
class validator_class(settings.ChoicesValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
headers = hidpp20.OnboardProfiles.get_profile_headers(device)
|
||||
|
|
@ -558,7 +556,7 @@ class ReportRate(settings.Setting):
|
|||
choices_universe[7] = "7ms"
|
||||
choices_universe[8] = "8ms"
|
||||
|
||||
class validator_class(settings_validator.ChoicesValidator):
|
||||
class validator_class(settings.ChoicesValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
# if device.wpid == '408E':
|
||||
|
|
@ -590,7 +588,7 @@ class ExtendedReportRate(settings.Setting):
|
|||
choices_universe[5] = "250us"
|
||||
choices_universe[6] = "125us"
|
||||
|
||||
class validator_class(settings_validator.ChoicesValidator):
|
||||
class validator_class(settings.ChoicesValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
reply = device.feature_request(_F.EXTENDED_ADJUSTABLE_REPORT_RATE, 0x10)
|
||||
|
|
@ -642,7 +640,7 @@ class ScrollRatchet(settings.Setting):
|
|||
description = _("Switch the mouse wheel between speed-controlled ratcheting and always freespin.")
|
||||
feature = _F.SMART_SHIFT
|
||||
choices_universe = common.NamedInts(**{_("Freespinning"): 1, _("Ratcheted"): 2})
|
||||
validator_class = settings_validator.ChoicesValidator
|
||||
validator_class = settings.ChoicesValidator
|
||||
validator_options = {"choices": choices_universe}
|
||||
|
||||
|
||||
|
|
@ -685,7 +683,7 @@ class SmartShift(settings.Setting):
|
|||
|
||||
min_value = rw_class.MIN_VALUE
|
||||
max_value = rw_class.MAX_VALUE
|
||||
validator_class = settings_validator.RangeValidator
|
||||
validator_class = settings.RangeValidator
|
||||
|
||||
|
||||
class SmartShiftEnhanced(SmartShift):
|
||||
|
|
@ -732,7 +730,7 @@ class ReprogrammableKeys(settings.Settings):
|
|||
key_struct.remap(special_keys.CONTROL[common.bytes2int(data_bytes)])
|
||||
return True
|
||||
|
||||
class validator_class(settings_validator.ChoicesMapValidator):
|
||||
class validator_class(settings.ChoicesMapValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
choices = {}
|
||||
|
|
@ -901,7 +899,7 @@ class DivertKeys(settings.Settings):
|
|||
def read(self, device, key):
|
||||
key_index = device.keys.index(key)
|
||||
key_struct = device.keys[key_index]
|
||||
return b"\x00\x00\x01" if MappingFlag.DIVERTED in key_struct.mapping_flags else b"\x00\x00\x00"
|
||||
return b"\x00\x00\x01" if "diverted" in key_struct.mapping_flags else b"\x00\x00\x00"
|
||||
|
||||
def write(self, device, key, data_bytes):
|
||||
key_index = device.keys.index(key)
|
||||
|
|
@ -909,7 +907,7 @@ class DivertKeys(settings.Settings):
|
|||
key_struct.set_diverted(common.bytes2int(data_bytes) != 0) # not regular
|
||||
return True
|
||||
|
||||
class validator_class(settings_validator.ChoicesMapValidator):
|
||||
class validator_class(settings.ChoicesMapValidator):
|
||||
def __init__(self, choices, key_byte_count=2, byte_count=1, mask=0x01):
|
||||
super().__init__(choices, key_byte_count, byte_count, mask)
|
||||
|
||||
|
|
@ -929,20 +927,20 @@ class DivertKeys(settings.Settings):
|
|||
sliding = gestures = None
|
||||
choices = {}
|
||||
if device.keys:
|
||||
for key in device.keys:
|
||||
if KeyFlag.DIVERTABLE in key.flags and KeyFlag.VIRTUAL not in key.flags:
|
||||
if KeyFlag.RAW_XY in key.flags:
|
||||
choices[key.key] = setting_class.choices_gesture
|
||||
for k in device.keys:
|
||||
if "divertable" in k.flags and "virtual" not in k.flags:
|
||||
if "raw XY" in k.flags:
|
||||
choices[k.key] = setting_class.choices_gesture
|
||||
if gestures is None:
|
||||
gestures = MouseGesturesXY(device, name="MouseGestures")
|
||||
if _F.ADJUSTABLE_DPI in device.features:
|
||||
choices[key.key] = setting_class.choices_universe
|
||||
choices[k.key] = setting_class.choices_universe
|
||||
if sliding is None:
|
||||
sliding = DpiSlidingXY(
|
||||
device, name="DpiSliding", show_notification=desktop_notifications.show
|
||||
)
|
||||
else:
|
||||
choices[key.key] = setting_class.choices_divert
|
||||
choices[k.key] = setting_class.choices_divert
|
||||
if not choices:
|
||||
return None
|
||||
validator = cls(choices, key_byte_count=2, byte_count=1, mask=0x01)
|
||||
|
|
@ -985,7 +983,7 @@ class AdjustableDpi(settings.Setting):
|
|||
rw_options = {"read_fnid": 0x20, "write_fnid": 0x30}
|
||||
choices_universe = common.NamedInts.range(100, 4000, str, 50)
|
||||
|
||||
class validator_class(settings_validator.ChoicesValidator):
|
||||
class validator_class(settings.ChoicesValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
dpilist = produce_dpi_list(setting_class.feature, 0x10, 1, device, 0)
|
||||
|
|
@ -1013,9 +1011,9 @@ class ExtendedAdjustableDpi(settings.Setting):
|
|||
rw_options = {"read_fnid": 0x50, "write_fnid": 0x60}
|
||||
keys_universe = common.NamedInts(X=0, Y=1, LOD=2)
|
||||
choices_universe = common.NamedInts.range(100, 4000, str, 50)
|
||||
choices_universe[1] = "LOW"
|
||||
choices_universe[2] = "MEDIUM"
|
||||
choices_universe[3] = "HIGH"
|
||||
choices_universe[0] = "LOW"
|
||||
choices_universe[1] = "MEDIUM"
|
||||
choices_universe[2] = "HIGH"
|
||||
keys = common.NamedInts(X=0, Y=1, LOD=2)
|
||||
|
||||
def write_key_value(self, key, value, save=True):
|
||||
|
|
@ -1026,7 +1024,7 @@ class ExtendedAdjustableDpi(settings.Setting):
|
|||
result = self.write(self._value, save)
|
||||
return result[key] if isinstance(result, dict) else result
|
||||
|
||||
class validator_class(settings_validator.ChoicesMapValidator):
|
||||
class validator_class(settings.ChoicesMapValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
reply = device.feature_request(setting_class.feature, 0x10, 0x00)
|
||||
|
|
@ -1109,12 +1107,12 @@ class SpeedChange(settings.Setting):
|
|||
if self.device.persister:
|
||||
self.device.persister["_speed-change"] = currentSpeed
|
||||
|
||||
class validator_class(settings_validator.ChoicesValidator):
|
||||
class validator_class(settings.ChoicesValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
key_index = device.keys.index(special_keys.CONTROL.DPI_Change)
|
||||
key = device.keys[key_index] if key_index is not None else None
|
||||
if key is not None and KeyFlag.DIVERTABLE in key.flags:
|
||||
if key is not None and "divertable" in key.flags:
|
||||
keys = [setting_class.choices_extra, key.key]
|
||||
return cls(choices=common.NamedInts.list(keys), byte_count=2)
|
||||
|
||||
|
|
@ -1128,7 +1126,7 @@ class DisableKeyboardKeys(settings.BitFieldSetting):
|
|||
_labels = {k: (None, _("Disables the %s key.") % k) for k in special_keys.DISABLE}
|
||||
choices_universe = special_keys.DISABLE
|
||||
|
||||
class validator_class(settings_validator.BitFieldValidator):
|
||||
class validator_class(settings.BitFieldValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
mask = device.feature_request(_F.KEYBOARD_DISABLE_KEYS, 0x00)[0]
|
||||
|
|
@ -1160,7 +1158,7 @@ class Multiplatform(settings.Setting):
|
|||
# the problem here is how to construct the right values for the rules Set GUI,
|
||||
# as, for example, the integer value for 'Windows' can be different on different devices
|
||||
|
||||
class validator_class(settings_validator.ChoicesValidator):
|
||||
class validator_class(settings.ChoicesValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
def _str_os_versions(low, high):
|
||||
|
|
@ -1168,11 +1166,11 @@ class Multiplatform(settings.Setting):
|
|||
if version == 0:
|
||||
return ""
|
||||
elif version & 0xFF:
|
||||
return f"{str(version >> 8)}.{str(version & 0xFF)}"
|
||||
return str(version >> 8) + "." + str(version & 0xFF)
|
||||
else:
|
||||
return str(version >> 8)
|
||||
|
||||
return "" if low == 0 and high == 0 else f" {_str_os_version(low)}-{_str_os_version(high)}"
|
||||
return "" if low == 0 and high == 0 else " " + _str_os_version(low) + "-" + _str_os_version(high)
|
||||
|
||||
infos = device.feature_request(_F.MULTIPLATFORM)
|
||||
assert infos, "Oops, multiplatform count cannot be retrieved!"
|
||||
|
|
@ -1202,7 +1200,7 @@ class DualPlatform(settings.Setting):
|
|||
choices_universe[0x01] = "Android, Windows"
|
||||
feature = _F.DUALPLATFORM
|
||||
rw_options = {"read_fnid": 0x00, "write_fnid": 0x20}
|
||||
validator_class = settings_validator.ChoicesValidator
|
||||
validator_class = settings.ChoicesValidator
|
||||
validator_options = {"choices": choices_universe}
|
||||
|
||||
|
||||
|
|
@ -1215,7 +1213,7 @@ class ChangeHost(settings.Setting):
|
|||
rw_options = {"read_fnid": 0x00, "write_fnid": 0x10, "no_reply": True}
|
||||
choices_universe = common.NamedInts(**{"Host " + str(i + 1): i for i in range(3)})
|
||||
|
||||
class validator_class(settings_validator.ChoicesValidator):
|
||||
class validator_class(settings.ChoicesValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
infos = device.feature_request(_F.CHANGE_HOST)
|
||||
|
|
@ -1228,7 +1226,7 @@ class ChangeHost(settings.Setting):
|
|||
choices = common.NamedInts()
|
||||
for host in range(0, numHosts):
|
||||
paired, hostName = hostNames.get(host, (True, ""))
|
||||
choices[host] = f"{str(host + 1)}:{hostName}" if hostName else str(host + 1)
|
||||
choices[host] = str(host + 1) + ":" + hostName if hostName else str(host + 1)
|
||||
return cls(choices=choices, read_skip_byte_count=1) if choices and len(choices) > 1 else None
|
||||
|
||||
|
||||
|
|
@ -1327,7 +1325,7 @@ class Gesture2Gestures(settings.BitFieldWithOffsetAndMaskSetting):
|
|||
choices_universe = hidpp20_constants.GestureId
|
||||
_labels = _GESTURE2_GESTURES_LABELS
|
||||
|
||||
class validator_class(settings_validator.BitFieldWithOffsetAndMaskValidator):
|
||||
class validator_class(settings.BitFieldWithOffsetAndMaskValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device, om_method=None):
|
||||
options = [g for g in device.gestures.gestures.values() if g.can_be_enabled or g.default_enabled]
|
||||
|
|
@ -1344,7 +1342,7 @@ class Gesture2Divert(settings.BitFieldWithOffsetAndMaskSetting):
|
|||
choices_universe = hidpp20_constants.GestureId
|
||||
_labels = _GESTURE2_GESTURES_LABELS
|
||||
|
||||
class validator_class(settings_validator.BitFieldWithOffsetAndMaskValidator):
|
||||
class validator_class(settings.BitFieldWithOffsetAndMaskValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device, om_method=None):
|
||||
options = [g for g in device.gestures.gestures.values() if g.can_be_diverted]
|
||||
|
|
@ -1365,7 +1363,7 @@ class Gesture2Params(settings.LongSettings):
|
|||
_labels = _GESTURE2_PARAMS_LABELS
|
||||
_labels_sub = _GESTURE2_PARAMS_LABELS_SUB
|
||||
|
||||
class validator_class(settings_validator.MultipleRangeValidator):
|
||||
class validator_class(settings.MultipleRangeValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
params = _hidpp20.get_gestures(device).params.values()
|
||||
|
|
@ -1399,7 +1397,7 @@ class MKeyLEDs(settings.BitFieldSetting):
|
|||
def read(self, device): # no way to read, so just assume off
|
||||
return b"\x00"
|
||||
|
||||
class validator_class(settings_validator.BitFieldValidator):
|
||||
class validator_class(settings.BitFieldValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
number = device.feature_request(setting_class.feature, 0x00)[0]
|
||||
|
|
@ -1457,7 +1455,7 @@ class PersistentRemappableAction(settings.Settings):
|
|||
v = ks.remap(data_bytes)
|
||||
return v
|
||||
|
||||
class validator_class(settings_validator.ChoicesMapValidator):
|
||||
class validator_class(settings.ChoicesMapValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
remap_keys = device.remap_keys
|
||||
|
|
@ -1496,7 +1494,7 @@ class Sidetone(settings.Setting):
|
|||
label = _("Sidetone")
|
||||
description = _("Set sidetone level.")
|
||||
feature = _F.SIDETONE
|
||||
validator_class = settings_validator.RangeValidator
|
||||
validator_class = settings.RangeValidator
|
||||
min_value = 0
|
||||
max_value = 100
|
||||
|
||||
|
|
@ -1509,7 +1507,7 @@ class Equalizer(settings.RangeFieldSetting):
|
|||
rw_options = {"read_fnid": 0x20, "write_fnid": 0x30, "read_prefix": b"\x00"}
|
||||
keys_universe = []
|
||||
|
||||
class validator_class(settings_validator.PackedRangeValidator):
|
||||
class validator_class(settings.PackedRangeValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
data = device.feature_request(_F.EQUALIZER, 0x00)
|
||||
|
|
@ -1535,9 +1533,8 @@ class ADCPower(settings.Setting):
|
|||
label = _("Power Management")
|
||||
description = _("Power off in minutes (0 for never).")
|
||||
feature = _F.ADC_MEASUREMENT
|
||||
min_version = 2 # documentation for version 1 does not mention this capability
|
||||
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20}
|
||||
validator_class = settings_validator.RangeValidator
|
||||
validator_class = settings.RangeValidator
|
||||
min_value = 0x00
|
||||
max_value = 0xFF
|
||||
validator_options = {"byte_count": 1}
|
||||
|
|
@ -1549,7 +1546,7 @@ class BrightnessControl(settings.Setting):
|
|||
description = _("Control overall brightness")
|
||||
feature = _F.BRIGHTNESS_CONTROL
|
||||
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20}
|
||||
validator_class = settings_validator.RangeValidator
|
||||
validator_class = settings.RangeValidator
|
||||
|
||||
def __init__(self, device, rw, validator):
|
||||
super().__init__(device, rw, validator)
|
||||
|
|
@ -1573,7 +1570,7 @@ class BrightnessControl(settings.Setting):
|
|||
return reply
|
||||
return super().write(device, data_bytes)
|
||||
|
||||
class validator_class(settings_validator.RangeValidator):
|
||||
class validator_class(settings.RangeValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
reply = device.feature_request(_F.BRIGHTNESS_CONTROL)
|
||||
|
|
@ -1594,7 +1591,7 @@ class LEDControl(settings.Setting):
|
|||
feature = _F.COLOR_LED_EFFECTS
|
||||
rw_options = {"read_fnid": 0x70, "write_fnid": 0x80}
|
||||
choices_universe = common.NamedInts(Device=0, Solaar=1)
|
||||
validator_class = settings_validator.ChoicesValidator
|
||||
validator_class = settings.ChoicesValidator
|
||||
validator_options = {"choices": choices_universe}
|
||||
|
||||
|
||||
|
|
@ -1608,11 +1605,12 @@ class LEDZoneSetting(settings.Setting):
|
|||
label = _("LED Zone Effects")
|
||||
description = _("Set effect for LED Zone") + "\n" + _("LED Control needs to be set to Solaar to be effective.")
|
||||
feature = _F.COLOR_LED_EFFECTS
|
||||
color_field = {"name": _LEDP.color, "kind": settings.Kind.CHOICE, "label": None, "choices": colors}
|
||||
speed_field = {"name": _LEDP.speed, "kind": settings.Kind.RANGE, "label": _("Speed"), "min": 0, "max": 255}
|
||||
period_field = {"name": _LEDP.period, "kind": settings.Kind.RANGE, "label": _("Period"), "min": 100, "max": 5000}
|
||||
intensity_field = {"name": _LEDP.intensity, "kind": settings.Kind.RANGE, "label": _("Intensity"), "min": 0, "max": 100}
|
||||
ramp_field = {"name": _LEDP.ramp, "kind": settings.Kind.CHOICE, "label": _("Ramp"), "choices": hidpp20.LedRampChoice}
|
||||
color_field = {"name": _LEDP.color, "kind": settings.KIND.choice, "label": None, "choices": colors}
|
||||
speed_field = {"name": _LEDP.speed, "kind": settings.KIND.range, "label": _("Speed"), "min": 0, "max": 255}
|
||||
period_field = {"name": _LEDP.period, "kind": settings.KIND.range, "label": _("Period"), "min": 100, "max": 5000}
|
||||
intensity_field = {"name": _LEDP.intensity, "kind": settings.KIND.range, "label": _("Intensity"), "min": 0, "max": 100}
|
||||
ramp_field = {"name": _LEDP.ramp, "kind": settings.KIND.choice, "label": _("Ramp"), "choices": hidpp20.LEDRampChoices}
|
||||
# form_field = {"name": _LEDP.form, "kind": settings.KIND.choice, "label": _("Form"), "choices": _hidpp20.LEDFormChoices}
|
||||
possible_fields = [color_field, speed_field, period_field, intensity_field, ramp_field]
|
||||
|
||||
@classmethod
|
||||
|
|
@ -1622,14 +1620,14 @@ class LEDZoneSetting(settings.Setting):
|
|||
for zone in infos.zones:
|
||||
prefix = common.int2bytes(zone.index, 1)
|
||||
rw = settings.FeatureRW(cls.feature, read_fnid, write_fnid, prefix=prefix, suffix=suffix)
|
||||
validator = settings_validator.HeteroValidator(
|
||||
data_class=hidpp20.LEDEffectSetting, options=zone.effects, readable=infos.readable and read_fnid is not None
|
||||
validator = settings.HeteroValidator(
|
||||
data_class=hidpp20.LEDEffectSetting, options=zone.effects, readable=infos.readable
|
||||
)
|
||||
setting = cls(device, rw, validator)
|
||||
setting.name = cls.name + str(int(zone.location))
|
||||
setting.label = _("LEDs") + " " + str(hidpp20.LEDZoneLocations[zone.location])
|
||||
choices = [hidpp20.LEDEffects[e.ID][0] for e in zone.effects if e.ID in hidpp20.LEDEffects]
|
||||
ID_field = {"name": "ID", "kind": settings.Kind.CHOICE, "label": None, "choices": choices}
|
||||
ID_field = {"name": "ID", "kind": settings.KIND.choice, "label": None, "choices": choices}
|
||||
setting.possible_fields = [ID_field] + cls.possible_fields
|
||||
setting.fields_map = hidpp20.LEDEffects
|
||||
settings_.append(setting)
|
||||
|
|
@ -1647,7 +1645,7 @@ class RGBControl(settings.Setting):
|
|||
feature = _F.RGB_EFFECTS
|
||||
rw_options = {"read_fnid": 0x50, "write_fnid": 0x50}
|
||||
choices_universe = common.NamedInts(Device=0, Solaar=1)
|
||||
validator_class = settings_validator.ChoicesValidator
|
||||
validator_class = settings.ChoicesValidator
|
||||
validator_options = {"choices": choices_universe, "write_prefix_bytes": b"\x01", "read_skip_byte_count": 1}
|
||||
|
||||
|
||||
|
|
@ -1659,7 +1657,7 @@ class RGBEffectSetting(LEDZoneSetting):
|
|||
|
||||
@classmethod
|
||||
def build(cls, device):
|
||||
return cls.setup(device, None, 0x10, b"\x01")
|
||||
return cls.setup(device, 0xE0, 0x10, b"\x01")
|
||||
|
||||
|
||||
class PerKeyLighting(settings.Settings):
|
||||
|
|
@ -1725,7 +1723,7 @@ class PerKeyLighting(settings.Settings):
|
|||
class rw_class(settings.FeatureRWMap):
|
||||
pass
|
||||
|
||||
class validator_class(settings_validator.ChoicesMapValidator):
|
||||
class validator_class(settings.ChoicesMapValidator):
|
||||
@classmethod
|
||||
def build(cls, setting_class, device):
|
||||
choices_map = {}
|
||||
|
|
@ -1737,14 +1735,14 @@ class PerKeyLighting(settings.Settings):
|
|||
key = (
|
||||
setting_class.keys_universe[i]
|
||||
if i in setting_class.keys_universe
|
||||
else common.NamedInt(i, f"KEY {str(i)}")
|
||||
else common.NamedInt(i, "KEY " + str(i))
|
||||
)
|
||||
choices_map[key] = setting_class.choices_universe
|
||||
result = cls(choices_map) if choices_map else None
|
||||
return result
|
||||
|
||||
|
||||
SETTINGS: list[settings.Setting] = [
|
||||
SETTINGS = [
|
||||
RegisterHandDetection, # simple
|
||||
RegisterSmoothScroll, # simple
|
||||
RegisterSideScroll, # simple
|
||||
|
|
@ -1805,95 +1803,7 @@ SETTINGS: list[settings.Setting] = [
|
|||
]
|
||||
|
||||
|
||||
class SettingsProtocol(Protocol):
|
||||
@property
|
||||
def name(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def feature(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def register(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def kind(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def min_version(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def persist(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def rw_options(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def validator_class(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def validator_options(self):
|
||||
...
|
||||
|
||||
@classmethod
|
||||
def build(cls, device):
|
||||
...
|
||||
|
||||
def val_to_string(self, value):
|
||||
...
|
||||
|
||||
@property
|
||||
def choices(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
...
|
||||
|
||||
def _pre_read(self, cached, key=None):
|
||||
...
|
||||
|
||||
def read(self, cached=True):
|
||||
...
|
||||
|
||||
def _pre_write(self, save=True):
|
||||
...
|
||||
|
||||
def update(self, value, save=True):
|
||||
...
|
||||
|
||||
def write(self, value, save=True):
|
||||
...
|
||||
|
||||
def acceptable(self, args, current):
|
||||
...
|
||||
|
||||
def compare(self, args, current):
|
||||
...
|
||||
|
||||
def apply(self):
|
||||
...
|
||||
|
||||
def __str__(self):
|
||||
...
|
||||
|
||||
|
||||
def check_feature(device, settings_class: SettingsProtocol) -> None | bool | SettingsProtocol:
|
||||
def check_feature(device, settings_class: settings.Setting) -> None | bool | Any:
|
||||
if settings_class.feature not in device.features:
|
||||
return
|
||||
if settings_class.min_version > device.features.get_feature_version(settings_class.feature):
|
||||
|
|
@ -1907,66 +1817,44 @@ def check_feature(device, settings_class: SettingsProtocol) -> None | bool | Set
|
|||
logger.error(
|
||||
"check_feature %s [%s] error %s\n%s", settings_class.name, settings_class.feature, e, traceback.format_exc()
|
||||
)
|
||||
raise e # differentiate from an error-free determination that the setting is not supported
|
||||
return False # differentiate from an error-free determination that the setting is not supported
|
||||
|
||||
|
||||
def check_feature_settings(device, already_known) -> bool:
|
||||
"""Auto-detect device settings by the HID++ 2.0 features they have.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True, if device was fully queried to find features, False otherwise.
|
||||
"""
|
||||
# Returns True if device was queried to find features, False otherwise
|
||||
def check_feature_settings(device, already_known):
|
||||
"""Auto-detect device settings by the HID++ 2.0 features they have."""
|
||||
if not device.features or not device.online:
|
||||
return False
|
||||
if device.protocol and device.protocol < 2.0:
|
||||
return False
|
||||
absent = device.persister.get("_absent", []) if device.persister else []
|
||||
new_absent = []
|
||||
newAbsent = []
|
||||
for sclass in SETTINGS:
|
||||
if sclass.feature:
|
||||
known_present = device.persister and sclass.name in device.persister
|
||||
if not any(s.name == sclass.name for s in already_known) and (known_present or sclass.name not in absent):
|
||||
try:
|
||||
setting = check_feature(device, sclass)
|
||||
except Exception as err:
|
||||
# on an internal HID++ error, assume offline and stop further checking
|
||||
if (
|
||||
isinstance(err, exceptions.FeatureCallError)
|
||||
and err.error == hidpp20_constants.ErrorCode.LOGITECH_ERROR
|
||||
):
|
||||
logger.warning(f"HID++ internal error checking feature {sclass.name}: make device not present")
|
||||
device.online = False
|
||||
device.present = False
|
||||
return False
|
||||
else:
|
||||
logger.warning(f"ignore feature {sclass.name} because of error {err}")
|
||||
|
||||
setting = check_feature(device, sclass)
|
||||
if isinstance(setting, list):
|
||||
for s in setting:
|
||||
already_known.append(s)
|
||||
if sclass.name in new_absent:
|
||||
new_absent.remove(sclass.name)
|
||||
if sclass.name in newAbsent:
|
||||
newAbsent.remove(sclass.name)
|
||||
elif setting:
|
||||
already_known.append(setting)
|
||||
if sclass.name in new_absent:
|
||||
new_absent.remove(sclass.name)
|
||||
if sclass.name in newAbsent:
|
||||
newAbsent.remove(sclass.name)
|
||||
elif setting is None:
|
||||
if sclass.name not in new_absent and sclass.name not in absent and sclass.name not in device.persister:
|
||||
new_absent.append(sclass.name)
|
||||
if device.persister and new_absent:
|
||||
absent.extend(new_absent)
|
||||
if sclass.name not in newAbsent and sclass.name not in absent and sclass.name not in device.persister:
|
||||
newAbsent.append(sclass.name)
|
||||
if device.persister and newAbsent:
|
||||
absent.extend(newAbsent)
|
||||
device.persister["_absent"] = absent
|
||||
return True
|
||||
|
||||
|
||||
def check_feature_setting(device, setting_name: str) -> settings.Setting | None:
|
||||
def check_feature_setting(device, setting_name):
|
||||
for sclass in SETTINGS:
|
||||
if sclass.feature and sclass.name == setting_name and device.features:
|
||||
try:
|
||||
setting = check_feature(device, sclass)
|
||||
except Exception:
|
||||
return None
|
||||
setting = check_feature(device, sclass)
|
||||
if setting:
|
||||
return setting
|
||||
|
|
|
|||
|
|
@ -1,744 +0,0 @@
|
|||
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
|
||||
|
|
@ -29,22 +29,19 @@ from .common import UnsortedNamedInts
|
|||
_XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser(os.path.join("~", ".config"))
|
||||
_keys_file_path = os.path.join(_XDG_CONFIG_HOME, "solaar", "keys.yaml")
|
||||
|
||||
|
||||
# Original set done as
|
||||
# <controls.xml awk -F\" '/<Control /{sub(/^LD_FINFO_(CTRLID_)?/, "", $2);printf("\t%s=0x%04X,\n", $2, $4)}' | sort -t= -k2
|
||||
# Keys added afterwards based on information from Logitech and users
|
||||
CONTROL = NamedInts(
|
||||
{
|
||||
"Volume_Up_old": 0x0001,
|
||||
"Volume_Down_old": 0x0002,
|
||||
"Volume_Up": 0x0001,
|
||||
"Volume_Down": 0x0002,
|
||||
"Mute": 0x0003,
|
||||
"Play__Pause_old": 0x0004,
|
||||
"Play__Pause": 0x0004,
|
||||
"Next": 0x0005,
|
||||
"Previous": 0x0006,
|
||||
"Stop": 0x0007,
|
||||
"Application_Switcher": 0x0008,
|
||||
"Burn": 0x0009,
|
||||
"Calculator": 0x000A, # Craft Keyboard top 4th from right; Logitech
|
||||
"Calculator": 0x000A, # Craft Keyboard top 4th from right
|
||||
"Calendar": 0x000B,
|
||||
"Close": 0x000C,
|
||||
"Eject": 0x000D,
|
||||
|
|
@ -58,7 +55,7 @@ CONTROL = NamedInts(
|
|||
"Undo_As_HID": 0x0015,
|
||||
"Redo_As_Ctrl_Y": 0x0016,
|
||||
"Redo_As_HID": 0x0017,
|
||||
"Print_As_Ctrl_P": 0x0018, # Logitech, modified
|
||||
"Print_As_Ctrl_P": 0x0018,
|
||||
"Print_As_HID": 0x0019,
|
||||
"Save_As_Ctrl_S": 0x001A,
|
||||
"Save_As_HID": 0x001B,
|
||||
|
|
@ -113,13 +110,13 @@ CONTROL = NamedInts(
|
|||
"Pause_Break": 0x004D,
|
||||
"Scroll_Lock": 0x004E,
|
||||
"Contextual_Menu": 0x004F,
|
||||
"Left_Button": 0x0050, # LEFT_CLICK; Logitech
|
||||
"Right_Button": 0x0051, # RIGHT_CLICK; Logitech
|
||||
"Middle_Button": 0x0052, # MIDDLE_BUTTON; Logitech
|
||||
"Back_Button": 0x0053, # from M510v2 was BACK_AS_BUTTON_4; Logitech
|
||||
"Left_Button": 0x0050, # LEFT_CLICK
|
||||
"Right_Button": 0x0051, # RIGHT_CLICK
|
||||
"Middle_Button": 0x0052, # MIDDLE_BUTTON
|
||||
"Back_Button": 0x0053, # from M510v2 was BACK_AS_BUTTON_4
|
||||
"Back": 0x0054, # BACK_AS_HID
|
||||
"Back_As_Alt_Win_Arrow": 0x0055,
|
||||
"Forward_Button": 0x0056, # from M510v2 was FORWARD_AS_BUTTON_5; Logitech
|
||||
"Forward_Button": 0x0056, # from M510v2 was FORWARD_AS_BUTTON_5
|
||||
"Forward_As_HID": 0x0057,
|
||||
"Forward_As_Alt_Win_Arrow": 0x0058,
|
||||
"Button_6": 0x0059,
|
||||
|
|
@ -143,8 +140,8 @@ CONTROL = NamedInts(
|
|||
"Button_22": 0x006B,
|
||||
"Button_23": 0x006C,
|
||||
"Button_24": 0x006D,
|
||||
"Show_Desktop": 0x006E, # Craft Keyboard Fn F5; Logitch
|
||||
"Screen_Lock": 0x006F, # Craft Keyboard top 1st from right; Logitech
|
||||
"Show_Desktop": 0x006E, # Craft Keyboard Fn F5
|
||||
"Lock_PC": 0x006F, # Craft Keyboard top 1st from right
|
||||
"Fn_F1": 0x0070,
|
||||
"Fn_F2": 0x0071,
|
||||
"Fn_F3": 0x0072,
|
||||
|
|
@ -192,7 +189,7 @@ CONTROL = NamedInts(
|
|||
"Metro_Search": 0x00A3,
|
||||
"Combo_Sleep": 0x00A4,
|
||||
"Metro_Share": 0x00A5,
|
||||
"OS_Settings": 0x00A6, # Logitech
|
||||
"Metro_Settings": 0x00A6,
|
||||
"Metro_Devices": 0x00A7,
|
||||
"Metro_Start_Screen": 0x00A9,
|
||||
"Zoomin": 0x00AA,
|
||||
|
|
@ -215,23 +212,23 @@ CONTROL = NamedInts(
|
|||
"Fn_Down": 0x00C0,
|
||||
"Fn_Up": 0x00C1,
|
||||
"Multiplatform_Lock": 0x00C2,
|
||||
"Mouse_Gesture_Button": 0x00C3, # Thumb_Button on MX Master - Logitech name App_Switch_Gesture; Logitech
|
||||
"Smart_Shift": 0x00C4, # Top_Button on MX Master; Logitech
|
||||
"Mouse_Gesture_Button": 0x00C3, # Thumb_Button on MX Master - Logitech name App_Switch_Gesture
|
||||
"Smart_Shift": 0x00C4, # Top_Button on MX Master
|
||||
"Microphone": 0x00C5,
|
||||
"Wifi": 0x00C6,
|
||||
"Brightness_Down": 0x00C7, # Craft Keyboard Fn F1, Logitech
|
||||
"Brightness_Up": 0x00C8, # Craft Keyboard Fn F2, Logitech
|
||||
"Brightness_Down": 0x00C7, # Craft Keyboard Fn F1
|
||||
"Brightness_Up": 0x00C8, # Craft Keyboard Fn F2
|
||||
"Display_Out__Project_Screen_": 0x00C9,
|
||||
"View_Open_Apps": 0x00CA,
|
||||
"View_All_Apps": 0x00CB,
|
||||
"Switch_App": 0x00CC,
|
||||
"Fn_Inversion_Change": 0x00CD,
|
||||
"MultiPlatform_Back": 0x00CE, # Logitech
|
||||
"MultiPlatform_Back": 0x00CE,
|
||||
"MultiPlatform_Forward": 0x00CF,
|
||||
"MultiPlatform_Gesture_Button": 0x00D0,
|
||||
"Host_Switch_Channel_1": 0x00D1, # Craft Keyboard; Logitech
|
||||
"Host_Switch_Channel_2": 0x00D2, # Craft Keyboard; Logitech
|
||||
"Host_Switch_Channel_3": 0x00D3, # Craft Keyboard; Logitech
|
||||
"Host_Switch_Channel_1": 0x00D1, # Craft Keyboard
|
||||
"Host_Switch_Channel_2": 0x00D2, # Craft Keyboard
|
||||
"Host_Switch_Channel_3": 0x00D3, # Craft Keyboard
|
||||
"MultiPlatform_Search": 0x00D4,
|
||||
"MultiPlatform_Home__Mission_Control": 0x00D5,
|
||||
"MultiPlatform_Menu__Show__Hide_Virtual_Keyboard__Launchpad": 0x00D6,
|
||||
|
|
@ -244,21 +241,21 @@ CONTROL = NamedInts(
|
|||
"Multi_Platform_Language_Switch": 0x00DD,
|
||||
"F_Lock": 0x00DE,
|
||||
"Switch_Highlight": 0x00DF,
|
||||
"Mission_Control__Task_View": 0x00E0, # Craft Keyboard Fn F3 Switch_Workspace; Logitech
|
||||
"Mission_Control__Task_View": 0x00E0, # Craft Keyboard Fn F3 Switch_Workspace
|
||||
"Dashboard_Launchpad__Action_Center": 0x00E1, # Craft Keyboard Fn F4 Application_Launcher
|
||||
"Backlight_Down": 0x00E2, # Craft Keyboard Fn F6, Logitech
|
||||
"Backlight_Up": 0x00E3, # Craft Keyboard Fn F7, Logitech
|
||||
"Previous_Track": 0x00E4, # Craft Keyboard Fn F8 Previous_Track; Logitech
|
||||
"Play__Pause": 0x00E5, # Craft Keyboard Fn F9 Play__Pause; Logitech
|
||||
"Next_Track": 0x00E6, # Craft Keyboard Fn F10 Next_Track; Logitech
|
||||
"Mute_Sound": 0x00E7, # Craft Keyboard Fn F11 Mute; Logitech
|
||||
"Volume_Down": 0x00E8, # Craft Keyboard Fn F12 Volume_Down; Logitech
|
||||
"Volume_Up": 0x00E9, # Craft Keyboard next to F12 Volume_Down; Logitech
|
||||
"Backlight_Down": 0x00E2, # Craft Keyboard Fn F6
|
||||
"Backlight_Up": 0x00E3, # Craft Keyboard Fn F7
|
||||
"Previous_Fn": 0x00E4, # Craft Keyboard Fn F8 Previous_Track
|
||||
"Play__Pause_Fn": 0x00E5, # Craft Keyboard Fn F9 Play__Pause
|
||||
"Next_Fn": 0x00E6, # Craft Keyboard Fn F10 Next_Track
|
||||
"Mute_Fn": 0x00E7, # Craft Keyboard Fn F11 Mute
|
||||
"Volume_Down_Fn": 0x00E8, # Craft Keyboard Fn F12 Volume_Down
|
||||
"Volume_Up_Fn": 0x00E9, # Craft Keyboard next to F12 Volume_Down
|
||||
"App_Contextual_Menu__Right_Click": 0x00EA, # Craft Keyboard top 2nd from right
|
||||
"Right_Arrow": 0x00EB,
|
||||
"Left_Arrow": 0x00EC,
|
||||
"DPI_Change": 0x00ED,
|
||||
"Open_New_Tab": 0x00EE, # Logitech
|
||||
"New_Tab": 0x00EE,
|
||||
"F2": 0x00EF,
|
||||
"F3": 0x00F0,
|
||||
"F4": 0x00F1,
|
||||
|
|
@ -274,20 +271,20 @@ CONTROL = NamedInts(
|
|||
"Laser_Button_Short_Press": 0x00FB,
|
||||
"Laser_Button_Long_Press": 0x00FC,
|
||||
"DPI_Switch": 0x00FD,
|
||||
"Multiplatform_Home__Show_Desktop": 0x00FE, # Logitech
|
||||
"Multiplatform_Home__Show_Desktop": 0x00FE,
|
||||
"Multiplatform_App_Switch__Show_Dashboard": 0x00FF,
|
||||
"Multiplatform_App_Switch_2": 0x0100, # Multiplatform_App_Switch
|
||||
"Fn_Inversion__Hot_Key": 0x0101,
|
||||
"LeftAndRightClick": 0x0102,
|
||||
"Dictation": 0x0103, # MX Keys for Business Fn F5 ; MX Mini Fn F6 Dictation; Logitech
|
||||
"Emoji_Smiley_Heart_Eyes": 0x0104, # Logitech
|
||||
"Emoji_Crying_Face": 0x0105, # Logitech
|
||||
"Emoji_Smiley": 0x0106, # Logitech
|
||||
"Emoji_Smilie_With_Tears": 0x0107, # Logitech
|
||||
"Emoji": 0x0108, # MX Keys for Business Fn F6 ; MX Mini Fn F7 Emoji, Logitech
|
||||
"Multiplatform_App_Switch__Launchpad": 0x0109, # Logitech
|
||||
"Screen_Capture": 0x010A, # MX Keys for Business top 3rd from right; MX Mini Fn F8 Screenshot; Logitech
|
||||
"Grave_Accent": 0x010B, # Logitech
|
||||
"Voice_Dictation": 0x0103, # MX Keys for Business Fn F5 ; MX Mini Fn F6 Dictation
|
||||
"Emoji_Smiley_Heart_Eyes": 0x0104,
|
||||
"Emoji_Crying_Face": 0x0105,
|
||||
"Emoji_Smiley": 0x0106,
|
||||
"Emoji_Smilie_With_Tears": 0x0107,
|
||||
"Open_Emoji_Panel": 0x0108, # MX Keys for Business Fn F6 ; MX Mini Fn F7 Emoji
|
||||
"Multiplatform_App_Switch__Launchpad": 0x0109,
|
||||
"Snipping_Tool": 0x010A, # MX Keys for Business top 3rd from right; MX Mini Fn F8 Screenshot
|
||||
"Grave_Accent": 0x010B,
|
||||
"Tab_Key": 0x010C,
|
||||
"Caps_Lock": 0x010D,
|
||||
"Left_Shift": 0x010E,
|
||||
|
|
@ -300,297 +297,306 @@ CONTROL = NamedInts(
|
|||
"Right_Shift": 0x0115,
|
||||
"Insert": 0x0116,
|
||||
"Delete": 0x0117, # MX Mini Lock (on delete key in function row)
|
||||
"Home": 0x118, # Logitech
|
||||
"End": 0x119, # Logitech
|
||||
"Home": 0x118,
|
||||
"End": 0x119,
|
||||
"Page_Up": 0x11A,
|
||||
"Page_Down": 0x11B,
|
||||
"Mute_Microphone": 0x11C, # MX Keys for Business Fn F7 ; MX Mini Fn F9 Microphone Mute; Logitech
|
||||
"Do_Not_Disturb": 0x11D, # Logitech
|
||||
"Mute_Microphone": 0x11C, # MX Keys for Business Fn F7 ; MX Mini Fn F9 Microphone Mute
|
||||
"Do_Not_Disturb": 0x11D,
|
||||
"Backslash": 0x11E,
|
||||
"Refresh": 0x11F, # Logitech
|
||||
"Refresh": 0x11F,
|
||||
"Close_Tab": 0x120,
|
||||
"Lang_Switch": 0x121, # Logitech
|
||||
"Lang_Switch": 0x121,
|
||||
"Standard_Key_A": 0x122,
|
||||
"Standard_Key_B": 0x123,
|
||||
"Standard_Key_C": 0x124, # There are lots more of these
|
||||
"Right_Option__Start__2": 0x013C, # On MX Mechanical Mini
|
||||
"Play__Pause_mini": 0x0141, # On MX Mechanical Mini
|
||||
"Haptic": 0x01A0, # Logitech
|
||||
"Circle": 0x01A3,
|
||||
"Triangle": 0x01A4,
|
||||
"Diamond": 0x01A5,
|
||||
"Star": 0x01A6,
|
||||
"Cut": 0x1A9, # Logitech
|
||||
"Copy": 0x1AA, # Logitech
|
||||
"Paste": 0x1AB, # Logitech
|
||||
"Video_On_Off": 0x01AC, # Logitech
|
||||
"AI": 0x1B4, # Logitech
|
||||
"Play_Pause": 0x0141, # On MX Mechanical Mini
|
||||
}
|
||||
)
|
||||
|
||||
for i in range(1, 33): # add in G keys - these are not really Logitech Controls
|
||||
CONTROL[0x1000 + i] = f"G{str(i)}"
|
||||
CONTROL[0x1000 + i] = "G" + str(i)
|
||||
for i in range(1, 9): # add in M keys - these are not really Logitech Controls
|
||||
CONTROL[0x1100 + i] = f"M{str(i)}"
|
||||
CONTROL[0x1100 + i] = "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}"
|
||||
|
||||
|
||||
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
|
||||
# <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,
|
||||
# Multimedia tasks:
|
||||
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
|
||||
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
|
||||
# Both 0x001E and 0x001F are known as MediaCenterSet
|
||||
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
|
||||
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,
|
||||
# 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
|
||||
MULTI_PLATFORM_APP_SWITCH = 0x0096
|
||||
MULTI_PLATFORM_HOME = 0x0097
|
||||
MULTI_PLATFORM_MENU = 0x0098
|
||||
MULTI_PLATFORM_BACK = 0x0099
|
||||
SWITCH_LANGUAGE = 0x009A # Mac_switch_language
|
||||
SCREEN_CAPTURE = 0x009B # Mac_screen_Capture, on Craft Keyboard
|
||||
GESTURE_BUTTON = 0x009C
|
||||
SMART_SHIFT = 0x009D
|
||||
APP_EXPOSE = 0x009E
|
||||
SMART_ZOOM = 0x009F
|
||||
LOOKUP = 0x00A0
|
||||
MICROPHEON_ON_OFF = 0x00A1
|
||||
WIFI_ON_OFF = 0x00A2
|
||||
BRIGHTNESS_DOWN = 0x00A3
|
||||
BRIGHTNESS_UP = 0x00A4
|
||||
DISPLAY_OUT = 0x00A5
|
||||
VIEW_OPEN_APPS = 0x00A6
|
||||
VIEW_ALL_OPEN_APPS = 0x00A7
|
||||
APP_SWITCH = 0x00A8
|
||||
GESTURE_BUTTON_NAVIGATION = 0x00A9 # Mouse_Thumb_Button on MX Master
|
||||
FN_INVERSION = 0x00AA
|
||||
MULTI_PLATFORM_BACK_2 = 0x00AB # Alternative
|
||||
MULTI_PLATFORM_FORWARD = 0x00AC
|
||||
MULTI_PLATFORM_Gesture_Button = 0x00AD
|
||||
HostSwitch_Channel_1 = 0x00AE
|
||||
HostSwitch_Channel_2 = 0x00AF
|
||||
HostSwitch_Channel_3 = 0x00B0
|
||||
MULTI_PLATFORM_SEARCH = 0x00B1
|
||||
MULTI_PLATFORM_HOME_MISSION_CONTROL = 0x00B2
|
||||
MULTI_PLATFORM_MENU_LAUNCHPAD = 0x00B3
|
||||
VIRTUAL_GESTURE_BUTTON = 0x00B4
|
||||
CURSOR = 0x00B5
|
||||
KEYBOARD_RIGHT_ARROW = 0x00B6
|
||||
SW_CUSTOM_HIGHLIGHT = 0x00B7
|
||||
KEYBOARD_LEFT_ARROW = 0x00B8
|
||||
TBD = 0x00B9
|
||||
MULTI_PLATFORM_Language_Switch = 0x00BA
|
||||
SW_CUSTOM_HIGHLIGHT_2 = 0x00BB
|
||||
FAST_FORWARD = 0x00BC
|
||||
FAST_BACKWARD = 0x00BD
|
||||
SWITCH_HIGHLIGHTING = 0x00BE
|
||||
MISSION_CONTROL_TASK_VIEW = 0x00BF # Switch_Workspace on Craft Keyboard
|
||||
DASHBOARD_LAUNCHPAD_ACTION_CENTER = 0x00C0 # Application_Launcher on Craft
|
||||
# Keyboard
|
||||
BACKLIGHT_DOWN = 0x00C1 # Backlight_Down_FW_internal_function
|
||||
BACKLIGHT_UP = 0x00C2 # Backlight_Up_FW_internal_function
|
||||
RIGHT_CLICK_APP_CONTEXT_MENU = 0x00C3 # Context_Menu on Craft Keyboard
|
||||
DPI_Change = 0x00C4
|
||||
NEW_TAB = 0x00C5
|
||||
F2 = 0x00C6
|
||||
F3 = 0x00C7
|
||||
F4 = 0x00C8
|
||||
F5 = 0x00C9
|
||||
F6 = 0x00CA
|
||||
F7 = 0x00CB
|
||||
F8 = 0x00CC
|
||||
F1 = 0x00CD
|
||||
LASER_BUTTON = 0x00CE
|
||||
LASER_BUTTON_LONG_PRESS = 0x00CF
|
||||
START_PRESENTATION = 0x00D0
|
||||
BLANK_SCREEN = 0x00D1
|
||||
DPI_Switch = 0x00D2 # AdjustDPI on MX Vertical
|
||||
HOME_SHOW_DESKTOP = 0x00D3
|
||||
APP_SWITCH_DASHBOARD = 0x00D4
|
||||
APP_SWITCH_2 = 0x00D5 # Alternative
|
||||
FN_INVERSION_2 = 0x00D6 # Alternative
|
||||
LEFT_AND_RIGHT_CLICK = 0x00D7
|
||||
VOICE_DICTATION = 0x00D8
|
||||
EMOJI_SMILING_FACE_WITH_HEART_SHAPED_EYES = 0x00D9
|
||||
EMOJI_LOUDLY_CRYING_FACE = 0x00DA
|
||||
EMOJI_SMILEY = 0x00DB
|
||||
EMOJI_SMILE_WITH_TEARS = 0x00DC
|
||||
OPEN_EMOJI_PANEL = 0x00DD
|
||||
MULTI_PLATFORM_APP_SWITCH_LAUNCHPAD = 0x00DE
|
||||
SNIPPING_TOOL = 0x00DF
|
||||
GRAVE_ACCENT = 0x00E0
|
||||
STANDARD_TAB_KEY = 0x00E1
|
||||
CAPS_LOCK = 0x00E2
|
||||
LEFT_SHIFT = 0x00E3
|
||||
LEFT_CONTROL = 0x00E4
|
||||
LEFT_OPTION_START = 0x00E5
|
||||
LEFT_COMMAND_ALT = 0x00E6
|
||||
RIGHT_COMMAND_ALT = 0x00E7
|
||||
RIGHT_OPTION_START = 0x00E8
|
||||
RIGHT_CONTROL = 0x00E9
|
||||
RIGHT_SHIFT = 0x0EA
|
||||
INSERT = 0x00EB
|
||||
DELETE = 0x00EC
|
||||
HOME = 0x00ED
|
||||
END = 0x00EE
|
||||
PAGE_UP_2 = 0x00EF # Alternative
|
||||
PAGE_DOWN_2 = 0x00F0 # Alternative
|
||||
MUTE_MICROPHONE = 0x00F1
|
||||
DO_NOT_DISTURB = 0x00F2
|
||||
BACKSLASH = 0x00F3
|
||||
REFRESH = 0x00F4
|
||||
CLOSE_TAB = 0x00F5
|
||||
LANG_SWITCH = 0x00F6
|
||||
STANDARD_ALPHABETICAL_KEY = 0x00F7
|
||||
RRIGH_OPTION_START_2 = 0x00F8
|
||||
LEFT_OPTION = 0x00F9
|
||||
RIGHT_OPTION = 0x00FA
|
||||
LEFT_CMD = 0x00FB
|
||||
RIGHT_CMD = 0x00FC
|
||||
|
||||
def __str__(self):
|
||||
return self.name.replace("_", " ").title()
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class CIDGroupBit(IntEnum):
|
||||
|
|
@ -1220,11 +1226,11 @@ MOUSE_BUTTONS = NamedInts(
|
|||
)
|
||||
MOUSE_BUTTONS._fallback = lambda x: f"unknown mouse button:{x:04X}"
|
||||
|
||||
|
||||
class HorizontalScroll(IntEnum):
|
||||
Left = 0x4000
|
||||
Right = 0x8000
|
||||
|
||||
HORIZONTAL_SCROLL = NamedInts(
|
||||
Horizontal_Scroll_Left=0x4000,
|
||||
Horizontal_Scroll_Right=0x8000,
|
||||
)
|
||||
HORIZONTAL_SCROLL._fallback = lambda x: f"unknown horizontal scroll:{x:04X}"
|
||||
|
||||
# Construct universe for Persistent Remappable Keys setting (only for supported values)
|
||||
KEYS = UnsortedNamedInts()
|
||||
|
|
@ -1259,7 +1265,7 @@ for code in MOUSE_BUTTONS:
|
|||
KEYS[(ACTIONID.Mouse << 24) + (int(code) << 8)] = str(code)
|
||||
|
||||
# Add Horizontal Scroll
|
||||
for code in HorizontalScroll:
|
||||
for code in HORIZONTAL_SCROLL:
|
||||
KEYS[(ACTIONID.Hscroll << 24) + (int(code) << 8)] = str(code)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,10 @@ def _create_parser():
|
|||
)
|
||||
sp.add_argument(
|
||||
"device",
|
||||
help="device to configure; may be a device number (1..6), a serial number, " "or a substring of a device's name",
|
||||
help=(
|
||||
"device to configure; may be a device number (1..6), a serial number, ",
|
||||
"or a substring of a device's name",
|
||||
),
|
||||
)
|
||||
sp.add_argument("setting", nargs="?", help="device-specific setting; leave empty to list available settings")
|
||||
sp.add_argument("value_key", nargs="?", help="new value for the setting or key for keyed settings")
|
||||
|
|
@ -113,7 +116,8 @@ def _receivers(dev_path=None):
|
|||
continue
|
||||
try:
|
||||
r = receiver.create_receiver(base, dev_info)
|
||||
logger.debug("[%s] => %s", dev_info.path, r)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("[%s] => %s", dev_info.path, r)
|
||||
if r:
|
||||
yield r
|
||||
except Exception as e:
|
||||
|
|
@ -131,7 +135,8 @@ def _receivers_and_devices(dev_path=None):
|
|||
else:
|
||||
d = receiver.create_receiver(base, dev_info)
|
||||
|
||||
logger.debug("[%s] => %s", dev_info.path, d)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("[%s] => %s", dev_info.path, d)
|
||||
if d is not None:
|
||||
yield d
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import yaml
|
|||
from logitech_receiver import settings
|
||||
from logitech_receiver import settings_templates
|
||||
from logitech_receiver.common import NamedInts
|
||||
from logitech_receiver.settings_templates import SettingsProtocol
|
||||
|
||||
from solaar import configuration
|
||||
|
||||
|
|
@ -31,9 +30,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),
|
||||
|
|
@ -54,7 +53,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)")
|
||||
|
|
@ -65,7 +64,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)")
|
||||
|
|
@ -216,26 +215,26 @@ def run(receivers, args, _find_receiver, find_device):
|
|||
dev.persister[setting.name] = setting._value
|
||||
|
||||
|
||||
def set(dev, setting: SettingsProtocol, args, save):
|
||||
if setting.kind == settings.Kind.TOGGLE:
|
||||
def set(dev, setting, 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
|
||||
|
|
@ -253,7 +252,7 @@ def set(dev, setting: SettingsProtocol, 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
|
||||
|
|
@ -272,7 +271,7 @@ def set(dev, setting: SettingsProtocol, 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
|
||||
|
|
|
|||
|
|
@ -39,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.NotificationFlag.WIRELESS):
|
||||
_hidpp10.set_notification_flags(receiver, old_notification_flags | hidpp10_constants.NotificationFlag.WIRELESS)
|
||||
if not (old_notification_flags & hidpp10_constants.NOTIFICATION_FLAG.wireless):
|
||||
_hidpp10.set_notification_flags(receiver, old_notification_flags | hidpp10_constants.NOTIFICATION_FLAG.wireless)
|
||||
|
||||
# get all current devices
|
||||
known_devices = [dev.number for dev in receiver]
|
||||
|
|
@ -121,7 +121,7 @@ def run(receivers, args, find_receiver, _ignore):
|
|||
if n:
|
||||
receiver.handle.notifications_hook(n)
|
||||
|
||||
if not (old_notification_flags & hidpp10_constants.NotificationFlag.WIRELESS):
|
||||
if not (old_notification_flags & hidpp10_constants.NOTIFICATION_FLAG.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)
|
||||
|
|
|
|||
|
|
@ -45,29 +45,29 @@ def run(receivers, args, find_receiver, _ignore):
|
|||
print("")
|
||||
print(" Register Dump")
|
||||
rgst = receiver.read_register(Registers.NOTIFICATIONS)
|
||||
print(" Notifications %#04x: %s" % (Registers.NOTIFICATIONS % 0x100, f"0x{strhex(rgst)}" if rgst else "None"))
|
||||
print(" Notifications %#04x: %s" % (Registers.NOTIFICATIONS % 0x100, "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")
|
||||
% (Registers.RECEIVER_CONNECTION % 0x100, "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")
|
||||
" Device Activity %#04x: %s" % (Registers.DEVICES_ACTIVITY % 0x100, "0x" + strhex(rgst) if rgst else "None")
|
||||
)
|
||||
|
||||
for sub_reg in range(0, 16):
|
||||
rgst = receiver.read_register(Registers.RECEIVER_INFO, sub_reg)
|
||||
print(
|
||||
" Pairing Register %#04x %#04x: %s"
|
||||
% (Registers.RECEIVER_INFO % 0x100, sub_reg, f"0x{strhex(rgst)}" if rgst else "None")
|
||||
% (Registers.RECEIVER_INFO % 0x100, sub_reg, "0x" + strhex(rgst) if rgst else "None")
|
||||
)
|
||||
for device in range(0, 7):
|
||||
for sub_reg in [0x10, 0x20, 0x30, 0x50]:
|
||||
rgst = receiver.read_register(Registers.RECEIVER_INFO, sub_reg + device)
|
||||
print(
|
||||
" Pairing Register %#04x %#04x: %s"
|
||||
% (Registers.RECEIVER_INFO % 0x100, sub_reg + device, f"0x{strhex(rgst)}" if rgst else "None")
|
||||
% (Registers.RECEIVER_INFO % 0x100, sub_reg + device, "0x" + strhex(rgst) if rgst else "None")
|
||||
)
|
||||
rgst = receiver.read_register(Registers.RECEIVER_INFO, 0x40 + device)
|
||||
print(
|
||||
|
|
@ -90,7 +90,7 @@ def run(receivers, args, find_receiver, _ignore):
|
|||
rgst = receiver.read_register(Registers.FIRMWARE, sub_reg)
|
||||
print(
|
||||
" Firmware %#04x %#04x: %s"
|
||||
% (Registers.FIRMWARE % 0x100, sub_reg, f"0x{strhex(rgst)}" if rgst is not None else "None")
|
||||
% (Registers.FIRMWARE % 0x100, sub_reg, "0x" + strhex(rgst) if rgst is not None else "None")
|
||||
)
|
||||
|
||||
print("")
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ def _print_receiver(receiver):
|
|||
notification_flags = _hidpp10.get_notification_flags(receiver)
|
||||
if notification_flags is not None:
|
||||
if notification_flags:
|
||||
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
|
||||
notification_names = hidpp10_constants.NOTIFICATION_FLAG.flag_names(notification_flags)
|
||||
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags:06X})")
|
||||
else:
|
||||
print(" Notifications: (none)")
|
||||
|
|
@ -82,8 +82,8 @@ def _battery_line(dev):
|
|||
level, nextLevel, status, voltage = battery.level, battery.next_level, battery.status, battery.voltage
|
||||
text = _battery_text(level)
|
||||
if voltage is not None:
|
||||
text = f"{text} {voltage}mV "
|
||||
nextText = "" if nextLevel is None else f", next level {_battery_text(nextLevel)}"
|
||||
text = text + f" {voltage}mV "
|
||||
nextText = "" if nextLevel is None else ", next level " + _battery_text(nextLevel)
|
||||
print(f" Battery: {text}, {status}{nextText}.")
|
||||
else:
|
||||
print(" Battery status unavailable.")
|
||||
|
|
@ -131,14 +131,14 @@ def _print_device(dev, num=None):
|
|||
notification_flags = _hidpp10.get_notification_flags(dev)
|
||||
if notification_flags is not None:
|
||||
if notification_flags:
|
||||
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
|
||||
notification_names = hidpp10_constants.NOTIFICATION_FLAG.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.DeviceFeature.flag_names(device_features)
|
||||
device_features_names = hidpp10_constants.DEVICE_FEATURES.flag_names(device_features)
|
||||
print(f" Features: {', '.join(device_features_names)} (0x{device_features:06X})")
|
||||
else:
|
||||
print(" Features: (none)")
|
||||
|
|
@ -151,8 +151,8 @@ def _print_device(dev, num=None):
|
|||
if isinstance(feature, str):
|
||||
feature_bytes = bytes.fromhex(feature[-4:])
|
||||
else:
|
||||
feature_bytes = feature.to_bytes(2, byteorder="little")
|
||||
feature_int = int.from_bytes(feature_bytes, byteorder="little")
|
||||
feature_bytes = feature.to_bytes(2)
|
||||
feature_int = int.from_bytes(feature_bytes)
|
||||
flags = dev.request(0x0000, feature_bytes)
|
||||
flags = 0 if flags is None else ord(flags[1:2])
|
||||
flags = common.flag_names(hidpp20_constants.FeatureFlag, flags)
|
||||
|
|
@ -277,11 +277,8 @@ def _print_device(dev, num=None):
|
|||
print(" %2d: %-26s, default: %-27s => %-26s" % (k.index, k.key, k.default_task, k.mapped_to))
|
||||
gmask_fmt = ",".join(k.group_mask)
|
||||
gmask_fmt = gmask_fmt if gmask_fmt else "empty"
|
||||
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))
|
||||
print(f" {', '.join(k.flags)}, pos:{int(k.pos)}, group:{int(k.group):1}, group mask:{gmask_fmt}")
|
||||
report_fmt = ", ".join(k.mapping_flags)
|
||||
report_fmt = report_fmt if report_fmt else "default"
|
||||
print(f" reporting: {report_fmt}")
|
||||
if dev.online and dev.remap_keys:
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ def run(receivers, args, find_receiver, find_device):
|
|||
|
||||
if not dev.receiver.may_unpair:
|
||||
print(
|
||||
f"Receiver with USB id {dev.receiver.product_id} for {dev.name} [{dev.wpid}:{dev.serial}] does not unpair,",
|
||||
"but attempting anyway.",
|
||||
"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)
|
||||
)
|
||||
try:
|
||||
# query these now, it's last chance to get them
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ def _load():
|
|||
loaded_config = _convert_json(loaded_config)
|
||||
else:
|
||||
path = None
|
||||
logger.debug("load => %s", loaded_config)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("load => %s", loaded_config)
|
||||
global _config
|
||||
_config = _parse_config(loaded_config, path)
|
||||
|
||||
|
|
@ -77,13 +78,14 @@ def _parse_config(loaded_config, config_path):
|
|||
loaded_version = loaded_config[0]
|
||||
discard_derived_properties = loaded_version != current_version
|
||||
if discard_derived_properties:
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
for device in loaded_config[1:]:
|
||||
assert isinstance(device, dict)
|
||||
|
|
@ -152,7 +154,8 @@ def do_save():
|
|||
try:
|
||||
with open(_yaml_file_path, "w") as config_file:
|
||||
yaml.dump(_config, config_file, default_flow_style=None, width=150)
|
||||
logger.info("saved %s to %s", _config, _yaml_file_path)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
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)
|
||||
|
||||
|
|
@ -248,9 +251,11 @@ def persister(device):
|
|||
break
|
||||
if not entry:
|
||||
if not device.online: # don't create entry for offline devices
|
||||
logger.info("not setting up persister for offline device %s", device._name)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("not setting up persister for offline device %s", device._name)
|
||||
return
|
||||
logger.info("setting up persister for device %s", device.name)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
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)
|
||||
|
|
@ -68,7 +68,8 @@ def watch_suspend_resume(
|
|||
dbus_interface=_LOGIND_INTERFACE,
|
||||
path=_LOGIND_PATH,
|
||||
)
|
||||
logger.info("connected to system dbus, watching for suspend/resume events")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("connected to system dbus, watching for suspend/resume events")
|
||||
|
||||
|
||||
_BLUETOOTH_PATH_PREFIX = "/org/bluez/hci0/dev_"
|
||||
|
|
|
|||
|
|
@ -36,9 +36,7 @@ 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__)
|
||||
|
||||
|
||||
|
|
@ -56,7 +54,7 @@ tray_icon_size = None
|
|||
temp = tempfile.NamedTemporaryFile(prefix="Solaar_", mode="w", delete=True)
|
||||
|
||||
|
||||
def create_parser():
|
||||
def _parse_arguments():
|
||||
arg_parser = argparse.ArgumentParser(
|
||||
prog=NAME.lower(), epilog="For more information see https://pwr-solaar.github.io/Solaar"
|
||||
)
|
||||
|
|
@ -101,11 +99,7 @@ def create_parser():
|
|||
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:
|
||||
|
|
@ -134,8 +128,9 @@ def _parse_arguments():
|
|||
logging.getLogger("").addHandler(stream_handler)
|
||||
|
||||
if not args.action:
|
||||
language, encoding = locale.getlocale()
|
||||
logger.info("version %s, language %s (%s)", __version__, language, encoding)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
language, encoding = locale.getlocale()
|
||||
logger.info("version %s, language %s (%s)", __version__, language, encoding)
|
||||
|
||||
return args
|
||||
|
||||
|
|
@ -159,15 +154,10 @@ 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
|
||||
result = cli.run(args.action, args.hidraw_path)
|
||||
# explicit close before return
|
||||
temp.close()
|
||||
return result
|
||||
return cli.run(args.action, args.hidraw_path)
|
||||
|
||||
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")
|
||||
|
|
@ -179,8 +169,7 @@ def main():
|
|||
|
||||
udev_file = "42-logitech-unify-permissions.rules"
|
||||
if (
|
||||
platform.system() == "Linux"
|
||||
and logger.isEnabledFor(logging.WARNING)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
|
||||
import gettext
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
|
@ -26,37 +25,30 @@ from solaar import NAME
|
|||
|
||||
_LOCALE_DOMAIN = NAME.lower()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _find_locale_path(locale_domain: str) -> str:
|
||||
prefix_share = os.path.normpath(os.path.join(os.path.realpath(sys.path[0]), ".."))
|
||||
src_share = os.path.normpath(os.path.join(os.path.realpath(sys.path[0]), "..", "share"))
|
||||
|
||||
for location in prefix_share, src_share:
|
||||
mo_files = glob(os.path.join(location, "locale", "*", "LC_MESSAGES", f"{locale_domain}.mo"))
|
||||
mo_files = glob(os.path.join(location, "locale", "*", "LC_MESSAGES", locale_domain + ".mo"))
|
||||
if mo_files:
|
||||
return os.path.join(location, "locale")
|
||||
raise FileNotFoundError(f"Could not find locale path for {locale_domain}")
|
||||
|
||||
|
||||
def set_locale_to_system_default() -> None:
|
||||
def set_locale_to_system_default():
|
||||
"""Sets locale for translations to the system default.
|
||||
|
||||
If locale is unsupported, fallback to standard English without
|
||||
translation 'C'.
|
||||
|
||||
Set LC_ALL environment variable to enforce a locale setting e.g.
|
||||
'de_DE.UTF-8'. Run Solaar with your desired localization, for German
|
||||
use:
|
||||
'LC_ALL=de_DE.UTF-8 solaar'
|
||||
"""
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, "") # 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
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
try:
|
||||
path = _find_locale_path(_LOCALE_DOMAIN)
|
||||
|
|
|
|||
|
|
@ -15,17 +15,13 @@
|
|||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
import typing
|
||||
|
||||
from collections import namedtuple
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
|
||||
import gi
|
||||
import logitech_receiver
|
||||
|
|
@ -39,29 +35,26 @@ from logitech_receiver import notifications
|
|||
from . import configuration
|
||||
from . import dbus
|
||||
from . import i18n
|
||||
from .ui import common
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from hidapi.common import DeviceInfo
|
||||
|
||||
gi.require_version("Gtk", "3.0") # NOQA: E402
|
||||
from gi.repository import GLib # NOQA: E402 # isort:skip
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from logitech_receiver.device import Device
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTION_ADD = "add"
|
||||
|
||||
_GHOST_DEVICE = namedtuple("_GHOST_DEVICE", ("receiver", "number", "name", "kind", "online", "path"))
|
||||
_GHOST_DEVICE = namedtuple("_GHOST_DEVICE", ("receiver", "number", "name", "kind", "online"))
|
||||
_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, path=None
|
||||
receiver=device.receiver,
|
||||
number=device.number,
|
||||
name=device.name,
|
||||
kind=device.kind,
|
||||
online=False,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -75,13 +68,15 @@ class SolaarListener(listener.EventsListener):
|
|||
receiver.status_callback = self._status_changed
|
||||
|
||||
def has_started(self):
|
||||
logger.info("%s: notifications listener has started (%s)", self.receiver, self.receiver.handle)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: notifications listener has started (%s)", self.receiver, self.receiver.handle)
|
||||
nfs = self.receiver.enable_connection_notifications()
|
||||
if not self.receiver.isDevice and not ((nfs if nfs else 0) & hidpp10_constants.NotificationFlag.WIRELESS.value):
|
||||
logger.warning(
|
||||
"Receiver on %s might not support connection notifications, GUI might not show its devices",
|
||||
self.receiver.path,
|
||||
)
|
||||
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,
|
||||
)
|
||||
self.receiver.notification_flags = nfs
|
||||
self.receiver.notify_devices()
|
||||
self._status_changed(self.receiver)
|
||||
|
|
@ -89,7 +84,8 @@ class SolaarListener(listener.EventsListener):
|
|||
def has_stopped(self):
|
||||
r, self.receiver = self.receiver, None
|
||||
assert r is not None
|
||||
logger.info("%s: notifications listener has stopped", r)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
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)
|
||||
|
|
@ -137,7 +133,8 @@ 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.
|
||||
logger.info("device %s was unpaired, ghosting", device)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("device %s was unpaired, ghosting", device)
|
||||
device = _ghost(device)
|
||||
|
||||
self.status_changed_callback(device, alert, reason)
|
||||
|
|
@ -155,17 +152,20 @@ class SolaarListener(listener.EventsListener):
|
|||
|
||||
# a notification that came in to the device listener - strange, but nothing needs to be done here
|
||||
if self.receiver.isDevice:
|
||||
logger.debug("Notification %s via device %s being ignored.", n, self.receiver)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
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:
|
||||
logger.info("ignoring DJ pairing notification %s", n)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
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
|
||||
logger.warning("Unexpected device number (%s) in notification %s.", n.devnumber, n)
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("Unexpected device number (%s) in notification %s.", n.devnumber, n)
|
||||
return
|
||||
already_known = n.devnumber in self.receiver
|
||||
|
||||
|
|
@ -187,7 +187,7 @@ class SolaarListener(listener.EventsListener):
|
|||
if (
|
||||
self.receiver.read_register(
|
||||
hidpp10_constants.Registers.RECEIVER_INFO,
|
||||
hidpp10_constants.InfoSubRegisters.PAIRING_INFORMATION + n.devnumber - 1,
|
||||
hidpp10_constants.INFO_SUBREGISTERS.pairing_information + n.devnumber - 1,
|
||||
)
|
||||
is None
|
||||
):
|
||||
|
|
@ -210,7 +210,8 @@ class SolaarListener(listener.EventsListener):
|
|||
|
||||
# Apply settings every time the device connects
|
||||
if n.sub_id == 0x41:
|
||||
logger.info("connection %s for device wpid %s kind %s serial %s", n, dev.wpid, dev.kind, dev._serial)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
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)
|
||||
|
|
@ -222,8 +223,10 @@ class SolaarListener(listener.EventsListener):
|
|||
|
||||
if self.receiver.pairing.lock_open and not already_known:
|
||||
# this should be the first notification after a device was paired
|
||||
logger.warning("first notification was not a connection notification")
|
||||
logger.info("%s: pairing detected new device", self.receiver)
|
||||
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)
|
||||
self.receiver.pairing.new_device = dev
|
||||
elif dev.online is None:
|
||||
dev.ping()
|
||||
|
|
@ -232,33 +235,36 @@ class SolaarListener(listener.EventsListener):
|
|||
return f"<SolaarListener({self.receiver.path},{self.receiver.handle})>"
|
||||
|
||||
|
||||
def _process_bluez_dbus(device: Device, path, dictionary: dict, signature):
|
||||
def _process_bluez_dbus(device, path, dictionary, 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")
|
||||
logger.info("bluez dbus for %s: %s", device, "CONNECTED" if connected else "DISCONNECTED")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
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:
|
||||
logger.info("bluez cleanup for %s", device)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("bluez cleanup for %s", device)
|
||||
_cleanup_bluez_dbus(device)
|
||||
|
||||
|
||||
def _cleanup_bluez_dbus(device: Device):
|
||||
def _cleanup_bluez_dbus(device):
|
||||
"""Remove dbus signal receiver for device"""
|
||||
logger.info("bluez cleanup for %s", device)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
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: DeviceInfo):
|
||||
def _start(device_info):
|
||||
assert _status_callback and _setting_callback
|
||||
|
||||
if not device_info.isDevice:
|
||||
isDevice = device_info.isDevice
|
||||
if not isDevice:
|
||||
receiver_ = logitech_receiver.receiver.create_receiver(base, device_info, _setting_callback)
|
||||
else:
|
||||
receiver_ = logitech_receiver.device.create_device(base, device_info, _setting_callback)
|
||||
|
|
@ -279,7 +285,8 @@ def _start(device_info: DeviceInfo):
|
|||
|
||||
def start_all():
|
||||
stop_all() # just in case this it called twice in a row...
|
||||
logger.info("starting receiver listening threads")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("starting receiver listening threads")
|
||||
for device_info in base.receivers_and_devices():
|
||||
_process_receiver_event(ACTION_ADD, device_info)
|
||||
|
||||
|
|
@ -288,7 +295,8 @@ def stop_all():
|
|||
listeners = list(_all_listeners.values())
|
||||
_all_listeners.clear()
|
||||
if listeners:
|
||||
logger.info("stopping receiver listening threads %s", listeners)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("stopping receiver listening threads %s", listeners)
|
||||
for listener_thread in listeners:
|
||||
listener_thread.stop()
|
||||
configuration.save()
|
||||
|
|
@ -300,7 +308,8 @@ 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):
|
||||
logger.info("ping all devices%s", " when resuming" if resuming else "")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
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,7 +336,7 @@ _setting_callback = None # GUI callback to change UI in response to changes to
|
|||
_error_callback = None # GUI callback to report errors
|
||||
|
||||
|
||||
def setup_scanner(status_changed_callback: Callable, setting_changed_callback: Callable, error_callback: Callable):
|
||||
def setup_scanner(status_changed_callback, setting_changed_callback, error_callback):
|
||||
global _status_callback, _error_callback, _setting_callback
|
||||
assert _status_callback is None, "scanner was already set-up"
|
||||
_status_callback = status_changed_callback
|
||||
|
|
@ -336,24 +345,25 @@ def setup_scanner(status_changed_callback: Callable, setting_changed_callback: C
|
|||
base.notify_on_receivers_glib(GLib, _process_receiver_event)
|
||||
|
||||
|
||||
def _process_add(device_info: DeviceInfo, retry):
|
||||
def _process_add(device_info, retry):
|
||||
try:
|
||||
_start(device_info)
|
||||
except OSError as e:
|
||||
if e.errno == errno.EACCES:
|
||||
try:
|
||||
output = subprocess.check_output(["/usr/bin/getfacl", "-p", device_info.path], text=True)
|
||||
logger.warning("Missing permissions on %s\n%s.", device_info.path, output)
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
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(common.ErrorReason.PERMISSIONS, device_info.path)
|
||||
_error_callback("permissions", device_info.path)
|
||||
else:
|
||||
_error_callback(common.ErrorReason.NO_DEVICE, device_info.path)
|
||||
_error_callback("nodevice", device_info.path)
|
||||
except exceptions.NoReceiver:
|
||||
_error_callback(common.ErrorReason.NO_DEVICE, device_info.path)
|
||||
_error_callback("nodevice", device_info.path)
|
||||
|
||||
|
||||
# receiver add/remove events will start/stop listener threads
|
||||
|
|
@ -361,7 +371,8 @@ def _process_receiver_event(action, device_info):
|
|||
assert action is not None
|
||||
assert device_info is not None
|
||||
assert _error_callback
|
||||
logger.info("receiver event %s %s", action, device_info)
|
||||
if logger.isEnabledFor(logging.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:
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ class TaskRunner(Thread):
|
|||
def run(self):
|
||||
self.alive = True
|
||||
|
||||
logger.debug("started")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("started")
|
||||
|
||||
while self.alive:
|
||||
task = self.queue.get()
|
||||
|
|
@ -58,4 +59,5 @@ class TaskRunner(Thread):
|
|||
except Exception:
|
||||
logger.exception("calling %s", function)
|
||||
|
||||
logger.debug("stopped")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("stopped")
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
|
||||
import logging
|
||||
|
||||
from enum import Enum
|
||||
from typing import Callable
|
||||
|
||||
import gi
|
||||
|
|
@ -49,14 +48,9 @@ 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):
|
||||
logger.debug("startup registered=%s, remote=%s", app.get_is_registered(), app.get_is_remote())
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("startup registered=%s, remote=%s", app.get_is_registered(), app.get_is_remote())
|
||||
common.start_async()
|
||||
desktop_notifications.init()
|
||||
if use_tray:
|
||||
|
|
@ -66,7 +60,8 @@ def _startup(app, startup_hook, use_tray, show_window):
|
|||
|
||||
|
||||
def _activate(app):
|
||||
logger.debug("activate")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("activate")
|
||||
if app.get_windows():
|
||||
window.popup()
|
||||
else:
|
||||
|
|
@ -79,7 +74,8 @@ def _command_line(app, command_line):
|
|||
if not args:
|
||||
_activate(app)
|
||||
elif args[0] == "config": # config call from remote instance
|
||||
logger.info("remote command line %s", args)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
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)
|
||||
|
|
@ -89,7 +85,8 @@ def _command_line(app, command_line):
|
|||
|
||||
|
||||
def _shutdown(_app, shutdown_hook):
|
||||
logger.debug("shutdown")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("shutdown")
|
||||
shutdown_hook()
|
||||
common.stop_async()
|
||||
tray.destroy()
|
||||
|
|
@ -111,9 +108,9 @@ def run_loop(
|
|||
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.connect("command-line", _command_line)
|
||||
application.connect("activate", _activate)
|
||||
application.connect("shutdown", _shutdown, shutdown_hook)
|
||||
|
||||
application.register()
|
||||
if application.get_is_remote():
|
||||
|
|
@ -123,7 +120,8 @@ def run_loop(
|
|||
|
||||
def _status_changed(device, alert, reason, refresh=False):
|
||||
assert device is not None
|
||||
logger.debug("status changed: %s (%s) %s", device, alert, reason)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("status changed: %s (%s) %s", device, alert, reason)
|
||||
if alert is None:
|
||||
alert = Alert.NONE
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,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 Enum
|
||||
|
||||
|
||||
from typing import List
|
||||
from typing import Tuple
|
||||
from typing import Union
|
||||
|
|
@ -23,10 +24,6 @@ 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
|
||||
|
|
@ -34,10 +31,10 @@ class AboutView:
|
|||
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_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))
|
||||
self.view.connect("response", lambda x, y: self.handle_close(x))
|
||||
|
||||
def update_version_info(self, version: str) -> None:
|
||||
self.view.set_version(version)
|
||||
|
|
|
|||
|
|
@ -14,19 +14,14 @@
|
|||
## 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
|
||||
|
||||
|
||||
class GtkSignal(Enum):
|
||||
ACTIVATE = "activate"
|
||||
from .common import error_dialog
|
||||
|
||||
|
||||
def make_image_menu_item(label, icon_name, function, *args):
|
||||
|
|
@ -38,7 +33,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(GtkSignal.ACTIVATE.value, function, *args)
|
||||
menu_item.connect("activate", function, *args)
|
||||
menu_item.label = label
|
||||
menu_item.icon = icon
|
||||
return menu_item
|
||||
|
|
@ -50,7 +45,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(GtkSignal.ACTIVATE.value, function, *args)
|
||||
action.connect("activate", function, *args)
|
||||
return action
|
||||
|
||||
|
||||
|
|
@ -59,7 +54,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(GtkSignal.ACTIVATE.value, function, *args)
|
||||
action.connect("activate", function, *args)
|
||||
return action
|
||||
|
||||
|
||||
|
|
@ -100,4 +95,4 @@ def unpair(window, device):
|
|||
try:
|
||||
del receiver[device_number]
|
||||
except Exception:
|
||||
common.error_dialog(common.ErrorReason.UNPAIR, device)
|
||||
error_dialog("unpair", device)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
|
||||
import logging
|
||||
|
||||
from enum import Enum
|
||||
from typing import Tuple
|
||||
|
||||
import gi
|
||||
|
|
@ -31,28 +30,22 @@ from gi.repository import Gtk # NOQA: E402
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ErrorReason(Enum):
|
||||
PERMISSIONS = "Permissions"
|
||||
NO_DEVICE = "No device"
|
||||
UNPAIR = "Unpair"
|
||||
|
||||
|
||||
def _create_error_text(reason: ErrorReason, object_) -> Tuple[str, str]:
|
||||
if reason == ErrorReason.PERMISSIONS:
|
||||
def _create_error_text(reason: str, object_) -> Tuple[str, str]:
|
||||
if reason == "permissions":
|
||||
title = _("Permissions error")
|
||||
text = (
|
||||
_("Found a Logitech receiver or device (%s), but did not have permission to open it.") % object_
|
||||
+ "\n\n"
|
||||
+ _("If you've just installed Solaar, try disconnecting the receiver or device and then reconnecting it.")
|
||||
)
|
||||
elif reason == ErrorReason.NO_DEVICE:
|
||||
elif reason == "nodevice":
|
||||
title = _("Cannot connect to device error")
|
||||
text = (
|
||||
_("Found a Logitech receiver or device at %s, but encountered an error connecting to it.") % object_
|
||||
+ "\n\n"
|
||||
+ _("Try disconnecting the device and then reconnecting it or turning it off and then on.")
|
||||
)
|
||||
elif reason == ErrorReason.UNPAIR:
|
||||
elif reason == "unpair":
|
||||
title = _("Unpairing failed")
|
||||
text = (
|
||||
_("Failed to unpair %{device} from %{receiver}.").format(
|
||||
|
|
@ -63,11 +56,11 @@ def _create_error_text(reason: ErrorReason, object_) -> Tuple[str, str]:
|
|||
+ _("The receiver returned an error, with no further details.")
|
||||
)
|
||||
else:
|
||||
raise Exception("ui.error_dialog: don't know how to handle (%s, %s)", reason.name, object_)
|
||||
raise Exception("ui.error_dialog: don't know how to handle (%s, %s)", reason, object_)
|
||||
return title, text
|
||||
|
||||
|
||||
def _error_dialog(reason: ErrorReason, object_):
|
||||
def _error_dialog(reason: str, object_):
|
||||
logger.error("error: %s %s", reason, object_)
|
||||
title, text = _create_error_text(reason, object_)
|
||||
|
||||
|
|
@ -77,7 +70,8 @@ def _error_dialog(reason: ErrorReason, object_):
|
|||
m.destroy()
|
||||
|
||||
|
||||
def error_dialog(reason: ErrorReason, object_):
|
||||
def error_dialog(reason, object_):
|
||||
assert reason is not None
|
||||
GLib.idle_add(_error_dialog, reason, object_)
|
||||
|
||||
|
||||
|
|
@ -97,6 +91,5 @@ def stop_async():
|
|||
|
||||
|
||||
def ui_async(function, *args, **kwargs):
|
||||
"""Runs a function asynchronously."""
|
||||
if _task_runner:
|
||||
_task_runner(function, *args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
import logging
|
||||
import traceback
|
||||
|
||||
from enum import Enum
|
||||
from threading import Timer
|
||||
|
||||
import gi
|
||||
|
|
@ -39,23 +38,14 @@ 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
|
||||
logger.warning("%s: error reading so use None (%s): %s", s.name, s._device, repr(e))
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
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)
|
||||
|
|
@ -115,7 +105,7 @@ class Control:
|
|||
def layout(self, sbox, label, change, spinner, failed):
|
||||
sbox.pack_start(label, False, False, 0)
|
||||
sbox.pack_end(change, False, False, 0)
|
||||
fill = sbox.setting.kind == settings.Kind.RANGE or sbox.setting.kind == settings.Kind.HETERO
|
||||
fill = sbox.setting.kind == settings.KIND.range or sbox.setting.kind == settings.KIND.hetero
|
||||
sbox.pack_end(self, fill, fill, 0)
|
||||
sbox.pack_end(spinner, False, False, 0)
|
||||
sbox.pack_end(failed, False, False, 0)
|
||||
|
|
@ -126,7 +116,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(GtkSignal.NOTIFY_ACTIVE.value, self.changed)
|
||||
self.connect("notify::active", self.changed)
|
||||
|
||||
def set_value(self, value):
|
||||
if value is not None:
|
||||
|
|
@ -145,7 +135,7 @@ class SliderControl(Gtk.Scale, Control):
|
|||
self.set_round_digits(0)
|
||||
self.set_digits(0)
|
||||
self.set_increments(1, 5)
|
||||
self.connect(GtkSignal.VALUE_CHANGED.value, self.changed)
|
||||
self.connect("value-changed", self.changed)
|
||||
|
||||
def get_value(self):
|
||||
return int(super().get_value())
|
||||
|
|
@ -177,7 +167,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(GtkSignal.CHANGED.value, self.changed)
|
||||
self.connect("changed", self.changed)
|
||||
|
||||
def get_value(self):
|
||||
return int(self.get_active_id()) if self.get_active_id() is not None else None
|
||||
|
|
@ -215,9 +205,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(GtkSignal.CHANGED.value, self.changed)
|
||||
self.connect(GtkSignal.ACTIVATE.value, self.activate)
|
||||
completion.connect(GtkSignal.MATCH_SELECTED.value, self.select)
|
||||
self.connect("changed", self.changed)
|
||||
self.connect("activate", self.activate)
|
||||
completion.connect("match_selected", self.select)
|
||||
|
||||
def get_value(self):
|
||||
choice = self.get_choice()
|
||||
|
|
@ -231,9 +221,6 @@ 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 ""
|
||||
|
|
@ -266,7 +253,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(GtkSignal.CHANGED.value, self.map_value_notify_key)
|
||||
self.keyBox.connect("changed", self.map_value_notify_key)
|
||||
|
||||
def get_value(self):
|
||||
key_choice = int(self.keyBox.get_active_id())
|
||||
|
|
@ -286,6 +273,7 @@ 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:
|
||||
|
|
@ -313,7 +301,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(GtkSignal.CLICKED.value, self.toggle_display)
|
||||
btn.connect("clicked", self.toggle_display)
|
||||
self._button = btn
|
||||
hbox = Gtk.HBox(homogeneous=False, spacing=6)
|
||||
hbox.pack_end(change, False, False, 0)
|
||||
|
|
@ -361,7 +349,7 @@ class MultipleToggleControl(MultipleControl):
|
|||
h.set_tooltip_text(lbl_tooltip or " ")
|
||||
control = Gtk.Switch()
|
||||
control._setting_key = int(k)
|
||||
control.connect(GtkSignal.NOTIFY_ACTIVE.value, self.toggle_notify)
|
||||
control.connect("notify::active", self.toggle_notify)
|
||||
h.pack_start(lbl, False, False, 0)
|
||||
h.pack_end(control, False, False, 0)
|
||||
lbl.set_margin_start(30)
|
||||
|
|
@ -388,7 +376,7 @@ class MultipleToggleControl(MultipleControl):
|
|||
elem.set_state(v)
|
||||
if elem.get_state():
|
||||
active += 1
|
||||
to_join.append(f"{lbl.get_text()}: {str(elem.get_state())}")
|
||||
to_join.append(lbl.get_text() + ": " + str(elem.get_state()))
|
||||
b = ", ".join(to_join)
|
||||
self._button.set_label(f"{active} / {total}")
|
||||
self._button.set_tooltip_text(b)
|
||||
|
|
@ -438,7 +426,7 @@ class MultipleRangeControl(MultipleControl):
|
|||
h.pack_end(control, False, False, 0)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
control.connect(GtkSignal.VALUE_CHANGED.value, self.changed, item, sub_item)
|
||||
control.connect("value-changed", self.changed, item, sub_item)
|
||||
item_lb.add(h)
|
||||
h._setting_sub_item = sub_item
|
||||
h._label, h._control = sub_item_lbl, control
|
||||
|
|
@ -472,7 +460,7 @@ class MultipleRangeControl(MultipleControl):
|
|||
item = ch._setting_item
|
||||
v = value.get(int(item), None)
|
||||
if v is not None:
|
||||
b += f"{str(item)}: ("
|
||||
b += str(item) + ": ("
|
||||
to_join = []
|
||||
for c in ch._sub_items:
|
||||
sub_item = c._setting_sub_item
|
||||
|
|
@ -482,7 +470,7 @@ class MultipleRangeControl(MultipleControl):
|
|||
sub_item_value = c._control.get_value()
|
||||
c._control.set_value(sub_item_value)
|
||||
n += 1
|
||||
to_join.append(f"{str(sub_item)}={sub_item_value}")
|
||||
to_join.append(str(sub_item) + f"={sub_item_value}")
|
||||
b += ", ".join(to_join) + ") "
|
||||
lbl_text = ngettext("%d value", "%d values", n) % n
|
||||
self._button.set_label(lbl_text)
|
||||
|
|
@ -499,7 +487,7 @@ class PackedRangeControl(MultipleRangeControl):
|
|||
control = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, validator.min_value, validator.max_value, 1)
|
||||
control.set_round_digits(0)
|
||||
control.set_digits(0)
|
||||
control.connect(GtkSignal.VALUE_CHANGED.value, self.changed, validator.keys[item])
|
||||
control.connect("value-changed", 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]
|
||||
|
|
@ -535,7 +523,7 @@ class PackedRangeControl(MultipleRangeControl):
|
|||
h.control.set_value(v)
|
||||
else:
|
||||
v = self.sbox.setting._value[int(item)]
|
||||
b += f"{str(item)}: ({str(v)}) "
|
||||
b += 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)
|
||||
|
|
@ -556,19 +544,19 @@ class HeteroKeyControl(Gtk.HBox, Control):
|
|||
item_lblbox = None
|
||||
|
||||
item_box = ComboBoxText()
|
||||
if item["kind"] == settings.Kind.CHOICE:
|
||||
if item["kind"] == settings.KIND.choice:
|
||||
for entry in item["choices"]:
|
||||
item_box.append(str(int(entry)), str(entry))
|
||||
item_box.set_active(0)
|
||||
item_box.connect(GtkSignal.CHANGED.value, self.changed)
|
||||
item_box.connect("changed", self.changed)
|
||||
self.pack_start(item_box, False, False, 0)
|
||||
elif item["kind"] == settings.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(GtkSignal.VALUE_CHANGED.value, self.changed)
|
||||
item_box.connect("value-changed", self.changed)
|
||||
self.pack_start(item_box, True, True, 0)
|
||||
item_box.set_visible(False)
|
||||
self._items[str(item["name"])] = (item_lblbox, item_box)
|
||||
|
|
@ -676,26 +664,27 @@ def _create_sbox(s, _device):
|
|||
change.set_relief(Gtk.ReliefStyle.NONE)
|
||||
change.add(change_icon)
|
||||
change.set_sensitive(True)
|
||||
change.connect(GtkSignal.CLICKED.value, _change_click, sbox)
|
||||
change.connect("clicked", _change_click, sbox)
|
||||
|
||||
if s.kind == settings.Kind.TOGGLE:
|
||||
if s.kind == settings.KIND.toggle:
|
||||
control = ToggleControl(sbox)
|
||||
elif s.kind == settings.Kind.RANGE:
|
||||
elif s.kind == settings.KIND.range:
|
||||
control = SliderControl(sbox)
|
||||
elif s.kind == settings.Kind.CHOICE:
|
||||
elif s.kind == settings.KIND.choice:
|
||||
control = _create_choice_control(sbox)
|
||||
elif s.kind == settings.Kind.MAP_CHOICE:
|
||||
elif s.kind == settings.KIND.map_choice:
|
||||
control = MapChoiceControl(sbox)
|
||||
elif s.kind == settings.Kind.MULTIPLE_TOGGLE:
|
||||
elif s.kind == settings.KIND.multiple_toggle:
|
||||
control = MultipleToggleControl(sbox, change)
|
||||
elif s.kind == settings.Kind.MULTIPLE_RANGE:
|
||||
elif s.kind == settings.KIND.multiple_range:
|
||||
control = MultipleRangeControl(sbox, change)
|
||||
elif s.kind == settings.Kind.PACKED_RANGE:
|
||||
elif s.kind == settings.KIND.packed_range:
|
||||
control = PackedRangeControl(sbox, change)
|
||||
elif s.kind == settings.Kind.HETERO:
|
||||
elif s.kind == settings.KIND.hetero:
|
||||
control = HeteroKeyControl(sbox, change)
|
||||
else:
|
||||
logger.warning("setting %s display not implemented", s.label)
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("setting %s display not implemented", s.label)
|
||||
return None
|
||||
|
||||
control.set_sensitive(False) # the first read will enable it
|
||||
|
|
@ -717,10 +706,7 @@ def _update_setting_item(sbox, value, is_online=True, sensitive=True, null_okay=
|
|||
return
|
||||
sbox._failed.set_visible(False)
|
||||
sbox._control.set_sensitive(False)
|
||||
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_value(value)
|
||||
sbox._control.set_sensitive(sensitive is True)
|
||||
_change_icon(sensitive, sbox._change_icon)
|
||||
|
||||
|
|
@ -824,9 +810,10 @@ def record_setting(device, setting, values):
|
|||
|
||||
|
||||
def _record_setting(device, setting_class, values):
|
||||
logger.debug("on %s changing setting %s to %s", device, setting_class.name, values)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
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:
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ if available:
|
|||
global available
|
||||
if available:
|
||||
if not Notify.is_initted():
|
||||
logger.info("starting desktop notifications")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("starting desktop notifications")
|
||||
try:
|
||||
return Notify.init(NAME.lower())
|
||||
except Exception:
|
||||
|
|
@ -69,7 +70,8 @@ if available:
|
|||
def uninit():
|
||||
"""Stop desktop notifications."""
|
||||
if available and Notify.is_initted():
|
||||
logger.info("stopping desktop notifications")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("stopping desktop notifications")
|
||||
_notifications.clear()
|
||||
Notify.uninit()
|
||||
|
||||
|
|
@ -122,7 +124,7 @@ if available:
|
|||
n.set_hint("value", GLib.Variant("i", progress))
|
||||
|
||||
try:
|
||||
return n.show()
|
||||
n.show()
|
||||
except Exception:
|
||||
logger.exception(f"showing {n}")
|
||||
|
||||
|
|
|
|||
|
|
@ -35,11 +35,11 @@ from typing import Optional
|
|||
from gi.repository import Gdk
|
||||
from gi.repository import GObject
|
||||
from gi.repository import Gtk
|
||||
from logitech_receiver import diversion
|
||||
from logitech_receiver import diversion as _DIV
|
||||
from logitech_receiver.common import NamedInt
|
||||
from logitech_receiver.common import NamedInts
|
||||
from logitech_receiver.common import UnsortedNamedInts
|
||||
from logitech_receiver.settings import Kind
|
||||
from logitech_receiver.settings import KIND as _SKIND
|
||||
from logitech_receiver.settings import Setting
|
||||
from logitech_receiver.settings_templates import SETTINGS
|
||||
|
||||
|
|
@ -56,40 +56,6 @@ _diversion_dialog = None
|
|||
_rule_component_clipboard = None
|
||||
|
||||
|
||||
class GtkSignal(Enum):
|
||||
ACTIVATE = "activate"
|
||||
BUTTON_RELEASE_EVENT = "button-release-event"
|
||||
CHANGED = "changed"
|
||||
CLICKED = "clicked"
|
||||
DELETE_EVENT = "delete-event"
|
||||
KEY_PRESS_EVENT = "key-press-event"
|
||||
NOTIFY_ACTIVE = "notify::active"
|
||||
TOGGLED = "toggled"
|
||||
VALUE_CHANGED = "value_changed"
|
||||
|
||||
|
||||
def create_all_settings(all_settings: list[Setting]) -> dict[str, list[Setting]]:
|
||||
settings = {}
|
||||
for s in sorted(all_settings, key=lambda setting: setting.label):
|
||||
if s.name not in settings:
|
||||
settings[s.name] = [s]
|
||||
else:
|
||||
prev_setting = settings[s.name][0]
|
||||
prev_kind = prev_setting.validator_class.kind
|
||||
if prev_kind != s.validator_class.kind:
|
||||
logger.warning(
|
||||
"ignoring setting {} - same name of {}, but different kind ({} != {})".format(
|
||||
s.__name__, prev_setting.__name__, prev_kind, s.validator_class.kind
|
||||
)
|
||||
)
|
||||
continue
|
||||
settings[s.name].append(s)
|
||||
return settings
|
||||
|
||||
|
||||
ALL_SETTINGS = create_all_settings(SETTINGS)
|
||||
|
||||
|
||||
class RuleComponentWrapper(GObject.GObject):
|
||||
def __init__(self, component, level=0, editable=False):
|
||||
self.component = component
|
||||
|
|
@ -98,7 +64,7 @@ class RuleComponentWrapper(GObject.GObject):
|
|||
GObject.GObject.__init__(self)
|
||||
|
||||
def display_left(self):
|
||||
if isinstance(self.component, diversion.Rule):
|
||||
if isinstance(self.component, _DIV.Rule):
|
||||
if self.level == 0:
|
||||
return _("Built-in rules") if not self.editable else _("User-defined rules")
|
||||
if self.level == 1:
|
||||
|
|
@ -116,7 +82,7 @@ class RuleComponentWrapper(GObject.GObject):
|
|||
def display_icon(self):
|
||||
if self.component is None:
|
||||
return ""
|
||||
if isinstance(self.component, diversion.Rule) and self.level == 0:
|
||||
if isinstance(self.component, _DIV.Rule) and self.level == 0:
|
||||
return "emblem-system" if not self.editable else "avatar-default"
|
||||
return self.__component_ui().icon_name()
|
||||
|
||||
|
|
@ -177,17 +143,17 @@ def _populate_model(
|
|||
return
|
||||
if editable is None:
|
||||
editable = model[it][0].editable if it is not None else False
|
||||
if isinstance(rule_component, diversion.Rule):
|
||||
if isinstance(rule_component, _DIV.Rule):
|
||||
editable = editable or (rule_component.source is not None)
|
||||
wrapped = RuleComponentWrapper(rule_component, level, editable=editable)
|
||||
piter = model.insert(it, pos, (wrapped,))
|
||||
if isinstance(rule_component, (diversion.Rule, diversion.And, diversion.Or, diversion.Later)):
|
||||
if isinstance(rule_component, (_DIV.Rule, _DIV.And, _DIV.Or, _DIV.Later)):
|
||||
for c in rule_component.components:
|
||||
ed = editable or (isinstance(c, diversion.Rule) and c.source is not None)
|
||||
ed = editable or (isinstance(c, _DIV.Rule) and c.source is not None)
|
||||
_populate_model(model, piter, c, level + 1, editable=ed)
|
||||
if len(rule_component.components) == 0:
|
||||
_populate_model(model, piter, None, level + 1, editable=editable)
|
||||
elif isinstance(rule_component, diversion.Not):
|
||||
elif isinstance(rule_component, _DIV.Not):
|
||||
_populate_model(model, piter, rule_component.component, level + 1, editable=editable)
|
||||
|
||||
|
||||
|
|
@ -211,13 +177,13 @@ def allowed_actions(m: Gtk.TreeStore, it: Gtk.TreeIter) -> AllowedActions:
|
|||
parent_c = m[parent_it][0].component if wrapped.level > 0 else None
|
||||
|
||||
can_wrap = wrapped.editable and wrapped.component is not None and wrapped.level >= 2
|
||||
can_delete = wrapped.editable and not isinstance(parent_c, diversion.Not) and c is not None and wrapped.level >= 1
|
||||
can_insert = wrapped.editable and not isinstance(parent_c, diversion.Not) and wrapped.level >= 2
|
||||
can_delete = wrapped.editable and not isinstance(parent_c, _DIV.Not) and c is not None and wrapped.level >= 1
|
||||
can_insert = wrapped.editable and not isinstance(parent_c, _DIV.Not) and wrapped.level >= 2
|
||||
can_insert_only_rule = wrapped.editable and wrapped.level == 1
|
||||
can_flatten = (
|
||||
wrapped.editable
|
||||
and not isinstance(parent_c, diversion.Not)
|
||||
and isinstance(c, (diversion.Rule, diversion.And, diversion.Or))
|
||||
and not isinstance(parent_c, _DIV.Not)
|
||||
and isinstance(c, (_DIV.Rule, _DIV.And, _DIV.Or))
|
||||
and wrapped.level >= 2
|
||||
and len(c.components)
|
||||
)
|
||||
|
|
@ -276,7 +242,7 @@ class ActionMenu:
|
|||
p2 = self._menu_paste(m, it, below=True)
|
||||
p2.set_label(_("Paste below"))
|
||||
menu.append(p2)
|
||||
elif enabled_actions.insert_only_rule and isinstance(_rule_component_clipboard, diversion.Rule):
|
||||
elif enabled_actions.insert_only_rule and isinstance(_rule_component_clipboard, _DIV.Rule):
|
||||
p = self._menu_paste(m, it)
|
||||
menu.append(p)
|
||||
if enabled_actions.c is None:
|
||||
|
|
@ -286,7 +252,7 @@ class ActionMenu:
|
|||
p2 = self._menu_paste(m, it, below=True)
|
||||
p2.set_label(_("Paste rule below"))
|
||||
menu.append(p2)
|
||||
elif enabled_actions.insert_root and isinstance(_rule_component_clipboard, diversion.Rule):
|
||||
elif enabled_actions.insert_root and isinstance(_rule_component_clipboard, _DIV.Rule):
|
||||
p = self._menu_paste(m, m.iter_nth_child(it, 0))
|
||||
p.set_label(_("Paste rule"))
|
||||
menu.append(p)
|
||||
|
|
@ -330,7 +296,7 @@ class ActionMenu:
|
|||
parent_it = m.iter_parent(it)
|
||||
parent_c = m[parent_it][0].component
|
||||
idx = parent_c.components.index(c)
|
||||
if isinstance(c, diversion.Not):
|
||||
if isinstance(c, _DIV.Not):
|
||||
parent_c.components = [*parent_c.components[:idx], c.component, *parent_c.components[idx + 1 :]]
|
||||
children = [next(m[it].iterchildren())[0].component]
|
||||
else:
|
||||
|
|
@ -345,7 +311,7 @@ class ActionMenu:
|
|||
|
||||
def _menu_flatten(self, m, it):
|
||||
menu_flatten = Gtk.MenuItem(_("Flatten"))
|
||||
menu_flatten.connect(GtkSignal.ACTIVATE.value, self.menu_do_flatten, m, it)
|
||||
menu_flatten.connect("activate", self.menu_do_flatten, m, it)
|
||||
menu_flatten.show()
|
||||
return menu_flatten
|
||||
|
||||
|
|
@ -358,8 +324,8 @@ class ActionMenu:
|
|||
idx = 0
|
||||
else:
|
||||
idx = parent_c.components.index(c)
|
||||
if isinstance(new_c, diversion.Rule) and wrapped.level == 1:
|
||||
new_c.source = diversion._file_path # new rules will be saved to the YAML file
|
||||
if isinstance(new_c, _DIV.Rule) and wrapped.level == 1:
|
||||
new_c.source = _DIV._file_path # new rules will be saved to the YAML file
|
||||
idx += int(below)
|
||||
parent_c.components.insert(idx, new_c)
|
||||
self._populate_model_func(m, parent_it, new_c, level=wrapped.level, pos=idx)
|
||||
|
|
@ -368,7 +334,7 @@ class ActionMenu:
|
|||
m.remove(it) # remove placeholder in the end
|
||||
new_iter = m.iter_nth_child(parent_it, idx)
|
||||
self.tree_view.get_selection().select_iter(new_iter)
|
||||
if isinstance(new_c, (diversion.Rule, diversion.And, diversion.Or, diversion.Not)):
|
||||
if isinstance(new_c, (_DIV.Rule, _DIV.And, _DIV.Or, _DIV.Not)):
|
||||
self.tree_view.expand_row(m.get_path(new_iter), True)
|
||||
|
||||
def _menu_do_insert_new(self, _mitem, m, it, cls, initial_value, below=False):
|
||||
|
|
@ -379,37 +345,37 @@ class ActionMenu:
|
|||
elements = [
|
||||
_("Insert"),
|
||||
[
|
||||
(_("Sub-rule"), diversion.Rule, []),
|
||||
(_("Or"), diversion.Or, []),
|
||||
(_("And"), diversion.And, []),
|
||||
(_("Sub-rule"), _DIV.Rule, []),
|
||||
(_("Or"), _DIV.Or, []),
|
||||
(_("And"), _DIV.And, []),
|
||||
[
|
||||
_("Condition"),
|
||||
[
|
||||
(_("Feature"), diversion.Feature, rule_conditions.FeatureUI.FEATURES_WITH_DIVERSION[0]),
|
||||
(_("Report"), diversion.Report, 0),
|
||||
(_("Process"), diversion.Process, ""),
|
||||
(_("Mouse process"), diversion.MouseProcess, ""),
|
||||
(_("Modifiers"), diversion.Modifiers, []),
|
||||
(_("Key"), diversion.Key, ""),
|
||||
(_("KeyIsDown"), diversion.KeyIsDown, ""),
|
||||
(_("Active"), diversion.Active, ""),
|
||||
(_("Device"), diversion.Device, ""),
|
||||
(_("Host"), diversion.Host, ""),
|
||||
(_("Setting"), diversion.Setting, [None, "", None]),
|
||||
(_("Test"), diversion.Test, next(iter(diversion.TESTS))),
|
||||
(_("Test bytes"), diversion.TestBytes, [0, 1, 0]),
|
||||
(_("Mouse Gesture"), diversion.MouseGesture, ""),
|
||||
(_("Feature"), _DIV.Feature, rule_conditions.FeatureUI.FEATURES_WITH_DIVERSION[0]),
|
||||
(_("Report"), _DIV.Report, 0),
|
||||
(_("Process"), _DIV.Process, ""),
|
||||
(_("Mouse process"), _DIV.MouseProcess, ""),
|
||||
(_("Modifiers"), _DIV.Modifiers, []),
|
||||
(_("Key"), _DIV.Key, ""),
|
||||
(_("KeyIsDown"), _DIV.KeyIsDown, ""),
|
||||
(_("Active"), _DIV.Active, ""),
|
||||
(_("Device"), _DIV.Device, ""),
|
||||
(_("Host"), _DIV.Host, ""),
|
||||
(_("Setting"), _DIV.Setting, [None, "", None]),
|
||||
(_("Test"), _DIV.Test, next(iter(_DIV.TESTS))),
|
||||
(_("Test bytes"), _DIV.TestBytes, [0, 1, 0]),
|
||||
(_("Mouse Gesture"), _DIV.MouseGesture, ""),
|
||||
],
|
||||
],
|
||||
[
|
||||
_("Action"),
|
||||
[
|
||||
(_("Key press"), diversion.KeyPress, "space"),
|
||||
(_("Mouse scroll"), diversion.MouseScroll, [0, 0]),
|
||||
(_("Mouse click"), diversion.MouseClick, ["left", 1]),
|
||||
(_("Set"), diversion.Set, [None, "", None]),
|
||||
(_("Execute"), diversion.Execute, [""]),
|
||||
(_("Later"), diversion.Later, [1]),
|
||||
(_("Key press"), _DIV.KeyPress, "space"),
|
||||
(_("Mouse scroll"), _DIV.MouseScroll, [0, 0]),
|
||||
(_("Mouse click"), _DIV.MouseClick, ["left", 1]),
|
||||
(_("Set"), _DIV.Set, [None, "", None]),
|
||||
(_("Execute"), _DIV.Execute, [""]),
|
||||
(_("Later"), _DIV.Later, [1]),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
@ -428,7 +394,7 @@ class ActionMenu:
|
|||
label, feature, *args = spec
|
||||
item = Gtk.MenuItem(label)
|
||||
args = [a.copy() if isinstance(a, list) else a for a in args]
|
||||
item.connect(GtkSignal.ACTIVATE.value, self._menu_do_insert_new, m, it, feature, *args, below)
|
||||
item.connect("activate", self._menu_do_insert_new, m, it, feature, *args, below)
|
||||
return item
|
||||
else:
|
||||
return None
|
||||
|
|
@ -439,7 +405,7 @@ class ActionMenu:
|
|||
|
||||
def _menu_create_rule(self, m, it, below=False) -> Gtk.MenuItem:
|
||||
menu_create_rule = Gtk.MenuItem(_("Insert new rule"))
|
||||
menu_create_rule.connect(GtkSignal.ACTIVATE.value, self._menu_do_insert_new, m, it, diversion.Rule, [], below)
|
||||
menu_create_rule.connect("activate", self._menu_do_insert_new, m, it, _DIV.Rule, [], below)
|
||||
menu_create_rule.show()
|
||||
return menu_create_rule
|
||||
|
||||
|
|
@ -459,7 +425,7 @@ class ActionMenu:
|
|||
|
||||
def _menu_delete(self, m, it) -> Gtk.MenuItem:
|
||||
menu_delete = Gtk.MenuItem(_("Delete"))
|
||||
menu_delete.connect(GtkSignal.ACTIVATE.value, self.menu_do_delete, m, it)
|
||||
menu_delete.connect("activate", self.menu_do_delete, m, it)
|
||||
menu_delete.show()
|
||||
return menu_delete
|
||||
|
||||
|
|
@ -468,20 +434,20 @@ class ActionMenu:
|
|||
c = wrapped.component
|
||||
parent_it = m.iter_parent(it)
|
||||
parent_c = m[parent_it][0].component
|
||||
if isinstance(c, diversion.Not): # avoid double negation
|
||||
if isinstance(c, _DIV.Not): # avoid double negation
|
||||
self.menu_do_flatten(_mitem, m, it)
|
||||
self.tree_view.expand_row(m.get_path(parent_it), True)
|
||||
elif isinstance(parent_c, diversion.Not): # avoid double negation
|
||||
elif isinstance(parent_c, _DIV.Not): # avoid double negation
|
||||
self.menu_do_flatten(_mitem, m, parent_it)
|
||||
else:
|
||||
idx = parent_c.components.index(c)
|
||||
self._menu_do_insert_new(_mitem, m, it, diversion.Not, c, below=True)
|
||||
self._menu_do_insert_new(_mitem, m, it, _DIV.Not, c, below=True)
|
||||
self.menu_do_delete(_mitem, m, m.iter_nth_child(parent_it, idx))
|
||||
self._on_update()
|
||||
|
||||
def _menu_negate(self, m, it) -> Gtk.MenuItem:
|
||||
menu_negate = Gtk.MenuItem(_("Negate"))
|
||||
menu_negate.connect(GtkSignal.ACTIVATE.value, self.menu_do_negate, m, it)
|
||||
menu_negate.connect("activate", self.menu_do_negate, m, it)
|
||||
menu_negate.show()
|
||||
return menu_negate
|
||||
|
||||
|
|
@ -490,7 +456,7 @@ class ActionMenu:
|
|||
c = wrapped.component
|
||||
parent_it = m.iter_parent(it)
|
||||
parent_c = m[parent_it][0].component
|
||||
if isinstance(parent_c, diversion.Not):
|
||||
if isinstance(parent_c, _DIV.Not):
|
||||
new_c = cls([c], warn=False)
|
||||
parent_c.component = new_c
|
||||
m.remove(it)
|
||||
|
|
@ -509,9 +475,9 @@ class ActionMenu:
|
|||
menu_sub_rule = Gtk.MenuItem(_("Sub-rule"))
|
||||
menu_and = Gtk.MenuItem(_("And"))
|
||||
menu_or = Gtk.MenuItem(_("Or"))
|
||||
menu_sub_rule.connect(GtkSignal.ACTIVATE.value, self.menu_do_wrap, m, it, diversion.Rule)
|
||||
menu_and.connect(GtkSignal.ACTIVATE.value, self.menu_do_wrap, m, it, diversion.And)
|
||||
menu_or.connect(GtkSignal.ACTIVATE.value, self.menu_do_wrap, m, it, diversion.Or)
|
||||
menu_sub_rule.connect("activate", self.menu_do_wrap, m, it, _DIV.Rule)
|
||||
menu_and.connect("activate", self.menu_do_wrap, m, it, _DIV.And)
|
||||
menu_or.connect("activate", self.menu_do_wrap, m, it, _DIV.Or)
|
||||
submenu_wrap.append(menu_sub_rule)
|
||||
submenu_wrap.append(menu_and)
|
||||
submenu_wrap.append(menu_or)
|
||||
|
|
@ -524,7 +490,7 @@ class ActionMenu:
|
|||
|
||||
wrapped = m[it][0]
|
||||
c = wrapped.component
|
||||
_rule_component_clipboard = diversion.RuleComponent().compile(c.data())
|
||||
_rule_component_clipboard = _DIV.RuleComponent().compile(c.data())
|
||||
|
||||
def menu_do_cut(self, _mitem, m, it):
|
||||
global _rule_component_clipboard
|
||||
|
|
@ -535,7 +501,7 @@ class ActionMenu:
|
|||
|
||||
def _menu_cut(self, m, it):
|
||||
menu_cut = Gtk.MenuItem(_("Cut"))
|
||||
menu_cut.connect(GtkSignal.ACTIVATE.value, self.menu_do_cut, m, it)
|
||||
menu_cut.connect("activate", self.menu_do_cut, m, it)
|
||||
menu_cut.show()
|
||||
return menu_cut
|
||||
|
||||
|
|
@ -545,19 +511,19 @@ class ActionMenu:
|
|||
c = _rule_component_clipboard
|
||||
_rule_component_clipboard = None
|
||||
if c:
|
||||
_rule_component_clipboard = diversion.RuleComponent().compile(c.data())
|
||||
_rule_component_clipboard = _DIV.RuleComponent().compile(c.data())
|
||||
self._menu_do_insert(_mitem, m, it, new_c=c, below=below)
|
||||
self._on_update()
|
||||
|
||||
def _menu_paste(self, m, it, below=False):
|
||||
menu_paste = Gtk.MenuItem(_("Paste"))
|
||||
menu_paste.connect(GtkSignal.ACTIVATE.value, self.menu_do_paste, m, it, below)
|
||||
menu_paste.connect("activate", self.menu_do_paste, m, it, below)
|
||||
menu_paste.show()
|
||||
return menu_paste
|
||||
|
||||
def _menu_copy(self, m, it):
|
||||
menu_copy = Gtk.MenuItem(_("Copy"))
|
||||
menu_copy.connect(GtkSignal.ACTIVATE.value, self.menu_do_copy, m, it)
|
||||
menu_copy.connect("activate", self.menu_do_copy, m, it)
|
||||
menu_copy.show()
|
||||
return menu_copy
|
||||
|
||||
|
|
@ -566,7 +532,7 @@ class DiversionDialog:
|
|||
def __init__(self, action_menu):
|
||||
window = Gtk.Window()
|
||||
window.set_title(_("Solaar Rule Editor"))
|
||||
window.connect(GtkSignal.DELETE_EVENT.value, self._closing)
|
||||
window.connect("delete-event", self._closing)
|
||||
vbox = Gtk.VBox()
|
||||
|
||||
self.top_panel, self.view = self._create_top_panel()
|
||||
|
|
@ -609,7 +575,7 @@ class DiversionDialog:
|
|||
|
||||
window.show_all()
|
||||
|
||||
window.connect(GtkSignal.DELETE_EVENT.value, lambda w, e: w.hide_on_delete() or True)
|
||||
window.connect("delete-event", lambda w, e: w.hide_on_delete() or True)
|
||||
|
||||
style = window.get_style_context()
|
||||
style.add_class("solaar")
|
||||
|
|
@ -638,13 +604,13 @@ class DiversionDialog:
|
|||
self.dirty = False
|
||||
for c in self.selected_rule_edit_panel.get_children():
|
||||
self.selected_rule_edit_panel.remove(c)
|
||||
diversion.load_config_rule_file()
|
||||
_DIV.load_config_rule_file()
|
||||
self.model = self._create_model()
|
||||
self.view.set_model(self.model)
|
||||
self.view.expand_all()
|
||||
|
||||
def _save_yaml_file(self):
|
||||
if diversion._save_config_rule_file():
|
||||
if _DIV._save_config_rule_file():
|
||||
self.dirty = False
|
||||
self.save_btn.set_sensitive(False)
|
||||
self.discard_btn.set_sensitive(False)
|
||||
|
|
@ -657,9 +623,9 @@ class DiversionDialog:
|
|||
view.set_enable_tree_lines(True)
|
||||
view.set_reorderable(False)
|
||||
|
||||
view.connect(GtkSignal.KEY_PRESS_EVENT.value, self._event_key_pressed)
|
||||
view.connect(GtkSignal.BUTTON_RELEASE_EVENT.value, self._event_button_released)
|
||||
view.get_selection().connect(GtkSignal.CHANGED.value, self._selection_changed)
|
||||
view.connect("key-press-event", self._event_key_pressed)
|
||||
view.connect("button-release-event", self._event_button_released)
|
||||
view.get_selection().connect("changed", self._selection_changed)
|
||||
sw.add(view)
|
||||
sw.set_size_request(0, 300) # don't ask for so much height
|
||||
|
||||
|
|
@ -674,8 +640,8 @@ class DiversionDialog:
|
|||
self.discard_btn.set_always_show_image(True)
|
||||
self.discard_btn.set_sensitive(False)
|
||||
self.discard_btn.set_valign(Gtk.Align.CENTER)
|
||||
self.save_btn.connect(GtkSignal.CLICKED.value, lambda *_args: self._save_yaml_file())
|
||||
self.discard_btn.connect(GtkSignal.CLICKED.value, lambda *_args: self._reload_yaml_file())
|
||||
self.save_btn.connect("clicked", lambda *_args: self._save_yaml_file())
|
||||
self.discard_btn.connect("clicked", lambda *_args: self._reload_yaml_file())
|
||||
button_box.pack_start(self.save_btn, False, False, 0)
|
||||
button_box.pack_start(self.discard_btn, False, False, 0)
|
||||
button_box.set_halign(Gtk.Align.CENTER)
|
||||
|
|
@ -690,10 +656,10 @@ class DiversionDialog:
|
|||
|
||||
def _create_model(self):
|
||||
model = Gtk.TreeStore(RuleComponentWrapper)
|
||||
if len(diversion.rules.components) == 1:
|
||||
if len(_DIV.rules.components) == 1:
|
||||
# only built-in rules - add empty user rule list
|
||||
diversion.rules.components.insert(0, diversion.Rule([], source=diversion._file_path))
|
||||
_populate_model(model, None, diversion.rules.components)
|
||||
_DIV.rules.components.insert(0, _DIV.Rule([], source=_DIV._file_path))
|
||||
_populate_model(model, None, _DIV.rules.components)
|
||||
return model
|
||||
|
||||
def _create_view_columns(self):
|
||||
|
|
@ -759,7 +725,7 @@ class DiversionDialog:
|
|||
)
|
||||
elif (
|
||||
enabled_actions.insert_only_rule
|
||||
and isinstance(_rule_component_clipboard, diversion.Rule)
|
||||
and isinstance(_rule_component_clipboard, _DIV.Rule)
|
||||
and e.keyval in [Gdk.KEY_v, Gdk.KEY_V]
|
||||
):
|
||||
self._action_menu.menu_do_paste(
|
||||
|
|
@ -767,7 +733,7 @@ class DiversionDialog:
|
|||
)
|
||||
elif (
|
||||
enabled_actions.insert_root
|
||||
and isinstance(_rule_component_clipboard, diversion.Rule)
|
||||
and isinstance(_rule_component_clipboard, _DIV.Rule)
|
||||
and e.keyval in [Gdk.KEY_v, Gdk.KEY_V]
|
||||
):
|
||||
self._action_menu.menu_do_paste(None, m, m.iter_nth_child(it, 0))
|
||||
|
|
@ -794,11 +760,11 @@ class DiversionDialog:
|
|||
if e.keyval == Gdk.KEY_exclam:
|
||||
self._action_menu.menu_do_negate(None, m, it)
|
||||
elif e.keyval == Gdk.KEY_ampersand:
|
||||
self._action_menu.menu_do_wrap(None, m, it, diversion.And)
|
||||
self._action_menu.menu_do_wrap(None, m, it, _DIV.And)
|
||||
elif e.keyval == Gdk.KEY_bar:
|
||||
self._action_menu.menu_do_wrap(None, m, it, diversion.Or)
|
||||
self._action_menu.menu_do_wrap(None, m, it, _DIV.Or)
|
||||
elif e.keyval in [Gdk.KEY_r, Gdk.KEY_R] and (state & Gdk.ModifierType.SHIFT_MASK):
|
||||
self._action_menu.menu_do_wrap(None, m, it, diversion.Rule)
|
||||
self._action_menu.menu_do_wrap(None, m, it, _DIV.Rule)
|
||||
if enabled_actions.flatten and e.keyval in [Gdk.KEY_asterisk, Gdk.KEY_KP_Multiply]:
|
||||
self._action_menu.menu_do_flatten(None, m, it)
|
||||
|
||||
|
|
@ -869,7 +835,7 @@ class SmartComboBox(Gtk.ComboBox):
|
|||
self._all_values = []
|
||||
self._blank = blank
|
||||
self._model = None
|
||||
self._completion = completion
|
||||
self._commpletion = completion
|
||||
self._case_insensitive = case_insensitive
|
||||
self._norm = lambda s: None if s is None else s if not case_insensitive else str(s).upper()
|
||||
self._replace_with_default_name = replace_with_default_name
|
||||
|
|
@ -881,7 +847,7 @@ class SmartComboBox(Gtk.ComboBox):
|
|||
if name != self.get_child().get_text():
|
||||
self.get_child().set_text(name)
|
||||
|
||||
self.connect(GtkSignal.CHANGED.value, lambda *a: replace_with(self.get_value(invalid_as_str=False)))
|
||||
self.connect("changed", lambda *a: replace_with(self.get_value(invalid_as_str=False)))
|
||||
|
||||
self.set_id_column(0)
|
||||
if self.get_has_entry():
|
||||
|
|
@ -921,7 +887,7 @@ class SmartComboBox(Gtk.ComboBox):
|
|||
if visible:
|
||||
to_complete += names if names else [str(value).strip()]
|
||||
self.set_model(filtered_model)
|
||||
if self.get_has_entry() and self._completion:
|
||||
if self.get_has_entry() and self._commpletion:
|
||||
CompletionEntry.add_completion_to_entry(self.get_child(), to_complete)
|
||||
if self._find_idx(old_value) is not None:
|
||||
self.set_value(old_value)
|
||||
|
|
@ -1101,19 +1067,16 @@ class UnsupportedRuleComponentUI(RuleComponentUI):
|
|||
|
||||
def create_widgets(self):
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.label.set_text(_("This editor does not support the selected rule component yet.") if self.component else "")
|
||||
self.label.set_text(_("This editor does not support the selected rule component yet."))
|
||||
self.widgets[self.label] = (0, 0, 1, 1)
|
||||
|
||||
def collect_value(self):
|
||||
return self.component.components[:] # not editable on the bottom panel
|
||||
|
||||
@classmethod
|
||||
def right_label(cls, component):
|
||||
return str(component)
|
||||
|
||||
|
||||
class RuleUI(RuleComponentUI):
|
||||
CLASS = diversion.Rule
|
||||
CLASS = _DIV.Rule
|
||||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
|
|
@ -1131,7 +1094,7 @@ class RuleUI(RuleComponentUI):
|
|||
|
||||
|
||||
class AndUI(RuleComponentUI):
|
||||
CLASS = diversion.And
|
||||
CLASS = _DIV.And
|
||||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
|
|
@ -1145,7 +1108,7 @@ class AndUI(RuleComponentUI):
|
|||
|
||||
|
||||
class OrUI(RuleComponentUI):
|
||||
CLASS = diversion.Or
|
||||
CLASS = _DIV.Or
|
||||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
|
|
@ -1159,7 +1122,7 @@ class OrUI(RuleComponentUI):
|
|||
|
||||
|
||||
class LaterUI(RuleComponentUI):
|
||||
CLASS = diversion.Later
|
||||
CLASS = _DIV.Later
|
||||
MIN_VALUE = 0.01
|
||||
MAX_VALUE = 100
|
||||
|
||||
|
|
@ -1173,7 +1136,7 @@ class LaterUI(RuleComponentUI):
|
|||
self.field.set_halign(Gtk.Align.CENTER)
|
||||
self.field.set_valign(Gtk.Align.CENTER)
|
||||
self.field.set_hexpand(True)
|
||||
self.field.connect(GtkSignal.VALUE_CHANGED.value, self._on_update)
|
||||
self.field.connect("value-changed", self._on_update)
|
||||
self.widgets[self.field] = (0, 1, 1, 1)
|
||||
|
||||
def show(self, component, editable):
|
||||
|
|
@ -1194,7 +1157,7 @@ class LaterUI(RuleComponentUI):
|
|||
|
||||
|
||||
class NotUI(RuleComponentUI):
|
||||
CLASS = diversion.Not
|
||||
CLASS = _DIV.Not
|
||||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
|
|
@ -1208,7 +1171,7 @@ class NotUI(RuleComponentUI):
|
|||
|
||||
|
||||
class ActionUI(RuleComponentUI):
|
||||
CLASS = diversion.Action
|
||||
CLASS = _DIV.Action
|
||||
|
||||
@classmethod
|
||||
def icon_name(cls):
|
||||
|
|
@ -1241,15 +1204,15 @@ class SetValueControl(Gtk.HBox):
|
|||
],
|
||||
case_insensitive=True,
|
||||
)
|
||||
self.toggle_widget.connect(GtkSignal.CHANGED.value, self._changed)
|
||||
self.toggle_widget.connect("changed", self._changed)
|
||||
self.range_widget = Gtk.SpinButton.new_with_range(0, 0xFFFF, 1)
|
||||
self.range_widget.connect(GtkSignal.VALUE_CHANGED.value, self._changed)
|
||||
self.range_widget.connect("value-changed", self._changed)
|
||||
self.choice_widget = SmartComboBox(
|
||||
[], completion=True, has_entry=True, case_insensitive=True, replace_with_default_name=True
|
||||
)
|
||||
self.choice_widget.connect(GtkSignal.CHANGED.value, self._changed)
|
||||
self.choice_widget.connect("changed", self._changed)
|
||||
self.sub_key_widget = SmartComboBox([])
|
||||
self.sub_key_widget.connect(GtkSignal.CHANGED.value, self._changed)
|
||||
self.sub_key_widget.connect("changed", self._changed)
|
||||
self.unsupported_label = Gtk.Label(label=_("Unsupported setting"))
|
||||
self.pack_start(self.sub_key_widget, False, False, 0)
|
||||
self.sub_key_widget.set_hexpand(False)
|
||||
|
|
@ -1372,6 +1335,25 @@ class SetValueControl(Gtk.HBox):
|
|||
self.unsupported_label.show()
|
||||
|
||||
|
||||
def _all_settings():
|
||||
settings = {}
|
||||
for s in sorted(SETTINGS, key=lambda setting: setting.label):
|
||||
if s.name not in settings:
|
||||
settings[s.name] = [s]
|
||||
else:
|
||||
prev_setting = settings[s.name][0]
|
||||
prev_kind = prev_setting.validator_class.kind
|
||||
if prev_kind != s.validator_class.kind:
|
||||
logger.warning(
|
||||
"ignoring setting {} - same name of {}, but different kind ({} != {})".format(
|
||||
s.__name__, prev_setting.__name__, prev_kind, s.validator_class.kind
|
||||
)
|
||||
)
|
||||
continue
|
||||
settings[s.name].append(s)
|
||||
return settings
|
||||
|
||||
|
||||
class _DeviceUI:
|
||||
label_text = ""
|
||||
|
||||
|
|
@ -1400,7 +1382,7 @@ class _DeviceUI:
|
|||
self.device_field.set_value("")
|
||||
self.device_field.set_valign(Gtk.Align.CENTER)
|
||||
self.device_field.set_size_request(400, 0)
|
||||
self.device_field.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.device_field.connect("changed", self._on_update)
|
||||
self.widgets[self.device_field] = (1, 1, 1, 1)
|
||||
|
||||
def update_devices(self):
|
||||
|
|
@ -1424,7 +1406,7 @@ class _DeviceUI:
|
|||
|
||||
|
||||
class ActiveUI(_DeviceUI, ConditionUI):
|
||||
CLASS = diversion.Active
|
||||
CLASS = _DIV.Active
|
||||
label_text = _("Device is active and its settings can be changed.")
|
||||
|
||||
@classmethod
|
||||
|
|
@ -1433,7 +1415,7 @@ class ActiveUI(_DeviceUI, ConditionUI):
|
|||
|
||||
|
||||
class DeviceUI(_DeviceUI, ConditionUI):
|
||||
CLASS = diversion.Device
|
||||
CLASS = _DIV.Device
|
||||
label_text = _("Device that originated the current notification.")
|
||||
|
||||
@classmethod
|
||||
|
|
@ -1442,7 +1424,7 @@ class DeviceUI(_DeviceUI, ConditionUI):
|
|||
|
||||
|
||||
class HostUI(ConditionUI):
|
||||
CLASS = diversion.Host
|
||||
CLASS = _DIV.Host
|
||||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
|
|
@ -1451,7 +1433,7 @@ class HostUI(ConditionUI):
|
|||
self.widgets[self.label] = (0, 0, 1, 1)
|
||||
self.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.field.set_size_request(600, 0)
|
||||
self.field.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.field.connect("changed", self._on_update)
|
||||
self.widgets[self.field] = (0, 1, 1, 1)
|
||||
|
||||
def show(self, component, editable):
|
||||
|
|
@ -1472,7 +1454,8 @@ class HostUI(ConditionUI):
|
|||
|
||||
|
||||
class _SettingWithValueUI:
|
||||
MULTIPLE = [Kind.MULTIPLE_TOGGLE, Kind.MAP_CHOICE, Kind.MULTIPLE_RANGE]
|
||||
ALL_SETTINGS = _all_settings()
|
||||
MULTIPLE = [_SKIND.multiple_toggle, _SKIND.map_choice, _SKIND.multiple_range]
|
||||
ACCEPT_TOGGLE = True
|
||||
|
||||
label_text = ""
|
||||
|
|
@ -1498,8 +1481,8 @@ class _SettingWithValueUI:
|
|||
self.device_field.set_valign(Gtk.Align.CENTER)
|
||||
self.device_field.set_size_request(400, 0)
|
||||
self.device_field.set_margin_top(m)
|
||||
self.device_field.connect(GtkSignal.CHANGED.value, self._changed_device)
|
||||
self.device_field.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.device_field.connect("changed", self._changed_device)
|
||||
self.device_field.connect("changed", self._on_update)
|
||||
self.widgets[self.device_field] = (1, 1, 1, 1)
|
||||
|
||||
lbl = Gtk.Label(
|
||||
|
|
@ -1510,10 +1493,10 @@ class _SettingWithValueUI:
|
|||
vexpand=False,
|
||||
)
|
||||
self.widgets[lbl] = (0, 2, 1, 1)
|
||||
self.setting_field = SmartComboBox([(s[0].name, s[0].label) for s in ALL_SETTINGS.values()])
|
||||
self.setting_field = SmartComboBox([(s[0].name, s[0].label) for s in self.ALL_SETTINGS.values()])
|
||||
self.setting_field.set_valign(Gtk.Align.CENTER)
|
||||
self.setting_field.connect(GtkSignal.CHANGED.value, self._changed_setting)
|
||||
self.setting_field.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.setting_field.connect("changed", self._changed_setting)
|
||||
self.setting_field.connect("changed", self._on_update)
|
||||
self.widgets[self.setting_field] = (1, 2, 1, 1)
|
||||
|
||||
self.value_lbl = Gtk.Label(
|
||||
|
|
@ -1536,8 +1519,8 @@ class _SettingWithValueUI:
|
|||
self.key_field.set_margin_top(m)
|
||||
self.key_field.hide()
|
||||
self.key_field.set_valign(Gtk.Align.CENTER)
|
||||
self.key_field.connect(GtkSignal.CHANGED.value, self._changed_key)
|
||||
self.key_field.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.key_field.connect("changed", self._changed_key)
|
||||
self.key_field.connect("changed", self._on_update)
|
||||
self.widgets[self.key_field] = (3, 1, 1, 1)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -1563,7 +1546,7 @@ class _SettingWithValueUI:
|
|||
if extra is not None:
|
||||
choices |= NamedInts(**{str(extra): int(extra)})
|
||||
return choices, extra
|
||||
settings = ALL_SETTINGS.get(setting, [])
|
||||
settings = cls.ALL_SETTINGS.get(setting, [])
|
||||
choices = UnsortedNamedInts()
|
||||
extra = None
|
||||
for s in settings:
|
||||
|
|
@ -1579,14 +1562,14 @@ class _SettingWithValueUI:
|
|||
setting = device.settings.get(setting_name, None)
|
||||
settings = [type(setting)] if setting else None
|
||||
else:
|
||||
settings = ALL_SETTINGS.get(setting_name, [None])
|
||||
settings = cls.ALL_SETTINGS.get(setting_name, [None])
|
||||
setting = settings[0] # if settings have the same name, use the first one to get the basic data
|
||||
val_class = setting.validator_class if setting else None
|
||||
kind = val_class.kind if val_class else None
|
||||
if kind in cls.MULTIPLE:
|
||||
keys = UnsortedNamedInts()
|
||||
for s in settings:
|
||||
universe = getattr(s, "keys_universe" if kind == Kind.MAP_CHOICE else "choices_universe", None)
|
||||
universe = getattr(s, "keys_universe" if kind == _SKIND.map_choice else "choices_universe", None)
|
||||
if universe:
|
||||
keys |= universe
|
||||
# only one key per number is used
|
||||
|
|
@ -1658,12 +1641,12 @@ class _SettingWithValueUI:
|
|||
supported_keys = None
|
||||
if device_setting:
|
||||
val = device_setting._validator
|
||||
if device_setting.kind == Kind.MULTIPLE_TOGGLE:
|
||||
if device_setting.kind == _SKIND.multiple_toggle:
|
||||
supported_keys = val.get_options() or None
|
||||
elif device_setting.kind == Kind.MAP_CHOICE:
|
||||
elif device_setting.kind == _SKIND.map_choice:
|
||||
choices = val.choices or None
|
||||
supported_keys = choices.keys() if choices else None
|
||||
elif device_setting.kind == Kind.MULTIPLE_RANGE:
|
||||
elif device_setting.kind == _SKIND.multiple_range:
|
||||
supported_keys = val.keys
|
||||
self.key_field.show_only(supported_keys, include_new=True)
|
||||
self._update_validation()
|
||||
|
|
@ -1672,24 +1655,24 @@ class _SettingWithValueUI:
|
|||
setting, val_class, kind, keys = self._setting_attributes(setting_name, device)
|
||||
ds = device.settings if device else {}
|
||||
device_setting = ds.get(setting_name, None)
|
||||
if kind in (Kind.TOGGLE, Kind.MULTIPLE_TOGGLE):
|
||||
if kind in (_SKIND.toggle, _SKIND.multiple_toggle):
|
||||
self.value_field.make_toggle()
|
||||
elif kind in (Kind.CHOICE, Kind.MAP_CHOICE):
|
||||
elif kind in (_SKIND.choice, _SKIND.map_choice):
|
||||
all_values, extra = self._all_choices(device_setting or setting_name)
|
||||
self.value_field.make_choice(all_values, extra)
|
||||
supported_values = None
|
||||
if device_setting:
|
||||
val = device_setting._validator
|
||||
choices = getattr(val, "choices", None) or None
|
||||
if kind == Kind.CHOICE:
|
||||
if kind == _SKIND.choice:
|
||||
supported_values = choices
|
||||
elif kind == Kind.MAP_CHOICE and isinstance(choices, dict):
|
||||
elif kind == _SKIND.map_choice and isinstance(choices, dict):
|
||||
supported_values = choices.get(key, None) or None
|
||||
self.value_field.choice_widget.show_only(supported_values, include_new=True)
|
||||
self._update_validation()
|
||||
elif kind == Kind.RANGE:
|
||||
elif kind == _SKIND.range:
|
||||
self.value_field.make_range(val_class.min_value, val_class.max_value)
|
||||
elif kind == Kind.MULTIPLE_RANGE:
|
||||
elif kind == _SKIND.multiple_range:
|
||||
self.value_field.make_range_with_key(
|
||||
getattr(setting, "sub_items_universe", {}).get(key, {}) if setting else {},
|
||||
getattr(setting, "_labels_sub", None) if setting else None,
|
||||
|
|
@ -1720,7 +1703,7 @@ class _SettingWithValueUI:
|
|||
key = self.key_field.get_value(invalid_as_str=False, accept_hidden=False)
|
||||
icon = "dialog-warning" if key is None else ""
|
||||
self.key_field.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
|
||||
if kind in (Kind.CHOICE, Kind.MAP_CHOICE):
|
||||
if kind in (_SKIND.choice, _SKIND.map_choice):
|
||||
value = self.value_field.choice_widget.get_value(invalid_as_str=False, accept_hidden=False)
|
||||
icon = "dialog-warning" if value is None else ""
|
||||
self.value_field.choice_widget.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
|
||||
|
|
@ -1775,26 +1758,26 @@ class _SettingWithValueUI:
|
|||
key_label = getattr(setting, "_labels", {}).get(key, [None])[0] if setting else None
|
||||
disp.append(key_label or key)
|
||||
value = next(a, None)
|
||||
if setting and (kind in (Kind.CHOICE, Kind.MAP_CHOICE)):
|
||||
if setting and (kind in (_SKIND.choice, _SKIND.map_choice)):
|
||||
all_values = cls._all_choices(setting or setting_name)[0]
|
||||
supported_values = None
|
||||
if device_setting:
|
||||
val = device_setting._validator
|
||||
choices = getattr(val, "choices", None) or None
|
||||
if kind == Kind.CHOICE:
|
||||
if kind == _SKIND.choice:
|
||||
supported_values = choices
|
||||
elif kind == Kind.MAP_CHOICE and isinstance(choices, dict):
|
||||
elif kind == _SKIND.map_choice and isinstance(choices, dict):
|
||||
supported_values = choices.get(key, None) or None
|
||||
if supported_values and isinstance(supported_values, NamedInts):
|
||||
value = supported_values[value]
|
||||
if not supported_values and all_values and isinstance(all_values, NamedInts):
|
||||
value = all_values[value]
|
||||
disp.append(value)
|
||||
elif kind == Kind.MULTIPLE_RANGE and isinstance(value, dict) and len(value) == 1:
|
||||
elif kind == _SKIND.multiple_range and isinstance(value, dict) and len(value) == 1:
|
||||
k, v = next(iter(value.items()))
|
||||
k = (getattr(setting, "_labels_sub", {}).get(k, (None,))[0] if setting else None) or k
|
||||
disp.append(f"{k}={v}")
|
||||
elif kind in (Kind.TOGGLE, Kind.MULTIPLE_TOGGLE):
|
||||
elif kind in (_SKIND.toggle, _SKIND.multiple_toggle):
|
||||
disp.append(_(str(value)))
|
||||
else:
|
||||
disp.append(value)
|
||||
|
|
@ -1802,7 +1785,7 @@ class _SettingWithValueUI:
|
|||
|
||||
|
||||
class SetUI(_SettingWithValueUI, ActionUI):
|
||||
CLASS = diversion.Set
|
||||
CLASS = _DIV.Set
|
||||
ACCEPT_TOGGLE = True
|
||||
|
||||
label_text = _("Change setting on device")
|
||||
|
|
@ -1818,7 +1801,7 @@ class SetUI(_SettingWithValueUI, ActionUI):
|
|||
|
||||
|
||||
class SettingUI(_SettingWithValueUI, ConditionUI):
|
||||
CLASS = diversion.Setting
|
||||
CLASS = _DIV.Setting
|
||||
ACCEPT_TOGGLE = False
|
||||
|
||||
label_text = _("Setting on device")
|
||||
|
|
@ -1833,32 +1816,32 @@ class SettingUI(_SettingWithValueUI, ConditionUI):
|
|||
_SettingWithValueUI._on_update(self, *_args)
|
||||
|
||||
|
||||
COMPONENT_UI: dict[Any, RuleComponentUI] = {
|
||||
diversion.Rule: RuleUI,
|
||||
diversion.Not: NotUI,
|
||||
diversion.Or: OrUI,
|
||||
diversion.And: AndUI,
|
||||
diversion.Later: LaterUI,
|
||||
diversion.Process: rule_conditions.ProcessUI,
|
||||
diversion.MouseProcess: rule_conditions.MouseProcessUI,
|
||||
diversion.Active: ActiveUI,
|
||||
diversion.Device: DeviceUI,
|
||||
diversion.Host: HostUI,
|
||||
diversion.Feature: rule_conditions.FeatureUI,
|
||||
diversion.Report: rule_conditions.ReportUI,
|
||||
diversion.Modifiers: rule_conditions.ModifiersUI,
|
||||
diversion.Key: rule_conditions.KeyUI,
|
||||
diversion.KeyIsDown: rule_conditions.KeyIsDownUI,
|
||||
diversion.Test: rule_conditions.TestUI,
|
||||
diversion.TestBytes: rule_conditions.TestBytesUI,
|
||||
diversion.Setting: SettingUI,
|
||||
diversion.MouseGesture: rule_conditions.MouseGestureUI,
|
||||
diversion.KeyPress: rule_actions.KeyPressUI,
|
||||
diversion.MouseScroll: rule_actions.MouseScrollUI,
|
||||
diversion.MouseClick: rule_actions.MouseClickUI,
|
||||
diversion.Execute: rule_actions.ExecuteUI,
|
||||
diversion.Set: SetUI,
|
||||
# type(None): RuleComponentUI, # placeholders for empty rule/And/Or
|
||||
COMPONENT_UI = {
|
||||
_DIV.Rule: RuleUI,
|
||||
_DIV.Not: NotUI,
|
||||
_DIV.Or: OrUI,
|
||||
_DIV.And: AndUI,
|
||||
_DIV.Later: LaterUI,
|
||||
_DIV.Process: rule_conditions.ProcessUI,
|
||||
_DIV.MouseProcess: rule_conditions.MouseProcessUI,
|
||||
_DIV.Active: ActiveUI,
|
||||
_DIV.Device: DeviceUI,
|
||||
_DIV.Host: HostUI,
|
||||
_DIV.Feature: rule_conditions.FeatureUI,
|
||||
_DIV.Report: rule_conditions.ReportUI,
|
||||
_DIV.Modifiers: rule_conditions.ModifiersUI,
|
||||
_DIV.Key: rule_conditions.KeyUI,
|
||||
_DIV.KeyIsDown: rule_conditions.KeyIsDownUI,
|
||||
_DIV.Test: rule_conditions.TestUI,
|
||||
_DIV.TestBytes: rule_conditions.TestBytesUI,
|
||||
_DIV.Setting: SettingUI,
|
||||
_DIV.MouseGesture: rule_conditions.MouseGestureUI,
|
||||
_DIV.KeyPress: rule_actions.KeyPressUI,
|
||||
_DIV.MouseScroll: rule_actions.MouseScrollUI,
|
||||
_DIV.MouseClick: rule_actions.MouseClickUI,
|
||||
_DIV.Execute: rule_actions.ExecuteUI,
|
||||
_DIV.Set: SetUI,
|
||||
type(None): RuleComponentUI, # placeholders for empty rule/And/Or
|
||||
}
|
||||
|
||||
_all_devices = AllDevicesInfo()
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ def _init_icon_paths():
|
|||
return
|
||||
|
||||
_default_theme = Gtk.IconTheme.get_default()
|
||||
logger.debug("icon theme paths: %s", _default_theme.get_search_path())
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("icon theme paths: %s", _default_theme.get_search_path())
|
||||
|
||||
if gtk.battery_icons_style == "symbolic":
|
||||
global TRAY_OKAY
|
||||
|
|
@ -56,7 +57,8 @@ 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
|
||||
logger.debug("battery icon for %s:%s = %s", level, charging, icon_name)
|
||||
elif logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("battery icon for %s:%s = %s", level, charging, icon_name)
|
||||
return icon_name
|
||||
|
||||
|
||||
|
|
@ -103,7 +105,7 @@ def device_icon_set(name="_", kind=None):
|
|||
icon_set += ("input-mouse",)
|
||||
elif str(kind) == "headset":
|
||||
icon_set += ("audio-headphones", "audio-headset")
|
||||
icon_set += (f"input-{str(kind)}",)
|
||||
icon_set += ("input-" + str(kind),)
|
||||
# icon_set += (name.replace(' ', '-'),)
|
||||
_ICON_SETS[name] = icon_set
|
||||
return icon_set
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@
|
|||
|
||||
import logging
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gtk
|
||||
from logitech_receiver import hidpp10_constants
|
||||
|
|
@ -34,11 +32,6 @@ _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}
|
||||
|
|
@ -99,7 +92,8 @@ def prepare(receiver):
|
|||
|
||||
def check_lock_state(assistant, receiver, count=2):
|
||||
if not assistant.is_drawable():
|
||||
logger.debug("assistant %s destroyed, bailing out", assistant)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("assistant %s destroyed, bailing out", assistant)
|
||||
return False
|
||||
return _check_lock_state(assistant, receiver, count)
|
||||
|
||||
|
|
@ -135,18 +129,21 @@ def _check_lock_state(assistant, receiver, count):
|
|||
|
||||
def _pairing_failed(assistant, receiver, error):
|
||||
assistant.remove_page(0) # needed to reset the window size
|
||||
logger.debug("%s fail: %s", receiver, error)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
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
|
||||
logger.debug("%s success: %s", receiver, device)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s success: %s", receiver, device)
|
||||
_create_success_page(assistant, device)
|
||||
|
||||
|
||||
def _finish(assistant, receiver):
|
||||
logger.debug("finish %s", assistant)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("finish %s", assistant)
|
||||
assistant.destroy()
|
||||
receiver.pairing.new_device = None
|
||||
if receiver.pairing.lock_open:
|
||||
|
|
@ -161,7 +158,8 @@ def _finish(assistant, receiver):
|
|||
|
||||
|
||||
def _show_passcode(assistant, receiver, passkey):
|
||||
logger.debug("%s show passkey: %s", receiver, passkey)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
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}
|
||||
|
|
@ -209,8 +207,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(GtkSignal.CANCEL.value, finish, receiver)
|
||||
assistant.connect(GtkSignal.CLOSE.value, finish, receiver)
|
||||
assistant.connect("cancel", finish, receiver)
|
||||
assistant.connect("close", finish, receiver)
|
||||
return assistant
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
from enum import Enum
|
||||
|
||||
from shlex import quote as shlex_quote
|
||||
|
||||
from gi.repository import Gtk
|
||||
|
|
@ -29,12 +29,6 @@ from solaar.ui.rule_base import CompletionEntry
|
|||
from solaar.ui.rule_base import RuleComponentUI
|
||||
|
||||
|
||||
class GtkSignal(Enum):
|
||||
CHANGED = "changed"
|
||||
CLICKED = "clicked"
|
||||
TOGGLED = "toggled"
|
||||
|
||||
|
||||
class ActionUI(RuleComponentUI):
|
||||
CLASS = diversion.Action
|
||||
|
||||
|
|
@ -53,27 +47,25 @@ class KeyPressUI(ActionUI):
|
|||
self.label = Gtk.Label(
|
||||
label=_("Simulate a chorded key click or depress or release.\nOn Wayland requires write access to /dev/uinput."),
|
||||
halign=Gtk.Align.CENTER,
|
||||
justify=Gtk.Justification.CENTER,
|
||||
)
|
||||
self.widgets[self.label] = (0, 0, 5, 1)
|
||||
self.del_btns = []
|
||||
self.add_btn = Gtk.Button(label=_("Add key"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
|
||||
self.add_btn.connect(GtkSignal.CLICKED.value, self._clicked_add)
|
||||
self.add_btn.connect("clicked", self._clicked_add)
|
||||
self.widgets[self.add_btn] = (1, 1, 1, 1)
|
||||
self.action_clicked_radio = Gtk.RadioButton.new_with_label_from_widget(None, _("Click"))
|
||||
self.action_clicked_radio.connect(GtkSignal.TOGGLED.value, self._on_update, CLICK)
|
||||
self.action_clicked_radio.connect("toggled", self._on_update, CLICK)
|
||||
self.widgets[self.action_clicked_radio] = (0, 3, 1, 1)
|
||||
self.action_pressed_radio = Gtk.RadioButton.new_with_label_from_widget(self.action_clicked_radio, _("Depress"))
|
||||
self.action_pressed_radio.connect(GtkSignal.TOGGLED.value, self._on_update, DEPRESS)
|
||||
self.action_pressed_radio.connect("toggled", self._on_update, DEPRESS)
|
||||
self.widgets[self.action_pressed_radio] = (1, 3, 1, 1)
|
||||
self.action_released_radio = Gtk.RadioButton.new_with_label_from_widget(self.action_pressed_radio, _("Release"))
|
||||
self.action_released_radio.connect(GtkSignal.TOGGLED.value, self._on_update, RELEASE)
|
||||
self.action_released_radio.connect("toggled", self._on_update, RELEASE)
|
||||
self.widgets[self.action_released_radio] = (2, 3, 1, 1)
|
||||
|
||||
def _create_field(self):
|
||||
field_entry = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
|
||||
field_entry.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
field_entry.set_size_request(250, -1)
|
||||
field_entry.connect("changed", self._on_update)
|
||||
self.fields.append(field_entry)
|
||||
self.widgets[field_entry] = (len(self.fields) - 1, 1, 1, 1)
|
||||
return field_entry
|
||||
|
|
@ -82,7 +74,7 @@ class KeyPressUI(ActionUI):
|
|||
btn = Gtk.Button(label=_("Delete"), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True)
|
||||
self.del_btns.append(btn)
|
||||
self.widgets[btn] = (len(self.del_btns) - 1, 2, 1, 1)
|
||||
btn.connect(GtkSignal.CLICKED.value, self._clicked_del, len(self.del_btns) - 1)
|
||||
btn.connect("clicked", self._clicked_del, len(self.del_btns) - 1)
|
||||
return btn
|
||||
|
||||
def _clicked_add(self, _btn):
|
||||
|
|
@ -121,6 +113,7 @@ class KeyPressUI(ActionUI):
|
|||
field_entry = self.fields[i]
|
||||
with self.ignore_changes():
|
||||
field_entry.set_text(component.key_names[i])
|
||||
field_entry.set_size_request(int(0.3 * self.panel.get_toplevel().get_size()[0]), 0)
|
||||
field_entry.show_all()
|
||||
self.del_btns[i].show()
|
||||
for i in range(n, len(self.fields)):
|
||||
|
|
@ -139,7 +132,7 @@ class KeyPressUI(ActionUI):
|
|||
|
||||
@classmethod
|
||||
def right_label(cls, component):
|
||||
return " + ".join(component.key_names) + (f" ({component.action})" if component.action != CLICK else "")
|
||||
return " + ".join(component.key_names) + (" (" + component.action + ")" if component.action != CLICK else "")
|
||||
|
||||
|
||||
class MouseScrollUI(ActionUI):
|
||||
|
|
@ -150,9 +143,7 @@ class MouseScrollUI(ActionUI):
|
|||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
self.label = Gtk.Label(
|
||||
label=_("Simulate a mouse scroll.\nOn Wayland requires write access to /dev/uinput."),
|
||||
halign=Gtk.Align.CENTER,
|
||||
justify=Gtk.Justification.CENTER,
|
||||
label=_("Simulate a mouse scroll.\nOn Wayland requires write access to /dev/uinput."), halign=Gtk.Align.CENTER
|
||||
)
|
||||
self.widgets[self.label] = (0, 0, 4, 1)
|
||||
self.label_x = Gtk.Label(label="x", halign=Gtk.Align.END, valign=Gtk.Align.END, hexpand=True)
|
||||
|
|
@ -162,8 +153,8 @@ class MouseScrollUI(ActionUI):
|
|||
for f in [self.field_x, self.field_y]:
|
||||
f.set_halign(Gtk.Align.CENTER)
|
||||
f.set_valign(Gtk.Align.START)
|
||||
self.field_x.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.field_y.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.field_x.connect("changed", self._on_update)
|
||||
self.field_y.connect("changed", self._on_update)
|
||||
self.widgets[self.label_x] = (0, 1, 1, 1)
|
||||
self.widgets[self.field_x] = (1, 1, 1, 1)
|
||||
self.widgets[self.label_y] = (2, 1, 1, 1)
|
||||
|
|
@ -208,9 +199,7 @@ class MouseClickUI(ActionUI):
|
|||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
self.label = Gtk.Label(
|
||||
label=_("Simulate a mouse click.\nOn Wayland requires write access to /dev/uinput."),
|
||||
halign=Gtk.Align.CENTER,
|
||||
justify=Gtk.Justification.CENTER,
|
||||
label=_("Simulate a mouse click.\nOn Wayland requires write access to /dev/uinput."), halign=Gtk.Align.CENTER
|
||||
)
|
||||
self.widgets[self.label] = (0, 0, 4, 1)
|
||||
self.label_b = Gtk.Label(label=_("Button"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True)
|
||||
|
|
@ -221,9 +210,9 @@ class MouseClickUI(ActionUI):
|
|||
for f in [self.field_b, self.field_c]:
|
||||
f.set_halign(Gtk.Align.CENTER)
|
||||
f.set_valign(Gtk.Align.START)
|
||||
self.field_b.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.field_c.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.field_d.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.field_b.connect("changed", self._on_update)
|
||||
self.field_c.connect("changed", self._on_update)
|
||||
self.field_d.connect("changed", self._on_update)
|
||||
self.widgets[self.label_b] = (0, 1, 1, 1)
|
||||
self.widgets[self.field_b] = (1, 1, 1, 1)
|
||||
self.widgets[self.label_c] = (2, 1, 1, 1)
|
||||
|
|
@ -263,20 +252,18 @@ class ExecuteUI(ActionUI):
|
|||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
self.label = Gtk.Label(
|
||||
label=_("Execute a command with arguments."), halign=Gtk.Align.CENTER, justify=Gtk.Justification.CENTER
|
||||
)
|
||||
self.label = Gtk.Label(label=_("Execute a command with arguments."), halign=Gtk.Align.CENTER)
|
||||
self.widgets[self.label] = (0, 0, 5, 1)
|
||||
self.fields = []
|
||||
self.add_btn = Gtk.Button(label=_("Add argument"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
|
||||
self.del_btns = []
|
||||
self.add_btn.connect(GtkSignal.CLICKED.value, self._clicked_add)
|
||||
self.add_btn.connect("clicked", self._clicked_add)
|
||||
self.widgets[self.add_btn] = (1, 1, 1, 1)
|
||||
|
||||
def _create_field(self):
|
||||
field_entry = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
|
||||
field_entry.set_size_request(150, 0)
|
||||
field_entry.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
field_entry.connect("changed", self._on_update)
|
||||
self.fields.append(field_entry)
|
||||
self.widgets[field_entry] = (len(self.fields) - 1, 1, 1, 1)
|
||||
return field_entry
|
||||
|
|
@ -286,7 +273,7 @@ class ExecuteUI(ActionUI):
|
|||
btn.set_size_request(150, 0)
|
||||
self.del_btns.append(btn)
|
||||
self.widgets[btn] = (len(self.del_btns) - 1, 2, 1, 1)
|
||||
btn.connect(GtkSignal.CLICKED.value, self._clicked_del, len(self.del_btns) - 1)
|
||||
btn.connect("clicked", self._clicked_del, len(self.del_btns) - 1)
|
||||
return btn
|
||||
|
||||
def _clicked_add(self, *_args):
|
||||
|
|
|
|||
|
|
@ -13,10 +13,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.
|
||||
import abc
|
||||
|
||||
from contextlib import contextmanager as contextlib_contextmanager
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
|
||||
from gi.repository import Gtk
|
||||
|
|
@ -49,7 +47,7 @@ class CompletionEntry(Gtk.Entry):
|
|||
liststore.append((v,))
|
||||
|
||||
|
||||
class RuleComponentUI(abc.ABC):
|
||||
class RuleComponentUI:
|
||||
CLASS = diversion.RuleComponent
|
||||
|
||||
def __init__(self, panel, on_update: Callable = None):
|
||||
|
|
@ -60,17 +58,15 @@ class RuleComponentUI(abc.ABC):
|
|||
self._on_update_callback = (lambda: None) if on_update is None else on_update
|
||||
self.create_widgets()
|
||||
|
||||
@abc.abstractmethod
|
||||
def create_widgets(self) -> dict:
|
||||
def create_widgets(self):
|
||||
pass
|
||||
|
||||
def show(self, component, editable=True):
|
||||
self._show_widgets(editable)
|
||||
self.component = component
|
||||
|
||||
@abc.abstractmethod
|
||||
def collect_value(self) -> Any:
|
||||
pass
|
||||
def collect_value(self):
|
||||
return None
|
||||
|
||||
@contextlib_contextmanager
|
||||
def ignore_changes(self):
|
||||
|
|
@ -109,5 +105,5 @@ class RuleComponentUI(abc.ABC):
|
|||
for c in self.panel.get_children():
|
||||
self.panel.remove(c)
|
||||
|
||||
def update_devices(self): # noqa: B027
|
||||
def update_devices(self):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from gi.repository import Gtk
|
||||
from logitech_receiver import diversion
|
||||
|
|
@ -27,14 +26,6 @@ from solaar.ui.rule_base import CompletionEntry
|
|||
from solaar.ui.rule_base import RuleComponentUI
|
||||
|
||||
|
||||
class GtkSignal(Enum):
|
||||
CHANGED = "changed"
|
||||
CLICKED = "clicked"
|
||||
NOTIFY_ACTIVE = "notify::active"
|
||||
TOGGLED = "toggled"
|
||||
VALUE_CHANGED = "value-changed"
|
||||
|
||||
|
||||
class ConditionUI(RuleComponentUI):
|
||||
CLASS = diversion.Condition
|
||||
|
||||
|
|
@ -48,12 +39,12 @@ class ProcessUI(ConditionUI):
|
|||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.label.set_text(_("X11 active process. For use in X11 only."))
|
||||
self.widgets[self.label] = (0, 0, 1, 1)
|
||||
self.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.field.set_size_request(600, 0)
|
||||
self.field.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.field.connect("changed", self._on_update)
|
||||
self.widgets[self.field] = (0, 1, 1, 1)
|
||||
|
||||
def show(self, component, editable=True):
|
||||
|
|
@ -78,12 +69,12 @@ class MouseProcessUI(ConditionUI):
|
|||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.label.set_text(_("X11 mouse process. For use in X11 only."))
|
||||
self.widgets[self.label] = (0, 0, 1, 1)
|
||||
self.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.field.set_size_request(600, 0)
|
||||
self.field.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.field.connect("changed", self._on_update)
|
||||
self.widgets[self.field] = (0, 1, 1, 1)
|
||||
|
||||
def show(self, component, editable=True):
|
||||
|
|
@ -119,7 +110,7 @@ class FeatureUI(ConditionUI):
|
|||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.label.set_text(_("Feature name of notification triggering rule processing."))
|
||||
self.widgets[self.label] = (0, 0, 1, 1)
|
||||
self.field = Gtk.ComboBoxText.new_with_entry()
|
||||
|
|
@ -128,7 +119,7 @@ class FeatureUI(ConditionUI):
|
|||
self.field.append(feature, feature)
|
||||
self.field.set_valign(Gtk.Align.CENTER)
|
||||
self.field.set_size_request(600, 0)
|
||||
self.field.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.field.connect("changed", self._on_update)
|
||||
all_features = [str(f) for f in SupportedFeature]
|
||||
CompletionEntry.add_completion_to_entry(self.field.get_child(), all_features)
|
||||
self.widgets[self.field] = (0, 1, 1, 1)
|
||||
|
|
@ -165,14 +156,14 @@ class ReportUI(ConditionUI):
|
|||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.label.set_text(_("Report number of notification triggering rule processing."))
|
||||
self.widgets[self.label] = (0, 0, 1, 1)
|
||||
self.field = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1)
|
||||
self.field.set_halign(Gtk.Align.CENTER)
|
||||
self.field.set_valign(Gtk.Align.CENTER)
|
||||
self.field.set_hexpand(True)
|
||||
self.field.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.field.connect("changed", self._on_update)
|
||||
self.widgets[self.field] = (0, 1, 1, 1)
|
||||
|
||||
def show(self, component, editable=True):
|
||||
|
|
@ -197,7 +188,7 @@ class ModifiersUI(ConditionUI):
|
|||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.label.set_text(_("Active keyboard modifiers. Not always available in Wayland."))
|
||||
self.widgets[self.label] = (0, 0, 5, 1)
|
||||
self.labels = {}
|
||||
|
|
@ -209,7 +200,7 @@ class ModifiersUI(ConditionUI):
|
|||
self.widgets[switch] = (i, 2, 1, 1)
|
||||
self.labels[m] = label
|
||||
self.switches[m] = switch
|
||||
switch.connect(GtkSignal.NOTIFY_ACTIVE.value, self._on_update)
|
||||
switch.connect("notify::active", self._on_update)
|
||||
|
||||
def show(self, component, editable=True):
|
||||
super().show(component, editable)
|
||||
|
|
@ -235,7 +226,7 @@ class KeyUI(ConditionUI):
|
|||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.label.set_text(
|
||||
_(
|
||||
"Diverted key or button depressed or released.\n"
|
||||
|
|
@ -245,13 +236,13 @@ class KeyUI(ConditionUI):
|
|||
self.widgets[self.label] = (0, 0, 5, 1)
|
||||
self.key_field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.key_field.set_size_request(600, 0)
|
||||
self.key_field.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.key_field.connect("changed", self._on_update)
|
||||
self.widgets[self.key_field] = (0, 1, 2, 1)
|
||||
self.action_pressed_radio = Gtk.RadioButton.new_with_label_from_widget(None, _("Key down"))
|
||||
self.action_pressed_radio.connect(GtkSignal.TOGGLED.value, self._on_update, Key.DOWN)
|
||||
self.action_pressed_radio.connect("toggled", self._on_update, Key.DOWN)
|
||||
self.widgets[self.action_pressed_radio] = (2, 1, 1, 1)
|
||||
self.action_released_radio = Gtk.RadioButton.new_with_label_from_widget(self.action_pressed_radio, _("Key up"))
|
||||
self.action_released_radio.connect(GtkSignal.TOGGLED.value, self._on_update, Key.UP)
|
||||
self.action_released_radio.connect("toggled", self._on_update, Key.UP)
|
||||
self.widgets[self.action_released_radio] = (3, 1, 1, 1)
|
||||
|
||||
def show(self, component, editable=True):
|
||||
|
|
@ -287,7 +278,7 @@ class KeyIsDownUI(ConditionUI):
|
|||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.label.set_text(
|
||||
_(
|
||||
"Diverted key or button is currently down.\n"
|
||||
|
|
@ -297,7 +288,7 @@ class KeyIsDownUI(ConditionUI):
|
|||
self.widgets[self.label] = (0, 0, 5, 1)
|
||||
self.key_field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.key_field.set_size_request(600, 0)
|
||||
self.key_field.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.key_field.connect("changed", self._on_update)
|
||||
self.widgets[self.key_field] = (0, 1, 1, 1)
|
||||
|
||||
def show(self, component, editable=True):
|
||||
|
|
@ -327,7 +318,7 @@ class TestUI(ConditionUI):
|
|||
|
||||
def create_widgets(self):
|
||||
self.widgets = {}
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.label.set_text(_("Test condition on notification triggering rule processing."))
|
||||
self.widgets[self.label] = (0, 0, 4, 1)
|
||||
lbl = Gtk.Label(label=_("Test"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=False, vexpand=False)
|
||||
|
|
@ -344,12 +335,12 @@ class TestUI(ConditionUI):
|
|||
self.test.set_hexpand(False)
|
||||
self.test.set_size_request(300, 0)
|
||||
CompletionEntry.add_completion_to_entry(self.test.get_child(), diversion.TESTS)
|
||||
self.test.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.test.connect("changed", self._on_update)
|
||||
self.widgets[self.test] = (1, 1, 1, 1)
|
||||
|
||||
self.parameter = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
|
||||
self.parameter.set_size_request(150, 0)
|
||||
self.parameter.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
self.parameter.connect("changed", self._on_update)
|
||||
self.widgets[self.parameter] = (3, 1, 1, 1)
|
||||
|
||||
def show(self, component, editable=True):
|
||||
|
|
@ -383,7 +374,7 @@ class TestUI(ConditionUI):
|
|||
|
||||
@classmethod
|
||||
def right_label(cls, component):
|
||||
return component.test + (f" {repr(component.parameter)}" if component.parameter is not None else "")
|
||||
return component.test + (" " + repr(component.parameter) if component.parameter is not None else "")
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -433,7 +424,7 @@ class TestBytesUI(ConditionUI):
|
|||
self.fields = {}
|
||||
self.field_labels = {}
|
||||
self.widgets = {}
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
|
||||
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
|
||||
self.label.set_text(_("Bit or range test on bytes in notification message triggering rule processing."))
|
||||
self.widgets[self.label] = (0, 0, 5, 1)
|
||||
col = 0
|
||||
|
|
@ -453,14 +444,14 @@ class TestBytesUI(ConditionUI):
|
|||
field = Gtk.SpinButton.new_with_range(element.min, element.max, 1)
|
||||
field.set_value(0)
|
||||
field.set_size_request(150, 0)
|
||||
field.connect(GtkSignal.VALUE_CHANGED.value, self._on_update)
|
||||
field.connect("value-changed", self._on_update)
|
||||
label = Gtk.Label(label=element.label, margin_top=20)
|
||||
self.fields[element.id] = field
|
||||
self.field_labels[element.id] = label
|
||||
self.widgets[label] = (col, 1, 1, 1)
|
||||
self.widgets[field] = (col, 2, 1, 1)
|
||||
col += 1 if col != mode_col - 1 else 2
|
||||
self.mode_field.connect(GtkSignal.CHANGED.value, lambda cb: (self._on_update(), self._only_mode(cb.get_active_id())))
|
||||
self.mode_field.connect("changed", lambda cb: (self._on_update(), self._only_mode(cb.get_active_id())))
|
||||
self.mode_field.set_active_id("range")
|
||||
|
||||
def show(self, component, editable=True):
|
||||
|
|
@ -534,12 +525,11 @@ class MouseGestureUI(ConditionUI):
|
|||
self.label = Gtk.Label(
|
||||
label=_("Mouse gesture with optional initiating button followed by zero or more mouse movements."),
|
||||
halign=Gtk.Align.CENTER,
|
||||
justify=Gtk.Justification.CENTER,
|
||||
)
|
||||
self.widgets[self.label] = (0, 0, 5, 1)
|
||||
self.del_btns = []
|
||||
self.add_btn = Gtk.Button(label=_("Add movement"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
|
||||
self.add_btn.connect(GtkSignal.CLICKED.value, self._clicked_add)
|
||||
self.add_btn.connect("clicked", self._clicked_add)
|
||||
self.widgets[self.add_btn] = (1, 1, 1, 1)
|
||||
|
||||
def _create_field(self):
|
||||
|
|
@ -547,7 +537,7 @@ class MouseGestureUI(ConditionUI):
|
|||
for g in self.MOUSE_GESTURE_NAMES:
|
||||
field.append(g, g)
|
||||
CompletionEntry.add_completion_to_entry(field.get_child(), self.MOVE_NAMES)
|
||||
field.connect(GtkSignal.CHANGED.value, self._on_update)
|
||||
field.connect("changed", self._on_update)
|
||||
self.fields.append(field)
|
||||
self.widgets[field] = (len(self.fields) - 1, 1, 1, 1)
|
||||
return field
|
||||
|
|
@ -556,7 +546,7 @@ class MouseGestureUI(ConditionUI):
|
|||
btn = Gtk.Button(label=_("Delete"), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True)
|
||||
self.del_btns.append(btn)
|
||||
self.widgets[btn] = (len(self.del_btns) - 1, 2, 1, 1)
|
||||
btn.connect(GtkSignal.CLICKED.value, self._clicked_del, len(self.del_btns) - 1)
|
||||
btn.connect("clicked", self._clicked_del, len(self.del_btns) - 1)
|
||||
return btn
|
||||
|
||||
def _clicked_add(self, _btn):
|
||||
|
|
@ -593,6 +583,7 @@ class MouseGestureUI(ConditionUI):
|
|||
field = self.fields[i]
|
||||
with self.ignore_changes():
|
||||
field.get_child().set_text(component.movements[i])
|
||||
field.set_size_request(int(0.3 * self.panel.get_toplevel().get_size()[0]), 0)
|
||||
field.show_all()
|
||||
self.del_btns[i].show()
|
||||
for i in range(n, len(self.fields)):
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
import logging
|
||||
import os
|
||||
|
||||
from enum import Enum
|
||||
from time import time
|
||||
|
||||
import gi
|
||||
|
|
@ -43,11 +42,6 @@ _TRAY_ICON_SIZE = 48
|
|||
_MENU_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR
|
||||
|
||||
|
||||
class GtkSignal(Enum):
|
||||
ACTIVATE = "activate"
|
||||
SCROLL_EVENT = "scroll-event"
|
||||
|
||||
|
||||
def _create_menu(quit_handler):
|
||||
# per-device menu entries will be generated as-needed
|
||||
menu = Gtk.Menu()
|
||||
|
|
@ -132,7 +126,8 @@ def _scroll(tray_icon, event, direction=None):
|
|||
_picked_device = None
|
||||
|
||||
_picked_device = candidate or _picked_device
|
||||
logger.debug("scroll: picked %s", _picked_device)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("scroll: picked %s", _picked_device)
|
||||
_update_tray_icon()
|
||||
|
||||
|
||||
|
|
@ -152,7 +147,8 @@ try:
|
|||
# treat unavailable versions the same as unavailable packages
|
||||
raise ImportError from exc
|
||||
|
||||
logger.debug(f"using {'Ayatana ' if ayatana_appindicator_found else ''}AppIndicator3")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(f"using {'Ayatana ' if ayatana_appindicator_found else ''}AppIndicator3")
|
||||
|
||||
# Defense against AppIndicator3 bug that treats files in current directory as icon files
|
||||
# https://bugs.launchpad.net/ubuntu/+source/libappindicator/+bug/1363277
|
||||
|
|
@ -176,7 +172,7 @@ try:
|
|||
# ind.set_label(NAME.lower(), NAME.lower())
|
||||
|
||||
ind.set_menu(menu)
|
||||
ind.connect(GtkSignal.SCROLL_EVENT.value, _scroll)
|
||||
ind.connect("scroll-event", _scroll)
|
||||
|
||||
return ind
|
||||
|
||||
|
|
@ -210,15 +206,16 @@ try:
|
|||
GLib.timeout_add(10 * 1000, _icon.set_status, AppIndicator3.IndicatorStatus.ACTIVE)
|
||||
|
||||
except ImportError:
|
||||
logger.debug("using StatusIcon")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("using StatusIcon")
|
||||
|
||||
def _create(menu):
|
||||
icon = Gtk.StatusIcon.new_from_icon_name(icons.TRAY_INIT)
|
||||
icon.set_name(NAME.lower())
|
||||
icon.set_title(NAME)
|
||||
icon.set_tooltip_text(NAME)
|
||||
icon.connect(GtkSignal.ACTIVATE.value, window.toggle)
|
||||
icon.connect(GtkSignal.SCROLL_EVENT.value, _scroll)
|
||||
icon.connect("activate", window.toggle)
|
||||
icon.connect("scroll-event", _scroll)
|
||||
icon.connect(
|
||||
"popup-menu",
|
||||
lambda icon, button, time: menu.popup(None, None, icon.position_menu, icon, button, time),
|
||||
|
|
@ -314,7 +311,8 @@ def _pick_device_with_lowest_battery():
|
|||
picked = info
|
||||
picked_level = level or 0
|
||||
|
||||
logger.debug("picked device with lowest battery: %s", picked)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("picked device with lowest battery: %s", picked)
|
||||
|
||||
return picked
|
||||
|
||||
|
|
|
|||
|
|
@ -17,15 +17,13 @@
|
|||
|
||||
import logging
|
||||
|
||||
from enum import Enum
|
||||
from enum import IntEnum
|
||||
|
||||
import gi
|
||||
|
||||
from gi.repository.GObject import TYPE_PYOBJECT
|
||||
from logitech_receiver import hidpp10_constants
|
||||
from logitech_receiver.common import LOGITECH_VENDOR_ID
|
||||
from logitech_receiver.common import NamedInt
|
||||
from logitech_receiver.common import NamedInts
|
||||
|
||||
from solaar import NAME
|
||||
from solaar.i18n import _
|
||||
|
|
@ -56,30 +54,12 @@ try:
|
|||
except (ValueError, AttributeError):
|
||||
_CAN_SET_ROW_NONE = ""
|
||||
|
||||
|
||||
class Column(IntEnum):
|
||||
"""Columns of tree model."""
|
||||
|
||||
PATH = 0
|
||||
NUMBER = 1
|
||||
ACTIVE = 2
|
||||
NAME = 3
|
||||
ICON = 4
|
||||
STATUS_TEXT = 5
|
||||
STATUS_ICON = 6
|
||||
DEVICE = 7
|
||||
|
||||
|
||||
# tree model columns
|
||||
_COLUMN = NamedInts(PATH=0, NUMBER=1, ACTIVE=2, NAME=3, ICON=4, STATUS_TEXT=5, STATUS_ICON=6, DEVICE=7)
|
||||
_COLUMN_TYPES = (str, int, bool, str, str, str, str, TYPE_PYOBJECT)
|
||||
_TREE_SEPATATOR = (None, 0, False, None, None, None, None, None)
|
||||
assert len(_TREE_SEPATATOR) == len(_COLUMN_TYPES)
|
||||
assert len(_COLUMN_TYPES) == len(Column)
|
||||
|
||||
|
||||
class GtkSignal(Enum):
|
||||
CHANGED = "changed"
|
||||
CLICKED = "clicked"
|
||||
DELETE_EVENT = "delete-event"
|
||||
assert len(_COLUMN_TYPES) == len(_COLUMN)
|
||||
|
||||
|
||||
def _new_button(label, icon_name=None, icon_size=_NORMAL_BUTTON_ICON_SIZE, tooltip=None, toggle=False, clicked=None):
|
||||
|
|
@ -91,7 +71,7 @@ def _new_button(label, icon_name=None, icon_size=_NORMAL_BUTTON_ICON_SIZE, toolt
|
|||
c.pack_start(Gtk.Label(label=label), True, True, 0)
|
||||
b.add(c)
|
||||
if clicked is not None:
|
||||
b.connect(GtkSignal.CLICKED.value, clicked)
|
||||
b.connect("clicked", clicked)
|
||||
if tooltip:
|
||||
b.set_tooltip_text(tooltip)
|
||||
if not label and icon_size < _NORMAL_BUTTON_ICON_SIZE:
|
||||
|
|
@ -258,21 +238,21 @@ def _create_tree(model):
|
|||
tree.set_model(model)
|
||||
|
||||
def _is_separator(model, item, _ignore=None):
|
||||
return model.get_value(item, Column.PATH) is None
|
||||
return model.get_value(item, _COLUMN.PATH) is None
|
||||
|
||||
tree.set_row_separator_func(_is_separator, None)
|
||||
|
||||
icon_cell_renderer = Gtk.CellRendererPixbuf()
|
||||
icon_cell_renderer.set_property("stock-size", _TREE_ICON_SIZE)
|
||||
icon_column = Gtk.TreeViewColumn("Icon", icon_cell_renderer)
|
||||
icon_column.add_attribute(icon_cell_renderer, "sensitive", Column.ACTIVE)
|
||||
icon_column.add_attribute(icon_cell_renderer, "icon-name", Column.ICON)
|
||||
icon_column.add_attribute(icon_cell_renderer, "sensitive", _COLUMN.ACTIVE)
|
||||
icon_column.add_attribute(icon_cell_renderer, "icon-name", _COLUMN.ICON)
|
||||
tree.append_column(icon_column)
|
||||
|
||||
name_cell_renderer = Gtk.CellRendererText()
|
||||
name_column = Gtk.TreeViewColumn("device name", name_cell_renderer)
|
||||
name_column.add_attribute(name_cell_renderer, "sensitive", Column.ACTIVE)
|
||||
name_column.add_attribute(name_cell_renderer, "text", Column.NAME)
|
||||
name_column.add_attribute(name_cell_renderer, "sensitive", _COLUMN.ACTIVE)
|
||||
name_column.add_attribute(name_cell_renderer, "text", _COLUMN.NAME)
|
||||
name_column.set_expand(True)
|
||||
tree.append_column(name_column)
|
||||
tree.set_expander_column(name_column)
|
||||
|
|
@ -281,16 +261,16 @@ def _create_tree(model):
|
|||
status_cell_renderer.set_property("scale", 0.85)
|
||||
status_cell_renderer.set_property("xalign", 1)
|
||||
status_column = Gtk.TreeViewColumn("status text", status_cell_renderer)
|
||||
status_column.add_attribute(status_cell_renderer, "sensitive", Column.ACTIVE)
|
||||
status_column.add_attribute(status_cell_renderer, "text", Column.STATUS_TEXT)
|
||||
status_column.add_attribute(status_cell_renderer, "sensitive", _COLUMN.ACTIVE)
|
||||
status_column.add_attribute(status_cell_renderer, "text", _COLUMN.STATUS_TEXT)
|
||||
status_column.set_expand(True)
|
||||
tree.append_column(status_column)
|
||||
|
||||
battery_cell_renderer = Gtk.CellRendererPixbuf()
|
||||
battery_cell_renderer.set_property("stock-size", _TREE_ICON_SIZE)
|
||||
battery_column = Gtk.TreeViewColumn("status icon", battery_cell_renderer)
|
||||
battery_column.add_attribute(battery_cell_renderer, "sensitive", Column.ACTIVE)
|
||||
battery_column.add_attribute(battery_cell_renderer, "icon-name", Column.STATUS_ICON)
|
||||
battery_column.add_attribute(battery_cell_renderer, "sensitive", _COLUMN.ACTIVE)
|
||||
battery_column.add_attribute(battery_cell_renderer, "icon-name", _COLUMN.STATUS_ICON)
|
||||
tree.append_column(battery_column)
|
||||
|
||||
return tree
|
||||
|
|
@ -303,7 +283,7 @@ def _create_window_layout():
|
|||
assert _empty is not None
|
||||
|
||||
assert _tree.get_selection().get_mode() == Gtk.SelectionMode.SINGLE
|
||||
_tree.get_selection().connect(GtkSignal.CHANGED.value, _device_selected)
|
||||
_tree.get_selection().connect("changed", _device_selected)
|
||||
|
||||
tree_scroll = Gtk.ScrolledWindow()
|
||||
tree_scroll.add(_tree)
|
||||
|
|
@ -348,7 +328,7 @@ def _create(delete_action):
|
|||
window = Gtk.Window()
|
||||
window.set_title(NAME)
|
||||
window.set_role("status-window")
|
||||
window.connect(GtkSignal.DELETE_EVENT.value, delete_action)
|
||||
window.connect("delete-event", delete_action)
|
||||
|
||||
vbox = _create_window_layout()
|
||||
window.add(vbox)
|
||||
|
|
@ -368,20 +348,20 @@ def _create(delete_action):
|
|||
def _find_selected_device():
|
||||
selection = _tree.get_selection()
|
||||
model, item = selection.get_selected()
|
||||
return model.get_value(item, Column.DEVICE) if item else None
|
||||
return model.get_value(item, _COLUMN.DEVICE) if item else None
|
||||
|
||||
|
||||
def _find_selected_device_id():
|
||||
selection = _tree.get_selection()
|
||||
model, item = selection.get_selected()
|
||||
if item:
|
||||
return _model.get_value(item, Column.PATH), _model.get_value(item, Column.NUMBER)
|
||||
return _model.get_value(item, _COLUMN.PATH), _model.get_value(item, _COLUMN.NUMBER)
|
||||
|
||||
|
||||
# triggered by changing selection in the tree
|
||||
def _device_selected(selection):
|
||||
model, item = selection.get_selected()
|
||||
device = model.get_value(item, Column.DEVICE) if item else None
|
||||
device = model.get_value(item, _COLUMN.DEVICE) if item else None
|
||||
if device:
|
||||
_update_info_panel(device, full=True)
|
||||
else:
|
||||
|
|
@ -401,7 +381,7 @@ def _receiver_row(receiver_path, receiver=None):
|
|||
item = _model.get_iter_first()
|
||||
while item:
|
||||
# first row matching the path must be the receiver one
|
||||
if _model.get_value(item, Column.PATH) == receiver_path:
|
||||
if _model.get_value(item, _COLUMN.PATH) == receiver_path:
|
||||
return item
|
||||
item = _model.iter_next(item)
|
||||
|
||||
|
|
@ -411,7 +391,8 @@ def _receiver_row(receiver_path, receiver=None):
|
|||
status_icon = None
|
||||
row_data = (receiver_path, 0, True, receiver.name, icon_name, status_text, status_icon, receiver)
|
||||
assert len(row_data) == len(_TREE_SEPATATOR)
|
||||
logger.debug("new receiver row %s", row_data)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("new receiver row %s", row_data)
|
||||
item = _model.append(None, row_data)
|
||||
if _TREE_SEPATATOR:
|
||||
_model.append(None, _TREE_SEPATATOR)
|
||||
|
|
@ -434,13 +415,13 @@ def _device_row(receiver_path, device_number, device=None):
|
|||
item = _model.iter_children(receiver_row)
|
||||
new_child_index = 0
|
||||
while item:
|
||||
if _model.get_value(item, Column.PATH) != receiver_path:
|
||||
if _model.get_value(item, _COLUMN.PATH) != receiver_path:
|
||||
logger.warning(
|
||||
"path for device row %s different from path for receiver %s",
|
||||
_model.get_value(item, Column.PATH),
|
||||
_model.get_value(item, _COLUMN.PATH),
|
||||
receiver_path,
|
||||
)
|
||||
item_number = _model.get_value(item, Column.NUMBER)
|
||||
item_number = _model.get_value(item, _COLUMN.NUMBER)
|
||||
if item_number == device_number:
|
||||
return item
|
||||
if item_number > device_number:
|
||||
|
|
@ -464,7 +445,8 @@ def _device_row(receiver_path, device_number, device=None):
|
|||
device,
|
||||
)
|
||||
assert len(row_data) == len(_TREE_SEPATATOR)
|
||||
logger.debug("new device row %s at index %d", row_data, new_child_index)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("new device row %s at index %d", row_data, new_child_index)
|
||||
item = _model.insert(receiver_row, new_child_index, row_data)
|
||||
|
||||
return item or None
|
||||
|
|
@ -556,15 +538,17 @@ def _update_details(button):
|
|||
|
||||
flag_bits = device.notification_flags
|
||||
if flag_bits is not None:
|
||||
flag_names = hidpp10_constants.flags_to_str(flag_bits, fallback=f"({_('none')})")
|
||||
yield _("Notifications"), flag_names
|
||||
flag_names = (
|
||||
(f"({_('none')})",) if flag_bits == 0 else hidpp10_constants.NOTIFICATION_FLAG.flag_names(flag_bits)
|
||||
)
|
||||
yield _("Notifications"), f"\n{' ':15}".join(flag_names)
|
||||
|
||||
def _set_details(text):
|
||||
_details._text.set_markup(text)
|
||||
|
||||
def _make_text(items):
|
||||
text = "\n".join("%-13s: %s" % (name, value) for name, value in items)
|
||||
return f"<small><tt>{text}</tt></small>"
|
||||
return "<small><tt>" + text + "</tt></small>"
|
||||
|
||||
def _displayable_items(items):
|
||||
for name, value in items:
|
||||
|
|
@ -849,9 +833,9 @@ def update(device, need_popup=False, refresh=False):
|
|||
item = _receiver_row(device.path, device if is_alive else None)
|
||||
|
||||
if is_alive and item:
|
||||
was_pairing = bool(_model.get_value(item, Column.STATUS_ICON))
|
||||
was_pairing = bool(_model.get_value(item, _COLUMN.STATUS_ICON))
|
||||
is_pairing = (not device.isDevice) and bool(device.pairing.lock_open)
|
||||
_model.set_value(item, Column.STATUS_ICON, "network-wireless" if is_pairing else _CAN_SET_ROW_NONE)
|
||||
_model.set_value(item, _COLUMN.STATUS_ICON, "network-wireless" if is_pairing else _CAN_SET_ROW_NONE)
|
||||
|
||||
if selected_device_id == (device.path, 0):
|
||||
full_update = need_popup or was_pairing != is_pairing
|
||||
|
|
@ -866,7 +850,7 @@ def update(device, need_popup=False, refresh=False):
|
|||
|
||||
else:
|
||||
path = device.receiver.path if device.receiver is not None else device.path
|
||||
assert device.number is not None and device.number >= 0, f"invalid device number{str(device.number)}"
|
||||
assert device.number is not None and device.number >= 0, "invalid device number" + str(device.number)
|
||||
item = _device_row(path, device.number, device if bool(device) else None)
|
||||
|
||||
if bool(device) and item:
|
||||
|
|
@ -880,15 +864,15 @@ def update(device, need_popup=False, refresh=False):
|
|||
|
||||
|
||||
def update_device(device, item, selected_device_id, need_popup, full=False):
|
||||
was_online = _model.get_value(item, Column.ACTIVE)
|
||||
was_online = _model.get_value(item, _COLUMN.ACTIVE)
|
||||
is_online = bool(device.online)
|
||||
_model.set_value(item, Column.ACTIVE, is_online)
|
||||
_model.set_value(item, _COLUMN.ACTIVE, is_online)
|
||||
|
||||
battery_level = device.battery_info.level if device.battery_info is not None else None
|
||||
battery_voltage = device.battery_info.voltage if device.battery_info is not None else None
|
||||
if battery_level is None:
|
||||
_model.set_value(item, Column.STATUS_TEXT, _CAN_SET_ROW_NONE)
|
||||
_model.set_value(item, Column.STATUS_ICON, _CAN_SET_ROW_NONE)
|
||||
_model.set_value(item, _COLUMN.STATUS_TEXT, _CAN_SET_ROW_NONE)
|
||||
_model.set_value(item, _COLUMN.STATUS_ICON, _CAN_SET_ROW_NONE)
|
||||
else:
|
||||
if battery_voltage is not None and False: # Use levels instead of voltage here
|
||||
status_text = f"{int(battery_voltage)}mV"
|
||||
|
|
@ -896,13 +880,13 @@ def update_device(device, item, selected_device_id, need_popup, full=False):
|
|||
status_text = _(str(battery_level))
|
||||
else:
|
||||
status_text = f"{int(battery_level)}%"
|
||||
_model.set_value(item, Column.STATUS_TEXT, status_text)
|
||||
_model.set_value(item, _COLUMN.STATUS_TEXT, status_text)
|
||||
|
||||
charging = device.battery_info.charging() if device.battery_info is not None else None
|
||||
icon_name = icons.battery(battery_level, charging)
|
||||
_model.set_value(item, Column.STATUS_ICON, icon_name)
|
||||
_model.set_value(item, _COLUMN.STATUS_ICON, icon_name)
|
||||
|
||||
_model.set_value(item, Column.NAME, device.codename)
|
||||
_model.set_value(item, _COLUMN.NAME, device.codename)
|
||||
|
||||
if selected_device_id is None or need_popup:
|
||||
select(device.receiver.path if device.receiver else device.path, device.number)
|
||||
|
|
@ -917,7 +901,7 @@ def find_device(serial):
|
|||
|
||||
def check(_store, _treepath, row):
|
||||
nonlocal result
|
||||
device = _model.get_value(row, Column.DEVICE)
|
||||
device = _model.get_value(row, _COLUMN.DEVICE)
|
||||
if device and device.kind and (device.unitId == serial or device.serial == serial):
|
||||
result = device
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1.1.16
|
||||
1.1.14
|
||||
|
|
|
|||
27
mkdocs.yml
27
mkdocs.yml
|
|
@ -3,30 +3,29 @@ site_description: Linux Device Manager for Logitech Unifying Receivers and Devic
|
|||
site_author: pwr-Solaar
|
||||
repo_url: https://github.com/pwr-Solaar/Solaar
|
||||
repo_name: Solaar
|
||||
logo: img/favicon.png
|
||||
theme:
|
||||
name: readthedocs
|
||||
docs_dir: docs
|
||||
nav:
|
||||
- Solaar: index.md
|
||||
- Usage: usage.md
|
||||
- Capabilities: capabilities.md
|
||||
- Issues: issues.md
|
||||
- Rules: rules.md
|
||||
- Installation: installation.md
|
||||
- Uninstallation: uninstallation.md
|
||||
- Translation: i18n.md
|
||||
- Features: features.md
|
||||
- Devices: devices.md
|
||||
- Implementation: implementation.md
|
||||
- Debian: debian.md
|
||||
- Devices: devices.md
|
||||
- Features: features.md
|
||||
- Translation: i18n.md
|
||||
- Implementation: implementation.md
|
||||
- Installation: installation.md
|
||||
- Rules: rules.md
|
||||
- Usage: usage.md
|
||||
|
||||
plugins:
|
||||
- search
|
||||
# - mkdocstrings:
|
||||
# handlers:
|
||||
# python:
|
||||
# setup_commands:
|
||||
# - python -m pip install .
|
||||
- mkdocstrings:
|
||||
handlers:
|
||||
python:
|
||||
setup_commands:
|
||||
- python -m pip install .
|
||||
- mermaid2
|
||||
markdown_extensions:
|
||||
- pymdownx.superfences
|
||||
|
|
|
|||
1251
po/solaar.pot
1251
po/solaar.pot
File diff suppressed because it is too large
Load Diff
30
po/sv.po
30
po/sv.po
|
|
@ -132,11 +132,11 @@ msgstr "för många enheter"
|
|||
|
||||
#: lib/logitech_receiver/i18n.py:56
|
||||
msgid "sequence timeout"
|
||||
msgstr "tidsgräns för sekvens överskreds"
|
||||
msgstr "sekvens timout"
|
||||
|
||||
#: lib/logitech_receiver/i18n.py:59 lib/solaar/ui/window.py:572
|
||||
msgid "Firmware"
|
||||
msgstr "Fast programvara"
|
||||
msgstr "Programvara"
|
||||
|
||||
#: lib/logitech_receiver/i18n.py:60
|
||||
msgid "Bootloader"
|
||||
|
|
@ -274,7 +274,7 @@ msgstr "funktion"
|
|||
|
||||
#: lib/logitech_receiver/settings_templates.py:139
|
||||
msgid "Swap Fx function"
|
||||
msgstr "Byt Fx-funktion"
|
||||
msgstr "Skifta Fx-funktion"
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:140
|
||||
msgid ""
|
||||
|
|
@ -468,19 +468,19 @@ msgstr "Ställ in kronans mjuka rullning"
|
|||
|
||||
#: lib/logitech_receiver/settings_templates.py:383
|
||||
msgid "Divert G Keys"
|
||||
msgstr "Avleda G-tangenter"
|
||||
msgstr "Avleda G Keys"
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:385
|
||||
msgid ""
|
||||
"Make G keys send GKEY HID++ notifications (which trigger Solaar rules but "
|
||||
"are otherwise ignored)."
|
||||
msgstr ""
|
||||
"Få G-tangenter att skicka GKEY HID++-aviseringar (som utlöser Solaar-regler men "
|
||||
"Få G keys att skicka GKEY HID++-aviseringar (som utlöser Solaar-regler men "
|
||||
"som annars ignoreras)."
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:386
|
||||
msgid "May also make M keys and MR key send HID++ notifications"
|
||||
msgstr "Kan också få M-tangenter och MR-tangenten att skicka HID++-aviseringar"
|
||||
msgstr "Kan också få M keys och MR key att skicka HID++-aviseringar"
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:402
|
||||
msgid "Scroll Wheel Ratcheted"
|
||||
|
|
@ -634,11 +634,11 @@ msgstr "Utför ett högerklick."
|
|||
|
||||
#: lib/logitech_receiver/settings_templates.py:901
|
||||
msgid "Single tap with two fingers"
|
||||
msgstr "Enkelt tryck med två fingrar"
|
||||
msgstr "Enkel tryck med två fingrar"
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:902
|
||||
msgid "Single tap with three fingers"
|
||||
msgstr "Enkelt tryck med tre fingrar"
|
||||
msgstr "Enkel tryck med tre fingrar"
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:906
|
||||
msgid "Double tap"
|
||||
|
|
@ -768,7 +768,7 @@ msgstr "Svep två fingrar från den övre kanten"
|
|||
#: lib/logitech_receiver/settings_templates.py:944
|
||||
#: lib/logitech_receiver/settings_templates.py:948
|
||||
msgid "Pinch to zoom out; spread to zoom in."
|
||||
msgstr "Nyp för att zooma ut; sprid för att zooma in."
|
||||
msgstr "Nyp för att zooma ut; sprida för att zooma in."
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:944
|
||||
msgid "Zoom with two fingers."
|
||||
|
|
@ -881,7 +881,7 @@ msgstr "Styr M-Key LED:ar."
|
|||
#: lib/logitech_receiver/settings_templates.py:1047
|
||||
#: lib/logitech_receiver/settings_templates.py:1077
|
||||
msgid "May need G Keys diverted to be effective."
|
||||
msgstr "Kan behöva omdirigera G-tangenter för att vara effektiva."
|
||||
msgstr "Kan behöva omdirigera G Keys för att vara effektiva."
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:1053
|
||||
#, python-format
|
||||
|
|
@ -1224,7 +1224,7 @@ msgstr "Och"
|
|||
|
||||
#: lib/solaar/ui/diversion_rules.py:513
|
||||
msgid "Condition"
|
||||
msgstr "Villkor"
|
||||
msgstr "Tillstånd"
|
||||
|
||||
#: lib/solaar/ui/diversion_rules.py:515 lib/solaar/ui/diversion_rules.py:1292
|
||||
msgid "Feature"
|
||||
|
|
@ -1278,7 +1278,7 @@ msgstr "Testa"
|
|||
|
||||
#: lib/solaar/ui/diversion_rules.py:527 lib/solaar/ui/diversion_rules.py:1643
|
||||
msgid "Test bytes"
|
||||
msgstr "Testa byte"
|
||||
msgstr "Testa bytes"
|
||||
|
||||
#: lib/solaar/ui/diversion_rules.py:528 lib/solaar/ui/diversion_rules.py:1736
|
||||
msgid "Mouse Gesture"
|
||||
|
|
@ -1385,7 +1385,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Omdirigerad knapp eller knapp nedtryckt eller släppt.\n"
|
||||
"Använd inställningarna för vidarekoppling av tangent/knapp och "
|
||||
"vidarekoppling av G-tangenter för att vidarekoppla tangenter och knappar."
|
||||
"vidarekoppling av G-knappar för att vidarekoppla nycklar och knappar."
|
||||
|
||||
#: lib/solaar/ui/diversion_rules.py:1392
|
||||
msgid "Key down"
|
||||
|
|
@ -1403,7 +1403,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Vidarekopplad nyckel eller knapp är för närvarande nere.\n"
|
||||
"Använd inställningarna för vidarekoppling av tangent/knapp och "
|
||||
"vidarekoppling av G-tangenter för att vidarekoppla tangenter och knappar."
|
||||
"vidarekoppling av G-knappar för att vidarekoppla nycklar och knappar."
|
||||
|
||||
#: lib/solaar/ui/diversion_rules.py:1475
|
||||
msgid "Test condition on notification triggering rule processing."
|
||||
|
|
@ -1452,7 +1452,7 @@ msgid ""
|
|||
"Bit or range test on bytes in notification message triggering rule "
|
||||
"processing."
|
||||
msgstr ""
|
||||
"Bit- eller omfångstest på byte i aviseringsmeddelande som utlöser "
|
||||
"Bit- eller omfångstest på bytes i aviseringsmeddelande som utlöser "
|
||||
"regelbearbetning."
|
||||
|
||||
#: lib/solaar/ui/diversion_rules.py:1583
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
# because they could perform firmware updates.
|
||||
KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"
|
||||
|
||||
ACTION == "remove", GOTO="solaar_end"
|
||||
ACTION != "add", GOTO="solaar_end"
|
||||
SUBSYSTEM != "hidraw", GOTO="solaar_end"
|
||||
|
||||
# USB-connected Logitech receivers and devices
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
# Allowing users to write to the device is potentially dangerous
|
||||
# because they could perform firmware updates.
|
||||
|
||||
ACTION == "remove", GOTO="solaar_end"
|
||||
ACTION != "add", GOTO="solaar_end"
|
||||
SUBSYSTEM != "hidraw", GOTO="solaar_end"
|
||||
|
||||
# USB-connected Logitech receivers and devices
|
||||
|
|
|
|||
3
setup.py
3
setup.py
|
|
@ -76,13 +76,12 @@ setup(
|
|||
"psutil (>= 5.4.3)",
|
||||
'dbus-python ; platform_system=="Linux"',
|
||||
"PyGObject",
|
||||
"typing_extensions",
|
||||
],
|
||||
extras_require={
|
||||
"report-descriptor": ["hid-parser"],
|
||||
"desktop-notifications": ["Notify (>= 0.7)"],
|
||||
"git-commit": ["python-git-info"],
|
||||
"test": ["pytest", "pytest-mock", "pytest-cov"],
|
||||
"test": ["pytest", "pytest-mock", "pytest-cov", "typing_extensions"],
|
||||
"dev": ["ruff"],
|
||||
},
|
||||
package_dir={"": "lib"},
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Comment[ru]=Управление приёмником Logitech Unifying Receiver
|
|||
Comment[de]=Logitech Unifying Empfänger Geräteverwaltung
|
||||
Comment[es]=Administrador de periféricos de Logitech Receptor Unifying
|
||||
Comment[pl]=Menedżer urządzeń peryferyjnych odbiornika Logitech Unifying
|
||||
Comment[sv]=Kringutrustningshanterare för Logitech Unifying-mottagare
|
||||
Comment[sv]=Kringutrustningshanteraring för Logitech Unifying-mottagare
|
||||
Comment[zh_CN]=罗技优联设备管理器
|
||||
Comment[zh_TW]=羅技Unifying 裝置管理器
|
||||
Comment[zh_HK]=羅技Unifying 裝置管理器
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
<developer id="pwr-solaar.github.io">
|
||||
<name>pwr-Solaar</name>
|
||||
</developer>
|
||||
<url type="homepage">https://pwr-solaar.github.io/Solaar/</url>
|
||||
<url type="vcs-browser">https://github.com/pwr-Solaar/Solaar</url>
|
||||
<url type="homepage">https://github.com/pwr-Solaar/Solaar</url>
|
||||
<content_rating type="oars-1.0" />
|
||||
<update_contact>pfpschneider_AT_gmail.com</update_contact>
|
||||
|
||||
|
|
@ -48,7 +47,6 @@
|
|||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="1.1.16" date="2025-10-23"/>
|
||||
<release version="1.1.14" date="2025-01-01"/>
|
||||
<release version="1.1.13" date="2024-05-11"/>
|
||||
<release version="1.1.12" date="2024-04-27"/>
|
||||
|
|
|
|||
|
|
@ -388,8 +388,6 @@ class Device:
|
|||
setting_callback: Any = None
|
||||
sliding = profiles = _backlight = _keys = _remap_keys = _led_effects = _gestures = None
|
||||
_gestures_lock = threading.Lock()
|
||||
number = "d1"
|
||||
present = True
|
||||
|
||||
read_register = device.Device.read_register
|
||||
write_register = device.Device.write_register
|
||||
|
|
@ -407,7 +405,6 @@ class Device:
|
|||
self.persister = configuration._DeviceEntry()
|
||||
self.features = hidpp20.FeaturesArray(self)
|
||||
self.settings = []
|
||||
self.receiver = []
|
||||
if self.feature is not None:
|
||||
self.features = hidpp20.FeaturesArray(self)
|
||||
self.responses = [
|
||||
|
|
@ -438,18 +435,6 @@ class Device:
|
|||
print("PING", self._protocol)
|
||||
return self._protocol
|
||||
|
||||
def handle_notification(self, handle):
|
||||
pass
|
||||
|
||||
def changed(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def set_battery_info(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def status_string(self):
|
||||
pass
|
||||
|
||||
|
||||
def match_requests(number, responses, call_args_list):
|
||||
for i in range(0 - number, 0):
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ import pytest
|
|||
from logitech_receiver import base
|
||||
from logitech_receiver import exceptions
|
||||
from logitech_receiver.base import HIDPP_SHORT_MESSAGE_ID
|
||||
from logitech_receiver.common import LOGITECH_VENDOR_ID
|
||||
from logitech_receiver.common import BusID
|
||||
from logitech_receiver.base import request
|
||||
from logitech_receiver.hidpp10_constants import ErrorCode as Hidpp10Error
|
||||
from logitech_receiver.hidpp20_constants import ErrorCode as Hidpp20Error
|
||||
|
||||
|
|
@ -38,9 +37,10 @@ def test_product_information(usb_id, expected_name, expected_receiver_kind):
|
|||
|
||||
def test_filter_receivers_known():
|
||||
bus_id = 2
|
||||
vendor_id = 0x046D
|
||||
product_id = 0xC548
|
||||
|
||||
receiver_info = base.get_known_receiver_info(bus_id, LOGITECH_VENDOR_ID, product_id)
|
||||
receiver_info = base._filter_receivers(bus_id, vendor_id, product_id)
|
||||
|
||||
assert receiver_info["name"] == "Bolt Receiver"
|
||||
assert receiver_info["receiver_kind"] == "bolt"
|
||||
|
|
@ -48,51 +48,43 @@ def test_filter_receivers_known():
|
|||
|
||||
def test_filter_receivers_unknown():
|
||||
bus_id = 1
|
||||
vendor_id = 0x046D
|
||||
product_id = 0xC500
|
||||
|
||||
receiver_info = base.get_known_receiver_info(bus_id, LOGITECH_VENDOR_ID, product_id)
|
||||
receiver_info = base._filter_receivers(bus_id, vendor_id, product_id)
|
||||
|
||||
assert receiver_info["bus_id"] == bus_id
|
||||
assert receiver_info["product_id"] == product_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"product_id, bus, hidpp_short, hidpp_long, expected",
|
||||
"product_id, hidpp_short, hidpp_long",
|
||||
[
|
||||
(0xC548, BusID.USB, True, False, {"name": "Bolt Receiver", "usb_interface": 2}),
|
||||
(0xC07D, BusID.USB, True, False, {"usb_interface": 1}),
|
||||
(0xC07E, BusID.USB, False, True, {"usb_interface": 1}),
|
||||
(0xC07E, BusID.BLUETOOTH, False, True, {"bus_id": 5}),
|
||||
(0xA07E, BusID.USB, False, True, {"product_id": 0xA07E}),
|
||||
(0xA07C, BusID.USB, False, False, None),
|
||||
(0xC07F, BusID.USB, None, None, {"usb_interface": 2}),
|
||||
(0xC07F, BusID.BLUETOOTH, None, None, None),
|
||||
(0xB013, BusID.BLUETOOTH, None, None, {"product_id": 0xB013}),
|
||||
(0xC548, True, False),
|
||||
(0xC07E, True, False),
|
||||
(0xC07E, False, True),
|
||||
(0xA07E, False, True),
|
||||
(0xA07E, None, None),
|
||||
(0xA07C, False, False),
|
||||
],
|
||||
)
|
||||
def test_filter_products_of_interest(product_id, bus, hidpp_short, hidpp_long, expected):
|
||||
receiver_info = base.filter_products_of_interest(
|
||||
bus,
|
||||
LOGITECH_VENDOR_ID,
|
||||
def test_filter_products_of_interest(product_id, hidpp_short, hidpp_long):
|
||||
bus_id = 3
|
||||
vendor_id = 0x046D
|
||||
|
||||
receiver_info = base._filter_products_of_interest(
|
||||
bus_id,
|
||||
vendor_id,
|
||||
product_id,
|
||||
hidpp_short=hidpp_short,
|
||||
hidpp_long=hidpp_long,
|
||||
)
|
||||
|
||||
if expected is None:
|
||||
assert receiver_info == expected
|
||||
if not hidpp_short and not hidpp_long:
|
||||
assert receiver_info is None
|
||||
else:
|
||||
assert all([receiver_info[key] == expected_value for key, expected_value in expected.items()])
|
||||
assert receiver_info["vendor_id"] == LOGITECH_VENDOR_ID
|
||||
assert receiver_info["product_id"]
|
||||
|
||||
|
||||
def test_match():
|
||||
record = {"vendor_id": LOGITECH_VENDOR_ID}
|
||||
|
||||
res = base._match_device(record, 0, LOGITECH_VENDOR_ID, 0)
|
||||
|
||||
assert res is True
|
||||
assert isinstance(receiver_info["vendor_id"], int)
|
||||
assert receiver_info["product_id"] == product_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -152,19 +144,19 @@ def test_request_errors(
|
|||
with mock.patch(
|
||||
"logitech_receiver.base._read",
|
||||
return_value=(HIDPP_SHORT_MESSAGE_ID, device_number, prefix + reply_data_sw_id + struct.pack("B", error_code)),
|
||||
), mock.patch("logitech_receiver.base._read_input_buffer"), mock.patch(
|
||||
), mock.patch("logitech_receiver.base._skip_incoming", return_value=None), mock.patch(
|
||||
"logitech_receiver.base.write", return_value=None
|
||||
), mock.patch("logitech_receiver.base._get_next_sw_id", return_value=next_sw_id):
|
||||
if raise_exception:
|
||||
with pytest.raises(exceptions.FeatureCallError) as context:
|
||||
base.request(handle, device_number, next_sw_id, return_error=return_error)
|
||||
request(handle, device_number, next_sw_id, return_error=return_error)
|
||||
assert context.value.number == device_number
|
||||
assert context.value.request == next_sw_id
|
||||
assert context.value.error == error_code
|
||||
assert context.value.params == b""
|
||||
|
||||
else:
|
||||
result = base.request(handle, device_number, next_sw_id, return_error=return_error)
|
||||
result = request(handle, device_number, next_sw_id, return_error=return_error)
|
||||
assert result == (error_code if return_error else None)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import pytest
|
||||
|
||||
from logitech_receiver import base_usb
|
||||
from logitech_receiver.common import LOGITECH_VENDOR_ID
|
||||
|
||||
|
||||
def test_ensure_known_receivers_mappings_are_valid():
|
||||
for key, receiver in base_usb.KNOWN_RECEIVERS.items():
|
||||
assert key == receiver["product_id"]
|
||||
|
||||
|
||||
def test_get_receiver_info():
|
||||
expected = {
|
||||
"vendor_id": LOGITECH_VENDOR_ID,
|
||||
"product_id": 0xC548,
|
||||
"usb_interface": 2,
|
||||
"name": "Bolt Receiver",
|
||||
"receiver_kind": "bolt",
|
||||
"max_devices": 6,
|
||||
"may_unpair": True,
|
||||
}
|
||||
|
||||
res = base_usb.get_receiver_info(0xC548)
|
||||
|
||||
assert res == expected
|
||||
|
||||
|
||||
def test_get_receiver_info_unknown_device_fails():
|
||||
with pytest.raises(ValueError):
|
||||
base_usb.get_receiver_info(0xC500)
|
||||
|
|
@ -2,28 +2,22 @@ from unittest import mock
|
|||
|
||||
from logitech_receiver import desktop_notifications
|
||||
|
||||
# depends on external environment, so make some tests dependent on availability
|
||||
|
||||
def test_notifications_available():
|
||||
result = desktop_notifications.notifications_available()
|
||||
|
||||
assert not result
|
||||
|
||||
|
||||
def test_init():
|
||||
result = desktop_notifications.init()
|
||||
|
||||
assert result == desktop_notifications.available
|
||||
assert not desktop_notifications.init()
|
||||
|
||||
|
||||
def test_uninit():
|
||||
assert desktop_notifications.uninit() is None
|
||||
|
||||
|
||||
class MockDevice(mock.Mock):
|
||||
name = "MockDevice"
|
||||
|
||||
def close():
|
||||
return True
|
||||
|
||||
|
||||
def test_show():
|
||||
dev = MockDevice()
|
||||
dev = mock.MagicMock()
|
||||
reason = "unknown"
|
||||
result = desktop_notifications.show(dev, reason)
|
||||
assert result is not None if desktop_notifications.available else result is None
|
||||
assert desktop_notifications.show(dev, reason) is None
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@ import pytest
|
|||
from logitech_receiver import common
|
||||
from logitech_receiver import device
|
||||
from logitech_receiver import hidpp20
|
||||
from logitech_receiver.common import BatteryLevelApproximation
|
||||
from logitech_receiver.common import BatteryStatus
|
||||
|
||||
from . import fake_hidpp
|
||||
|
||||
|
|
@ -33,7 +31,7 @@ class LowLevelInterfaceFake:
|
|||
def __init__(self, responses=None):
|
||||
self.responses = responses
|
||||
|
||||
def open_path(self, path) -> int:
|
||||
def open_path(self, path):
|
||||
return fake_hidpp.open_path(path)
|
||||
|
||||
def find_paired_node(self, receiver_path: str, index: int, timeout: int):
|
||||
|
|
@ -86,7 +84,7 @@ def test_create_device(device_info, responses, expected_success):
|
|||
with pytest.raises(PermissionError):
|
||||
device.create_device(low_level_mock, device_info)
|
||||
elif not expected_success:
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
with pytest.raises(TypeError):
|
||||
device.create_device(low_level_mock, device_info)
|
||||
else:
|
||||
test_device = device.create_device(low_level_mock, device_info)
|
||||
|
|
@ -217,7 +215,7 @@ def test_device_receiver(number, pairing_info, responses, handle, _name, codenam
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"number, info, responses, handle, unitId, modelId, task_id, kind, firmware, serial, id, psl, rate",
|
||||
"number, info, responses, handle, unitId, modelId, tid, kind, firmware, serial, id, psl, rate",
|
||||
zip(
|
||||
range(1, 7),
|
||||
[pi_CCCC, pi_2011, pi_4066, pi_1007, pi_407B, pi_DDDD],
|
||||
|
|
@ -241,7 +239,7 @@ def test_device_receiver(number, pairing_info, responses, handle, _name, codenam
|
|||
["1ms", "2ms", "4ms", "8ms", "1ms", "9ms"], # polling rate
|
||||
),
|
||||
)
|
||||
def test_device_ids(number, info, responses, handle, unitId, modelId, task_id, kind, firmware, serial, id, psl, rate):
|
||||
def test_device_ids(number, info, responses, handle, unitId, modelId, tid, kind, firmware, serial, id, psl, rate):
|
||||
low_level = LowLevelInterfaceFake(responses)
|
||||
low_level.request = partial(fake_hidpp.request, fake_hidpp.replace_number(responses, number))
|
||||
low_level.ping = partial(fake_hidpp.ping, fake_hidpp.replace_number(responses, number))
|
||||
|
|
@ -250,7 +248,7 @@ def test_device_ids(number, info, responses, handle, unitId, modelId, task_id, k
|
|||
|
||||
assert test_device.unitId == unitId
|
||||
assert test_device.modelId == modelId
|
||||
assert test_device.tid_map == task_id
|
||||
assert test_device.tid_map == tid
|
||||
assert test_device.kind == kind
|
||||
assert test_device.firmware == firmware or len(test_device.firmware) > 0 and firmware is True
|
||||
assert test_device.id == id
|
||||
|
|
@ -327,14 +325,14 @@ def test_device_settings(device_info, responses, protocol, p, persister, setting
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"device_info, responses, protocol, expected_battery, changed",
|
||||
"device_info, responses, protocol, battery, changed",
|
||||
[
|
||||
(di_C318, fake_hidpp.r_empty, 1.0, None, {"active": True, "alert": 0, "reason": None}),
|
||||
(
|
||||
di_C318,
|
||||
fake_hidpp.r_keyboard_1,
|
||||
1.0,
|
||||
common.Battery(BatteryLevelApproximation.GOOD.value, None, BatteryStatus.DISCHARGING, None),
|
||||
common.Battery(50, None, 0, None),
|
||||
{"active": True, "alert": 0, "reason": None},
|
||||
),
|
||||
(
|
||||
|
|
@ -346,12 +344,12 @@ def test_device_settings(device_info, responses, protocol, p, persister, setting
|
|||
),
|
||||
],
|
||||
)
|
||||
def test_device_battery(device_info, responses, protocol, expected_battery, changed, mocker):
|
||||
def test_device_battery(device_info, responses, protocol, battery, changed, mocker):
|
||||
test_device = FakeDevice(responses, None, None, online=True, device_info=device_info)
|
||||
test_device._name = "TestDevice"
|
||||
test_device._protocol = protocol
|
||||
spy_changed = mocker.spy(test_device, "changed")
|
||||
|
||||
assert test_device.battery() == expected_battery
|
||||
assert test_device.battery() == battery
|
||||
test_device.read_battery()
|
||||
spy_changed.assert_called_with(**changed)
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ def test_set_notification_flags(mocker):
|
|||
spy_request = mocker.spy(device, "request")
|
||||
|
||||
result = _hidpp10.set_notification_flags(
|
||||
device, hidpp10_constants.NotificationFlag.BATTERY_STATUS, hidpp10_constants.NotificationFlag.WIRELESS
|
||||
device, hidpp10_constants.NOTIFICATION_FLAG.battery_status, hidpp10_constants.NOTIFICATION_FLAG.wireless
|
||||
)
|
||||
|
||||
spy_request.assert_called_once_with(0x8000 | Registers.NOTIFICATIONS, b"\x10\x01\x00")
|
||||
|
|
@ -267,32 +267,13 @@ def test_set_notification_flags_bad(mocker):
|
|||
spy_request = mocker.spy(device, "request")
|
||||
|
||||
result = _hidpp10.set_notification_flags(
|
||||
device, hidpp10_constants.NotificationFlag.BATTERY_STATUS, hidpp10_constants.NotificationFlag.WIRELESS
|
||||
device, hidpp10_constants.NOTIFICATION_FLAG.battery_status, hidpp10_constants.NOTIFICATION_FLAG.wireless
|
||||
)
|
||||
|
||||
assert spy_request.call_count == 0
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flag_bits, expected_names",
|
||||
[
|
||||
(None, ""),
|
||||
(0x0, "none"),
|
||||
(0x009020, "multi touch\n unknown:008020"),
|
||||
(0x080000, "mouse extra buttons"),
|
||||
(
|
||||
0x080000 + 0x000400,
|
||||
("link quality\n mouse extra buttons"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_notification_flag_str(flag_bits, expected_names):
|
||||
flag_names = hidpp10_constants.flags_to_str(flag_bits, fallback="none")
|
||||
|
||||
assert flag_names == expected_names
|
||||
|
||||
|
||||
def test_get_device_features():
|
||||
result = _hidpp10.get_device_features(device_standard)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ from logitech_receiver import exceptions
|
|||
from logitech_receiver import hidpp20
|
||||
from logitech_receiver import hidpp20_constants
|
||||
from logitech_receiver import special_keys
|
||||
from logitech_receiver.hidpp20 import KeyFlag
|
||||
from logitech_receiver.hidpp20 import MappingFlag
|
||||
from logitech_receiver.hidpp20_constants import GestureId
|
||||
|
||||
from . import fake_hidpp
|
||||
|
|
@ -156,54 +154,30 @@ def test_FeaturesArray_getitem(device, expected0, expected1, expected2, expected
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"device, index, cid, task_id, flags, default_task, expected_flags",
|
||||
"device, index, cid, tid, flags, default_task, flag_names",
|
||||
[
|
||||
(device_standard, 2, 1, 1, 0x30, "Volume Up", KeyFlag.REPROGRAMMABLE | KeyFlag.DIVERTABLE),
|
||||
(device_standard, 1, 2, 2, 0x20, "Volume Down", KeyFlag.DIVERTABLE),
|
||||
(device_standard, 2, 1, 1, 0x30, "Volume Up", ["reprogrammable", "divertable"]),
|
||||
(device_standard, 1, 2, 2, 0x20, "Volume Down", ["divertable"]),
|
||||
],
|
||||
)
|
||||
def test_reprogrammable_key_key(device, index, cid, task_id, flags, default_task, expected_flags):
|
||||
key = hidpp20.ReprogrammableKey(device, index, cid, task_id, flags)
|
||||
def test_ReprogrammableKey_key(device, index, cid, tid, flags, default_task, flag_names):
|
||||
key = hidpp20.ReprogrammableKey(device, index, cid, tid, flags)
|
||||
|
||||
assert key._device == device
|
||||
assert key.index == index
|
||||
assert key._cid == cid
|
||||
assert key._tid == task_id
|
||||
assert key._tid == tid
|
||||
assert key._flags == flags
|
||||
assert key.key == special_keys.CONTROL[cid]
|
||||
assert key.default_task == common.NamedInt(cid, default_task)
|
||||
assert key.flags == expected_flags
|
||||
assert list(key.flags) == flag_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"device, index, cid, task_id, flags, pos, group, gmask, default_task, expected_flags, group_names",
|
||||
"device, index, cid, tid, flags, pos, group, gmask, default_task, flag_names, group_names",
|
||||
[
|
||||
(
|
||||
device_standard,
|
||||
1,
|
||||
0x51,
|
||||
0x39,
|
||||
0x60,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
"Right Click",
|
||||
KeyFlag.DIVERTABLE | KeyFlag.PERSISTENTLY_DIVERTABLE,
|
||||
["g1"],
|
||||
),
|
||||
(
|
||||
device_standard,
|
||||
2,
|
||||
0x52,
|
||||
0x3A,
|
||||
0x11,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
"Mouse Middle Button",
|
||||
KeyFlag.MSE | KeyFlag.REPROGRAMMABLE,
|
||||
["g1", "g2"],
|
||||
),
|
||||
(device_standard, 1, 0x51, 0x39, 0x60, 0, 1, 1, "Right Click", ["divertable", "persistently divertable"], ["g1"]),
|
||||
(device_standard, 2, 0x52, 0x3A, 0x11, 1, 2, 3, "Mouse Middle Button", ["mse", "reprogrammable"], ["g1", "g2"]),
|
||||
(
|
||||
device_standard,
|
||||
3,
|
||||
|
|
@ -214,41 +188,39 @@ def test_reprogrammable_key_key(device, index, cid, task_id, flags, default_task
|
|||
2,
|
||||
7,
|
||||
"Mouse Back Button",
|
||||
KeyFlag.REPROGRAMMABLE | KeyFlag.RAW_XY,
|
||||
["reprogrammable", "raw XY"],
|
||||
["g1", "g2", "g3"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_reprogrammable_key_v4_key(
|
||||
device, index, cid, task_id, flags, pos, group, gmask, default_task, expected_flags, group_names
|
||||
):
|
||||
key = hidpp20.ReprogrammableKeyV4(device, index, cid, task_id, flags, pos, group, gmask)
|
||||
def test_reprogrammable_key_v4_key(device, index, cid, tid, flags, pos, group, gmask, default_task, flag_names, group_names):
|
||||
key = hidpp20.ReprogrammableKeyV4(device, index, cid, tid, flags, pos, group, gmask)
|
||||
|
||||
assert key._device == device
|
||||
assert key.index == index
|
||||
assert key._cid == cid
|
||||
assert key._tid == task_id
|
||||
assert key._tid == tid
|
||||
assert key._flags == flags
|
||||
assert key.pos == pos
|
||||
assert key.group == group
|
||||
assert key._gmask == gmask
|
||||
assert key.key == special_keys.CONTROL[cid]
|
||||
assert key.default_task == common.NamedInt(cid, default_task)
|
||||
assert key.flags == expected_flags
|
||||
assert list(key.flags) == flag_names
|
||||
assert list(key.group_mask) == group_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"responses, index, mapped_to, remappable_to, expected_mapping_flags",
|
||||
"responses, index, mapped_to, remappable_to, mapping_flags",
|
||||
[
|
||||
(fake_hidpp.responses_key, 1, "Right Click", common.UnsortedNamedInts(Right_Click=81, Left_Click=80), MappingFlag(0)),
|
||||
(fake_hidpp.responses_key, 2, "Left Click", None, MappingFlag.DIVERTED),
|
||||
(fake_hidpp.responses_key, 3, "Mouse Back Button", None, MappingFlag.DIVERTED | MappingFlag.PERSISTENTLY_DIVERTED),
|
||||
(fake_hidpp.responses_key, 4, "Mouse Forward Button", None, MappingFlag.DIVERTED | MappingFlag.RAW_XY_DIVERTED),
|
||||
(fake_hidpp.responses_key, 1, "Right Click", common.UnsortedNamedInts(Right_Click=81, Left_Click=80), []),
|
||||
(fake_hidpp.responses_key, 2, "Left Click", None, ["diverted"]),
|
||||
(fake_hidpp.responses_key, 3, "Mouse Back Button", None, ["diverted", "persistently diverted"]),
|
||||
(fake_hidpp.responses_key, 4, "Mouse Forward Button", None, ["diverted", "raw XY diverted"]),
|
||||
],
|
||||
)
|
||||
# these fields need access all the key data, so start by setting up a device and its key data
|
||||
def test_reprogrammable_key_v4_query(responses, index, mapped_to, remappable_to, expected_mapping_flags):
|
||||
def test_ReprogrammableKeyV4_query(responses, index, mapped_to, remappable_to, mapping_flags):
|
||||
device = fake_hidpp.Device(
|
||||
"KEY", responses=responses, feature=hidpp20_constants.SupportedFeature.REPROG_CONTROLS_V4, offset=5
|
||||
)
|
||||
|
|
@ -258,7 +230,7 @@ def test_reprogrammable_key_v4_query(responses, index, mapped_to, remappable_to,
|
|||
|
||||
assert key.mapped_to == mapped_to
|
||||
assert (key.remappable_to == remappable_to) or remappable_to is None
|
||||
assert key.mapping_flags == expected_mapping_flags
|
||||
assert list(key.mapping_flags) == mapping_flags
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -270,7 +242,7 @@ def test_reprogrammable_key_v4_query(responses, index, mapped_to, remappable_to,
|
|||
(fake_hidpp.responses_key, 4, False, False, False, 0x50, ["0056020000", "0056080000", "0056200000", "0056000050"]),
|
||||
],
|
||||
)
|
||||
def test_reprogrammable_key_v4_set(responses, index, diverted, persistently_diverted, rawXY_reporting, remap, sets, mocker):
|
||||
def test_ReprogrammableKeyV4_set(responses, index, diverted, persistently_diverted, rawXY_reporting, remap, sets, mocker):
|
||||
responses += [fake_hidpp.Response(r, 0x530, r) for r in sets]
|
||||
device = fake_hidpp.Device(
|
||||
"KEY", responses=responses, feature=hidpp20_constants.SupportedFeature.REPROG_CONTROLS_V4, offset=5
|
||||
|
|
@ -280,30 +252,28 @@ def test_reprogrammable_key_v4_set(responses, index, diverted, persistently_dive
|
|||
spy_request = mocker.spy(device, "request")
|
||||
|
||||
key = device.keys[index]
|
||||
_mapping_flags = key.mapping_flags
|
||||
_mapping_flags = list(key.mapping_flags)
|
||||
|
||||
if hidpp20.KeyFlag.DIVERTABLE in key.flags or not diverted:
|
||||
if "divertable" in key.flags or not diverted:
|
||||
key.set_diverted(diverted)
|
||||
else:
|
||||
with pytest.raises(exceptions.FeatureNotSupported):
|
||||
key.set_diverted(diverted)
|
||||
assert (MappingFlag.DIVERTED in key.mapping_flags) == (diverted and hidpp20.KeyFlag.DIVERTABLE in key.flags)
|
||||
assert ("diverted" in list(key.mapping_flags)) == (diverted and "divertable" in key.flags)
|
||||
|
||||
if hidpp20.KeyFlag.PERSISTENTLY_DIVERTABLE in key.flags or not persistently_diverted:
|
||||
if "persistently divertable" in key.flags or not persistently_diverted:
|
||||
key.set_persistently_diverted(persistently_diverted)
|
||||
else:
|
||||
with pytest.raises(exceptions.FeatureNotSupported):
|
||||
key.set_persistently_diverted(persistently_diverted)
|
||||
assert (hidpp20.MappingFlag.PERSISTENTLY_DIVERTED in key.mapping_flags) == (
|
||||
persistently_diverted and hidpp20.KeyFlag.PERSISTENTLY_DIVERTABLE in key.flags
|
||||
)
|
||||
assert ("persistently diverted" in key.mapping_flags) == (persistently_diverted and "persistently divertable" in key.flags)
|
||||
|
||||
if hidpp20.KeyFlag.RAW_XY in key.flags or not rawXY_reporting:
|
||||
if "raw XY" in key.flags or not rawXY_reporting:
|
||||
key.set_rawXY_reporting(rawXY_reporting)
|
||||
else:
|
||||
with pytest.raises(exceptions.FeatureNotSupported):
|
||||
key.set_rawXY_reporting(rawXY_reporting)
|
||||
assert (MappingFlag.RAW_XY_DIVERTED in key.mapping_flags) == (rawXY_reporting and hidpp20.KeyFlag.RAW_XY in key.flags)
|
||||
assert ("raw XY diverted" in list(key.mapping_flags)) == (rawXY_reporting and "raw XY" in key.flags)
|
||||
|
||||
if remap in key.remappable_to or remap == 0:
|
||||
key.remap(remap)
|
||||
|
|
@ -390,14 +360,14 @@ def test_KeysArrayV4_query_key(device, index, top, cid):
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"device, count, index, cid, task_id, flags, pos, group, gmask",
|
||||
"device, count, index, cid, tid, flags, pos, group, gmask",
|
||||
[
|
||||
(device_standard, 4, 0, 0x0011, 0x0012, 0xCDAB, 1, 2, 3),
|
||||
(device_standard, 6, 1, 0x0111, 0x0022, 0xCDAB, 1, 2, 3),
|
||||
(device_standard, 8, 3, 0x0311, 0x0032, 0xCDAB, 1, 2, 4),
|
||||
],
|
||||
)
|
||||
def test_KeysArrayV4__getitem(device, count, index, cid, task_id, flags, pos, group, gmask):
|
||||
def test_KeysArrayV4__getitem(device, count, index, cid, tid, flags, pos, group, gmask):
|
||||
keysarray = hidpp20.KeysArrayV4(device, count)
|
||||
|
||||
result = keysarray[index]
|
||||
|
|
@ -405,7 +375,7 @@ def test_KeysArrayV4__getitem(device, count, index, cid, task_id, flags, pos, gr
|
|||
assert result._device == device
|
||||
assert result.index == index
|
||||
assert result._cid == cid
|
||||
assert result._tid == task_id
|
||||
assert result._tid == tid
|
||||
assert result._flags == flags
|
||||
assert result.pos == pos
|
||||
assert result.group == group
|
||||
|
|
@ -413,7 +383,7 @@ def test_KeysArrayV4__getitem(device, count, index, cid, task_id, flags, pos, gr
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key, index", [(special_keys.CONTROL.Volume_Up_old, 2), (special_keys.CONTROL.Mute, 4), (special_keys.CONTROL.Next, None)]
|
||||
"key, index", [(special_keys.CONTROL.Volume_Up, 2), (special_keys.CONTROL.Mute, 4), (special_keys.CONTROL.Next, None)]
|
||||
)
|
||||
def test_KeysArrayV4_index(key, index):
|
||||
keysarray = hidpp20.KeysArrayV4(device_standard, 7)
|
||||
|
|
@ -451,7 +421,7 @@ device_key = fake_hidpp.Device(
|
|||
(special_keys.CONTROL.Virtual_Gesture_Button, 7, common.NamedInt(0x51, "Right Click"), None),
|
||||
],
|
||||
)
|
||||
def test_keys_array_v4_key(key, expected_index, expected_mapped_to, expected_remappable_to):
|
||||
def test_KeysArrayV4_key(key, expected_index, expected_mapped_to, expected_remappable_to):
|
||||
device_key._keys = _hidpp20.get_keys(device_key)
|
||||
device_key._keys._ensure_all_keys_queried()
|
||||
|
||||
|
|
@ -586,14 +556,14 @@ def test_param(responses, prm, id, index, size, value, default_value, write1, wr
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"responses, id, s, byte_count, expected_value, expected_string",
|
||||
"responses, id, s, byte_count, value, string",
|
||||
[
|
||||
(fake_hidpp.responses_gestures, 1, hidpp20.SpecGesture.DVI_FIELD_WIDTH, 1, 8, "[dvi field width=8]"),
|
||||
(fake_hidpp.responses_gestures, 2, hidpp20.SpecGesture.FIELD_WIDTHS, 1, 8, "[field widths=8]"),
|
||||
(fake_hidpp.responses_gestures, 3, hidpp20.SpecGesture.PERIOD_UNIT, 2, 2048, "[period unit=2048]"),
|
||||
(fake_hidpp.responses_gestures, 1, "DVI field width", 1, 8, "[DVI field width=8]"),
|
||||
(fake_hidpp.responses_gestures, 2, "field widths", 1, 8, "[field widths=8]"),
|
||||
(fake_hidpp.responses_gestures, 3, "period unit", 2, 2048, "[period unit=2048]"),
|
||||
],
|
||||
)
|
||||
def test_spec(responses, id, s, byte_count, expected_value, expected_string):
|
||||
def test_Spec(responses, id, s, byte_count, value, string):
|
||||
device = fake_hidpp.Device("GESTURE", responses=responses, feature=hidpp20_constants.SupportedFeature.GESTURE_2)
|
||||
gestures = _hidpp20.get_gestures(device)
|
||||
|
||||
|
|
@ -602,8 +572,8 @@ def test_spec(responses, id, s, byte_count, expected_value, expected_string):
|
|||
assert spec.id == id
|
||||
assert spec.spec == s
|
||||
assert spec.byte_count == byte_count
|
||||
assert spec.value == expected_value
|
||||
assert repr(spec) == expected_string
|
||||
assert spec.value == value
|
||||
assert repr(spec) == string
|
||||
|
||||
|
||||
def test_Gestures():
|
||||
|
|
@ -790,7 +760,7 @@ def test_LED_RGB_EffectsInfo(feature, cls, responses, readable, count, count_0):
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hex, expected_behavior, sector, address, typ, val, modifiers, data, byt",
|
||||
"hex, behavior, sector, address, typ, val, modifiers, data, byt",
|
||||
[
|
||||
("05010203", 0x0, 0x501, 0x0203, None, None, None, None, None),
|
||||
("15020304", 0x1, 0x502, 0x0304, None, None, None, None, None),
|
||||
|
|
@ -802,10 +772,10 @@ def test_LED_RGB_EffectsInfo(feature, cls, responses, readable, count, count_0):
|
|||
("709090A0", 0x7, None, None, None, None, None, None, b"\x70\x90\x90\xa0"),
|
||||
],
|
||||
)
|
||||
def test_button_bytes(hex, expected_behavior, sector, address, typ, val, modifiers, data, byt):
|
||||
def test_button_bytes(hex, behavior, sector, address, typ, val, modifiers, data, byt):
|
||||
button = hidpp20.Button.from_bytes(bytes.fromhex(hex))
|
||||
|
||||
assert getattr(button, "behavior", None) == expected_behavior
|
||||
assert getattr(button, "behavior", None) == behavior
|
||||
assert getattr(button, "sector", None) == sector
|
||||
assert getattr(button, "address", None) == address
|
||||
assert getattr(button, "type", None) == typ
|
||||
|
|
@ -882,7 +852,7 @@ hex3 = (
|
|||
(hex3, "", 2, 1, 16, 0, [0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF], "FFFFFFFF", "FFFFFFFFFFFFFFFFFFFFFF"),
|
||||
],
|
||||
)
|
||||
def test_onboard_profile_bytes(hex, name, sector, enabled, buttons, gbuttons, resolutions, button, lighting):
|
||||
def test_OnboardProfile_bytes(hex, name, sector, enabled, buttons, gbuttons, resolutions, button, lighting):
|
||||
profile = hidpp20.OnboardProfile.from_bytes(sector, enabled, buttons, gbuttons, bytes.fromhex(hex))
|
||||
|
||||
assert profile.name == name
|
||||
|
|
@ -903,7 +873,7 @@ def test_onboard_profile_bytes(hex, name, sector, enabled, buttons, gbuttons, re
|
|||
(fake_hidpp.responses_profiles_rom_2, "ONB", 1, 2, 2, 1, 254),
|
||||
],
|
||||
)
|
||||
def test_onboard_profiles_device(responses, name, count, buttons, gbuttons, sectors, size):
|
||||
def test_OnboardProfiles_device(responses, name, count, buttons, gbuttons, sectors, size):
|
||||
device = fake_hidpp.Device(
|
||||
name, True, 4.5, responses=responses, feature=hidpp20_constants.SupportedFeature.ONBOARD_PROFILES, offset=0x9
|
||||
)
|
||||
|
|
@ -920,5 +890,4 @@ def test_onboard_profiles_device(responses, name, count, buttons, gbuttons, sect
|
|||
assert profiles.size == size
|
||||
assert len(profiles.profiles) == count
|
||||
|
||||
yml_dump = yaml.dump(profiles)
|
||||
assert yaml.safe_load(yml_dump).to_bytes().hex() == profiles.to_bytes().hex()
|
||||
assert yaml.safe_load(yaml.dump(profiles)).to_bytes().hex() == profiles.to_bytes().hex()
|
||||
|
|
|
|||
|
|
@ -107,8 +107,8 @@ def test_get_battery_voltage():
|
|||
feature, battery = _hidpp20.get_battery_voltage(device)
|
||||
|
||||
assert feature == SupportedFeature.BATTERY_VOLTAGE
|
||||
assert battery.level == 92
|
||||
assert common.BatteryStatus.RECHARGING in battery.status
|
||||
assert battery.level == 90
|
||||
assert battery.status == common.BatteryStatus.RECHARGING
|
||||
assert battery.voltage == 0x1000
|
||||
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ def test_get_adc_measurement():
|
|||
feature, battery = _hidpp20.get_adc_measurement(device)
|
||||
|
||||
assert feature == SupportedFeature.ADC_MEASUREMENT
|
||||
assert battery.level == 92
|
||||
assert battery.level == 90
|
||||
assert battery.status == common.BatteryStatus.RECHARGING
|
||||
assert battery.voltage == 0x1000
|
||||
|
||||
|
|
@ -389,8 +389,8 @@ def test_decipher_battery_voltage():
|
|||
feature, battery = hidpp20.decipher_battery_voltage(report)
|
||||
|
||||
assert feature == SupportedFeature.BATTERY_VOLTAGE
|
||||
assert battery.level == 92
|
||||
assert common.BatteryStatus.RECHARGING in battery.status
|
||||
assert battery.level == 90
|
||||
assert battery.status == common.BatteryStatus.RECHARGING
|
||||
assert battery.voltage == 0x1000
|
||||
|
||||
|
||||
|
|
@ -410,7 +410,7 @@ def test_decipher_adc_measurement():
|
|||
feature, battery = hidpp20.decipher_adc_measurement(report)
|
||||
|
||||
assert feature == SupportedFeature.ADC_MEASUREMENT
|
||||
assert battery.level == 92
|
||||
assert battery.level == 90
|
||||
assert battery.status == common.BatteryStatus.RECHARGING
|
||||
assert battery.voltage == 0x1000
|
||||
|
||||
|
|
@ -438,47 +438,3 @@ def test_feature_flag_names(code, expected_flags):
|
|||
flags = common.flag_names(hidpp20_constants.FeatureFlag, code)
|
||||
|
||||
assert list(flags) == expected_flags
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code, expected_name",
|
||||
[
|
||||
(0x00, "Unknown Location"),
|
||||
(0x03, "Left Side"),
|
||||
],
|
||||
)
|
||||
def test_led_zone_locations(code, expected_name):
|
||||
assert hidpp20.LEDZoneLocations[code] == expected_name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"millivolt, expected_percentage",
|
||||
[
|
||||
(-1234, 0),
|
||||
(500, 0),
|
||||
(2000, 0),
|
||||
(3500, 0),
|
||||
(3519, 0),
|
||||
(3520, 1),
|
||||
(3559, 1),
|
||||
(3579, 2),
|
||||
(3646, 5),
|
||||
(3671, 10),
|
||||
(3717, 20),
|
||||
(3751, 30),
|
||||
(3778, 40),
|
||||
(3811, 50),
|
||||
(3859, 60),
|
||||
(3922, 70),
|
||||
(3989, 80),
|
||||
(4067, 90),
|
||||
(4180, 99),
|
||||
(4181, 100),
|
||||
(4186, 100),
|
||||
(4500, 100),
|
||||
],
|
||||
)
|
||||
def test_estimate_battery_level_percentage(millivolt, expected_percentage):
|
||||
percentage = hidpp20.estimate_battery_level_percentage(millivolt)
|
||||
|
||||
assert percentage == expected_percentage
|
||||
|
|
|
|||
|
|
@ -6,11 +6,8 @@ from logitech_receiver.common import Notification
|
|||
from logitech_receiver.hidpp10_constants import BoltPairingError
|
||||
from logitech_receiver.hidpp10_constants import PairingError
|
||||
from logitech_receiver.hidpp10_constants import Registers
|
||||
from logitech_receiver.hidpp20_constants import SupportedFeature
|
||||
from logitech_receiver.receiver import Receiver
|
||||
|
||||
from . import fake_hidpp
|
||||
|
||||
|
||||
class MockLowLevelInterface:
|
||||
def open_path(self, path):
|
||||
|
|
@ -25,314 +22,24 @@ class MockLowLevelInterface:
|
|||
def request(self, handle, devnumber, request_id, *params, **kwargs):
|
||||
pass
|
||||
|
||||
def find_paired_node(self, receiver_path: str, index: int, timeout: int):
|
||||
return None
|
||||
|
||||
def close(self, device_handle) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sub_id, notification_data, expected_error, expected_new_device",
|
||||
[
|
||||
(Registers.DISCOVERY_STATUS_NOTIFICATION, b"\x01", BoltPairingError.DEVICE_TIMEOUT, None),
|
||||
(
|
||||
Registers.DEVICE_DISCOVERY_NOTIFICATION,
|
||||
b"\x01\x01\x01\x01\x01\x01\x01\x01\x01",
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(Registers.PAIRING_STATUS_NOTIFICATION, b"\x02", BoltPairingError.FAILED, None),
|
||||
(Notification.PAIRING_LOCK, b"\x01", PairingError.DEVICE_TIMEOUT, None),
|
||||
(Notification.PAIRING_LOCK, b"\x02", PairingError.DEVICE_NOT_SUPPORTED, None),
|
||||
(Notification.PAIRING_LOCK, b"\x03", PairingError.TOO_MANY_DEVICES, None),
|
||||
(Notification.PAIRING_LOCK, b"\x06", PairingError.SEQUENCE_TIMEOUT, None),
|
||||
(Registers.PASSKEY_REQUEST_NOTIFICATION, b"\x06", None, None),
|
||||
(Registers.PASSKEY_PRESSED_NOTIFICATION, b"\x06", None, None),
|
||||
],
|
||||
)
|
||||
def test_process_receiver_notification(sub_id, notification_data, expected_error, expected_new_device):
|
||||
receiver: Receiver = Receiver(MockLowLevelInterface(), None, {}, True, None, None)
|
||||
notification = HIDPPNotification(0, 0, sub_id, 0x02, notification_data)
|
||||
|
||||
result = notifications.process_receiver_notification(receiver, notification)
|
||||
|
||||
assert result
|
||||
assert receiver.pairing.error == (None if expected_error is None else expected_error.name)
|
||||
assert receiver.pairing.new_device is expected_new_device
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hidpp_notification, expected",
|
||||
[
|
||||
(HIDPPNotification(0, 0, sub_id=Registers.BATTERY_STATUS, address=0, data=b"0x01"), False),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.NO_OPERATION, address=0, data=b"0x01"), False),
|
||||
(HIDPPNotification(0, 0, sub_id=0x40, address=0, data=b"0x01"), True),
|
||||
],
|
||||
)
|
||||
def test_process_device_notification(hidpp_notification, expected):
|
||||
device = fake_hidpp.Device()
|
||||
|
||||
result = notifications.process_device_notification(device, hidpp_notification)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hidpp_notification, expected",
|
||||
[
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0, data=b"0x01"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.DJ_PAIRING, address=0, data=b"0x01"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.CONNECTED, address=0, data=b"0x01"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.RAW_INPUT, address=0, data=b"0x01"), None),
|
||||
],
|
||||
)
|
||||
def test_process_dj_notification(hidpp_notification, expected):
|
||||
device = fake_hidpp.Device()
|
||||
|
||||
result = notifications._process_dj_notification(device, hidpp_notification)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hidpp_notification, expected",
|
||||
[
|
||||
(HIDPPNotification(0, 0, sub_id=Registers.BATTERY_STATUS, address=0, data=b"\x01\x00"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Registers.BATTERY_CHARGE, address=0, data=b"0x01\x00"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.RAW_INPUT, address=0, data=b"0x01"), None),
|
||||
],
|
||||
)
|
||||
def test_process_hidpp10_custom_notification(hidpp_notification, expected):
|
||||
device = fake_hidpp.Device()
|
||||
|
||||
result = notifications._process_hidpp10_custom_notification(device, hidpp_notification)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hidpp_notification, expected",
|
||||
[
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x02, data=b"0x01"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"0x01"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.DJ_PAIRING, address=0x00, data=b"0x01"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.DJ_PAIRING, address=0x02, data=b"0x01"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.DJ_PAIRING, address=0x03, data=b"0x01"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.DJ_PAIRING, address=0x03, data=b"0x4040"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.RAW_INPUT, address=0x00, data=b"0x01"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.POWER, address=0x00, data=b"0x01"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.POWER, address=0x01, data=b"0x01"), True),
|
||||
(HIDPPNotification(0, 0, sub_id=Notification.PAIRING_LOCK, address=0x01, data=b"0x01"), None),
|
||||
],
|
||||
)
|
||||
def test_process_hidpp10_notification(hidpp_notification, expected):
|
||||
fake_device = fake_hidpp.Device()
|
||||
fake_device.receiver = ["rec1", "rec2"]
|
||||
|
||||
result = notifications._process_hidpp10_notification(fake_device, hidpp_notification)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hidpp_notification, feature",
|
||||
[
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"0x01"),
|
||||
SupportedFeature.BATTERY_STATUS,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x02, data=b"0x01"),
|
||||
SupportedFeature.BATTERY_STATUS,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"0x01"),
|
||||
SupportedFeature.BATTERY_VOLTAGE,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x05, data=b"0x01"),
|
||||
SupportedFeature.BATTERY_VOLTAGE,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"0x01"),
|
||||
SupportedFeature.UNIFIED_BATTERY,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x02, data=b"0x01"),
|
||||
SupportedFeature.UNIFIED_BATTERY,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"0x01"),
|
||||
SupportedFeature.ADC_MEASUREMENT,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x02, data=b"0x01"),
|
||||
SupportedFeature.ADC_MEASUREMENT,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"01234GOOD"),
|
||||
SupportedFeature.SOLAR_DASHBOARD,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x10, data=b"01234GOOD"),
|
||||
SupportedFeature.SOLAR_DASHBOARD,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x20, data=b"01234GOOD"),
|
||||
SupportedFeature.SOLAR_DASHBOARD,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x02, data=b"01234GOOD"),
|
||||
SupportedFeature.SOLAR_DASHBOARD,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x02, data=b"CHARGENOTGOOD"),
|
||||
SupportedFeature.SOLAR_DASHBOARD,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"\x01\x01\x02"),
|
||||
SupportedFeature.WIRELESS_DEVICE_STATUS,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x02, data=b"0x01"),
|
||||
SupportedFeature.WIRELESS_DEVICE_STATUS,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"0x01"),
|
||||
SupportedFeature.TOUCHMOUSE_RAW_POINTS,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x10, data=b"0x01"),
|
||||
SupportedFeature.TOUCHMOUSE_RAW_POINTS,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x05, data=b"0x01"),
|
||||
SupportedFeature.TOUCHMOUSE_RAW_POINTS,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"0x01"),
|
||||
SupportedFeature.REPROG_CONTROLS,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x02, data=b"0x01"),
|
||||
SupportedFeature.REPROG_CONTROLS,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"0x01"),
|
||||
SupportedFeature.BACKLIGHT2,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(
|
||||
0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"\x01\x01\x01\x01\x01\x01\x01\x01"
|
||||
),
|
||||
SupportedFeature.REPROG_CONTROLS_V4,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x10, data=b"0x01"),
|
||||
SupportedFeature.REPROG_CONTROLS_V4,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x20, data=b"0x01"),
|
||||
SupportedFeature.REPROG_CONTROLS_V4,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"0x01"),
|
||||
SupportedFeature.HIRES_WHEEL,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x10, data=b"0x01"),
|
||||
SupportedFeature.HIRES_WHEEL,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x02, data=b"0x01"),
|
||||
SupportedFeature.HIRES_WHEEL,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"0x01"),
|
||||
SupportedFeature.ONBOARD_PROFILES,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x20, data=b"0x01"),
|
||||
SupportedFeature.ONBOARD_PROFILES,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x00, data=b"0x01"),
|
||||
SupportedFeature.BRIGHTNESS_CONTROL,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x10, data=b"0x01"),
|
||||
SupportedFeature.BRIGHTNESS_CONTROL,
|
||||
),
|
||||
(
|
||||
HIDPPNotification(0, 0, sub_id=Notification.CONNECT_DISCONNECT, address=0x20, data=b"0x01"),
|
||||
SupportedFeature.BRIGHTNESS_CONTROL,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_process_feature_notification(mocker, hidpp_notification, feature):
|
||||
fake_device = fake_hidpp.Device()
|
||||
fake_device.receiver = ["rec1", "rec2"]
|
||||
|
||||
result = notifications._process_feature_notification(fake_device, hidpp_notification)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_process_receiver_notification_invalid(mocker):
|
||||
invalid_sub_id = 0x30
|
||||
notification_data = b"\x02"
|
||||
notification = HIDPPNotification(0, 0, invalid_sub_id, 0, notification_data)
|
||||
mock_receiver = mocker.Mock()
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
notifications.process_receiver_notification(mock_receiver, notification)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sub_id, notification_data, expected",
|
||||
[
|
||||
(Notification.NO_OPERATION, b"\x00", False),
|
||||
],
|
||||
)
|
||||
def test_process_device_notification_extended(mocker, sub_id, notification_data, expected):
|
||||
device = mocker.Mock()
|
||||
device.handle_notification.return_value = None
|
||||
device.protocol = 2.0
|
||||
notification = HIDPPNotification(0, 0, sub_id, 0, notification_data)
|
||||
|
||||
result = notifications.process_device_notification(device, notification)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_handle_device_discovery():
|
||||
receiver: Receiver = Receiver(MockLowLevelInterface(), None, {}, True, None, None)
|
||||
sub_id = Registers.DISCOVERY_STATUS_NOTIFICATION
|
||||
data = b"\x01\x02\x03\x04\x05\x06"
|
||||
notification = HIDPPNotification(0, 0, sub_id, 0, data)
|
||||
|
||||
result = notifications.handle_device_discovery(receiver, notification)
|
||||
result = notifications._process_receiver_notification(receiver, notification)
|
||||
|
||||
assert result
|
||||
|
||||
|
||||
def test_handle_passkey_request(mocker):
|
||||
receiver_mock = mocker.Mock()
|
||||
data = b"\x01"
|
||||
notification = HIDPPNotification(0, 0, 0, 0, data)
|
||||
|
||||
result = notifications.handle_passkey_request(receiver_mock, notification)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_handle_passkey_pressed(mocker):
|
||||
receiver = mocker.Mock()
|
||||
sub_id = Registers.DISCOVERY_STATUS_NOTIFICATION
|
||||
data = b"\x01\x02\x03\x04\x05\x06"
|
||||
notification = HIDPPNotification(0, 0, sub_id, 0, data)
|
||||
|
||||
result = notifications.handle_passkey_pressed(receiver, notification)
|
||||
|
||||
assert result is True
|
||||
assert receiver.pairing.error == expected_error
|
||||
assert receiver.pairing.new_device is expected_new_device
|
||||
|
|
|
|||
|
|
@ -12,13 +12,6 @@ from logitech_receiver import receiver
|
|||
from . import fake_hidpp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nano_recv():
|
||||
device_info = DeviceInfo("12", product_id=0xC534)
|
||||
mock_low_level = LowLevelInterfaceFake(responses_lacking)
|
||||
yield receiver.create_receiver(mock_low_level, device_info, lambda x: x)
|
||||
|
||||
|
||||
class LowLevelInterfaceFake:
|
||||
def __init__(self, responses=None):
|
||||
self.responses = responses
|
||||
|
|
@ -196,119 +189,3 @@ def test_receiver_factory_no_device(device_info, responses):
|
|||
|
||||
with pytest.raises(exceptions.NoSuchDevice):
|
||||
r.device_pairing_information(1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address, data, expected_online, expected_encrypted",
|
||||
[
|
||||
(0x03, b"\x01\x02\x03", True, False),
|
||||
(0x10, b"\x61\x02\x03", False, True),
|
||||
],
|
||||
)
|
||||
def test_notification_information_nano_receiver(nano_recv, address, data, expected_online, expected_encrypted):
|
||||
_number = 0
|
||||
notification = base.HIDPPNotification(
|
||||
report_id=0x01,
|
||||
devnumber=0x52C,
|
||||
sub_id=0,
|
||||
address=address,
|
||||
data=data,
|
||||
)
|
||||
online, encrypted, wpid, kind = nano_recv.notification_information(_number, notification)
|
||||
|
||||
assert online == expected_online
|
||||
assert encrypted == expected_encrypted
|
||||
assert wpid == "0302"
|
||||
assert kind == "keyboard"
|
||||
|
||||
|
||||
def test_extract_serial_number():
|
||||
response = b'\x03\x16\xcc\x9c\xb4\x05\x06"\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
|
||||
serial_number = receiver.extract_serial(response[1:5])
|
||||
|
||||
assert serial_number == "16CC9CB4"
|
||||
|
||||
|
||||
def test_extract_max_devices():
|
||||
response = b'\x03\x16\xcc\x9c\xb4\x05\x06"\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
|
||||
max_devices = receiver.extract_max_devices(response)
|
||||
|
||||
assert max_devices == 6
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response, expected_remaining_pairings",
|
||||
[
|
||||
(b"\x00\x03\x00", -1),
|
||||
(b"\x00\x02\t", 4),
|
||||
],
|
||||
)
|
||||
def test_extract_remaining_pairings(response, expected_remaining_pairings):
|
||||
remaining_pairings = receiver.extract_remaining_pairings(response)
|
||||
|
||||
assert remaining_pairings == expected_remaining_pairings
|
||||
|
||||
|
||||
def test_extract_codename():
|
||||
response = b"A\x04K520"
|
||||
|
||||
codename = receiver.extract_codename(response)
|
||||
|
||||
assert codename == "K520"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"power_switch_byte, expected_location",
|
||||
[
|
||||
(b"\x01", "base"),
|
||||
(b"\x09", "top_edge"),
|
||||
(b"\x0c", "bottom_edge"),
|
||||
(b"\x00", "unknown"),
|
||||
(b"\x0f", "unknown"),
|
||||
],
|
||||
)
|
||||
def test_extract_power_switch_location(power_switch_byte, expected_location):
|
||||
response = b"\x19\x8e>\xb8\x06\x00\x00\x00\x00" + power_switch_byte + b"\x00\x00\x00\x00\x00"
|
||||
|
||||
ps_location = receiver.extract_power_switch_location(response)
|
||||
|
||||
assert ps_location == expected_location
|
||||
|
||||
|
||||
def test_extract_connection_count():
|
||||
response = b"\x00\x03\x00"
|
||||
|
||||
connection_count = receiver.extract_connection_count(response)
|
||||
|
||||
assert connection_count == 3
|
||||
|
||||
|
||||
def test_extract_wpid():
|
||||
response = b"@\x82"
|
||||
|
||||
res = receiver.extract_wpid(response)
|
||||
|
||||
assert res == "4082"
|
||||
|
||||
|
||||
def test_extract_polling_rate():
|
||||
response = b"\x08@\x82\x04\x02\x02\x07\x00\x00\x00\x00\x00\x00\x00"
|
||||
|
||||
polling_rate = receiver.extract_polling_rate(response)
|
||||
|
||||
assert polling_rate == 130
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data, expected_device_kind",
|
||||
[
|
||||
(0x00, "unknown"),
|
||||
(0x03, "numpad"),
|
||||
],
|
||||
)
|
||||
def test_extract_device_kind(data, expected_device_kind):
|
||||
device_kind = receiver.extract_device_kind(data)
|
||||
|
||||
assert str(device_kind) == expected_device_kind
|
||||
|
|
|
|||
|
|
@ -111,7 +111,6 @@ class FeatureTest:
|
|||
offset: int = 0x04
|
||||
version: int = 0x00
|
||||
rewrite: bool = False
|
||||
readable: bool = True
|
||||
|
||||
|
||||
simple_tests = [
|
||||
|
|
@ -268,7 +267,7 @@ simple_tests = [
|
|||
fake_hidpp.Response("0A", 0x0410, "0A"),
|
||||
),
|
||||
Setup(
|
||||
FeatureTest(settings_templates.ADCPower, 5, 0xA, version=0x03),
|
||||
FeatureTest(settings_templates.ADCPower, 5, 0xA),
|
||||
fake_hidpp.Response("05", 0x0410),
|
||||
fake_hidpp.Response("0A", 0x0420, "0A"),
|
||||
),
|
||||
|
|
@ -300,7 +299,6 @@ simple_tests = [
|
|||
settings_templates.RGBEffectSetting,
|
||||
hidpp20.LEDEffectSetting(ID=3, intensity=0x50, period=0x100),
|
||||
hidpp20.LEDEffectSetting(ID=2, color=0x505050, speed=0x50),
|
||||
readable=False,
|
||||
),
|
||||
fake_hidpp.Response("FFFF0100000001", 0x0400, "FFFF00"),
|
||||
fake_hidpp.Response("0000000102", 0x0400, "00FF00"),
|
||||
|
|
@ -310,12 +308,7 @@ simple_tests = [
|
|||
fake_hidpp.Response("00015050505000000000000001", 0x0410, "00015050505000000000000001"),
|
||||
),
|
||||
Setup(
|
||||
FeatureTest(
|
||||
settings_templates.RGBEffectSetting,
|
||||
None,
|
||||
hidpp20.LEDEffectSetting(ID=3, intensity=0x60, period=0x101),
|
||||
readable=False,
|
||||
),
|
||||
FeatureTest(settings_templates.RGBEffectSetting, None, hidpp20.LEDEffectSetting(ID=3, intensity=0x60, period=0x101)),
|
||||
fake_hidpp.Response("FFFF0100000001", 0x0400, "FFFF00"),
|
||||
fake_hidpp.Response("0000000102", 0x0400, "00FF00"),
|
||||
fake_hidpp.Response("0000000300040005", 0x0400, "000000"),
|
||||
|
|
@ -323,12 +316,7 @@ simple_tests = [
|
|||
fake_hidpp.Response("00000000000000010160000001", 0x0410, "00000000000000010160000001"),
|
||||
),
|
||||
Setup(
|
||||
FeatureTest(
|
||||
settings_templates.RGBEffectSetting,
|
||||
None,
|
||||
hidpp20.LEDEffectSetting(ID=3, intensity=0x60, period=0x101),
|
||||
readable=False,
|
||||
),
|
||||
FeatureTest(settings_templates.RGBEffectSetting, None, hidpp20.LEDEffectSetting(ID=3, intensity=0x60, period=0x101)),
|
||||
fake_hidpp.Response("FF000200020004000000000000000000", 0x0400, "FFFF00"),
|
||||
fake_hidpp.Response("00000002040000000000000000000000", 0x0400, "00FF00"),
|
||||
fake_hidpp.Response("00000000000000000000000000000000", 0x0400, "000000"),
|
||||
|
|
@ -499,7 +487,7 @@ def mock_gethostname(mocker):
|
|||
@pytest.mark.parametrize("test", simple_tests)
|
||||
def test_simple_template(test, mocker, mock_gethostname):
|
||||
tst = test.test
|
||||
print("TEST", tst.sclass, tst.sclass.feature)
|
||||
print("TEST", tst.sclass.feature)
|
||||
device = fake_hidpp.Device(responses=test.responses, feature=tst.sclass.feature, offset=tst.offset, version=tst.version)
|
||||
spy_request = mocker.spy(device, "request")
|
||||
|
||||
|
|
@ -513,12 +501,11 @@ def test_simple_template(test, mocker, mock_gethostname):
|
|||
elif test.choices is not None:
|
||||
assert setting.choices == test.choices
|
||||
|
||||
if tst.readable:
|
||||
value = setting.read(cached=False)
|
||||
assert value == tst.initial_value
|
||||
value = setting.read(cached=False)
|
||||
assert value == tst.initial_value
|
||||
|
||||
cached_value = setting.read(cached=True)
|
||||
assert cached_value == tst.initial_value
|
||||
cached_value = setting.read(cached=True)
|
||||
assert cached_value == tst.initial_value
|
||||
|
||||
write_value = setting.write(tst.write_value) if tst.write_value is not None else None
|
||||
assert write_value == tst.write_value
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import pytest
|
||||
|
||||
from logitech_receiver import settings_validator
|
||||
from logitech_receiver import settings
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -20,6 +20,6 @@ from logitech_receiver import settings_validator
|
|||
],
|
||||
)
|
||||
def test_bool_or_toggle(current, new, expected):
|
||||
result = settings_validator.bool_or_toggle(current=current, new=new)
|
||||
result = settings.bool_or_toggle(current=current, new=new)
|
||||
|
||||
assert result == expected
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
from solaar.gtk import create_parser
|
||||
|
||||
|
||||
def test_arg_parse():
|
||||
parser = create_parser()
|
||||
res = parser.parse_args([])
|
||||
|
||||
assert res.debug == 0
|
||||
assert res.hidraw_path is None
|
||||
assert res.restart_on_wake_up is False
|
||||
assert res.window is None
|
||||
assert res.battery_icons is None
|
||||
assert res.tray_icon_size is None
|
||||
|
||||
|
||||
def test_arg_parse_debug():
|
||||
parser = create_parser()
|
||||
res = parser.parse_args(["--debug"])
|
||||
|
||||
assert res.debug == 1
|
||||
|
||||
|
||||
def test_arg_parse_version():
|
||||
parser = create_parser()
|
||||
res = parser.parse_args(["version"])
|
||||
|
||||
assert res
|
||||
|
|
@ -10,12 +10,12 @@ from solaar.ui import common
|
|||
"reason, expected_in_title, expected_in_text",
|
||||
[
|
||||
(
|
||||
common.ErrorReason.PERMISSIONS,
|
||||
"permissions",
|
||||
"Permissions error",
|
||||
"not have permission to open",
|
||||
),
|
||||
(common.ErrorReason.NO_DEVICE, "connect to device error", "error connecting"),
|
||||
(common.ErrorReason.UNPAIR, "Unpairing failed", "receiver returned an error"),
|
||||
("nodevice", "connect to device error", "error connecting"),
|
||||
("unpair", "Unpairing failed", "receiver returned an error"),
|
||||
],
|
||||
)
|
||||
def test_create_error_text(reason, expected_in_title, expected_in_text):
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@ from unittest import mock
|
|||
|
||||
from solaar.ui import desktop_notifications
|
||||
|
||||
# depends on external environment, so make some tests dependent on availability
|
||||
|
||||
def test_notifications_available():
|
||||
result = desktop_notifications.notifications_available()
|
||||
|
||||
assert not result
|
||||
|
||||
|
||||
def test_init():
|
||||
result = desktop_notifications.init()
|
||||
|
||||
assert result == desktop_notifications.available
|
||||
assert not desktop_notifications.init()
|
||||
|
||||
|
||||
def test_uninit():
|
||||
|
|
@ -20,20 +22,7 @@ def test_alert():
|
|||
assert desktop_notifications.alert(reason) is None
|
||||
|
||||
|
||||
class MockDevice(mock.Mock):
|
||||
name = "MockDevice"
|
||||
|
||||
def close():
|
||||
return True
|
||||
|
||||
|
||||
def test_show():
|
||||
dev = MockDevice()
|
||||
dev = mock.MagicMock()
|
||||
reason = "unknown"
|
||||
available = desktop_notifications.init()
|
||||
|
||||
result = desktop_notifications.show(dev, reason)
|
||||
if available:
|
||||
assert result is not None
|
||||
else:
|
||||
assert result is None
|
||||
assert desktop_notifications.show(dev, reason) is None
|
||||
|
|
|
|||
Loading…
Reference in New Issue