Compare commits
324 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bda869542 | ||
|
|
ce1adc7b03 | ||
|
|
fc68521731 | ||
|
|
c87730f1eb | ||
|
|
76346cd5aa | ||
|
|
705279097f | ||
|
|
36377fdd5a | ||
|
|
a427c66dc1 | ||
|
|
f0c64f5fb3 | ||
|
|
a0e19282ec | ||
|
|
e999b12246 | ||
|
|
d3216ea57a | ||
|
|
0b6a5fa108 | ||
|
|
ff23601183 | ||
|
|
aaabb5d811 | ||
|
|
ccf1ac5b6d | ||
|
|
46da00e214 | ||
|
|
1dd1ace327 | ||
|
|
a87ae59a93 | ||
|
|
8298db0891 | ||
|
|
93e90f4894 | ||
|
|
4c63bdb6ee | ||
|
|
441d608ca0 | ||
|
|
2e549371ef | ||
|
|
4d2a42d541 | ||
|
|
24fe69924b | ||
|
|
ebc4536b02 | ||
|
|
50fed28b3b | ||
|
|
d77cc9ad65 | ||
|
|
a440eb7fcc | ||
|
|
f9ce65fd18 | ||
|
|
b3ea338f86 | ||
|
|
d7b2834697 | ||
|
|
ff67790f18 | ||
|
|
3686920e85 | ||
|
|
dbc97d96d5 | ||
|
|
dc3412c83b | ||
|
|
29bf463509 | ||
|
|
f12632b45e | ||
|
|
7963215fa2 | ||
|
|
817c90e561 | ||
|
|
0fd262424e | ||
|
|
97b6b958c8 | ||
|
|
f739331dc2 | ||
|
|
ec5b406909 | ||
|
|
cff0110f81 | ||
|
|
b96d0bbe0b | ||
|
|
96adf6a026 | ||
|
|
e906b83103 | ||
|
|
2cb5fa4b97 | ||
|
|
44a647499c | ||
|
|
02e05e46b0 | ||
|
|
bc41badff1 | ||
|
|
5c94cf4d9f | ||
|
|
e2a9206d78 | ||
|
|
e6ecf94deb | ||
|
|
b8ccec37ed | ||
|
|
51630421b2 | ||
|
|
ab517577b5 | ||
|
|
15ee0662f1 | ||
|
|
2c070e92b3 | ||
|
|
a866de47fb | ||
|
|
632d4dd5a0 | ||
|
|
f942dbec41 | ||
|
|
6fa8ec6b86 | ||
|
|
137dd6b2ff | ||
|
|
bdb0e9589b | ||
|
|
0335dd003c | ||
|
|
8bea0121cc | ||
|
|
783bd5e4da | ||
|
|
68514d83c1 | ||
|
|
6409fc2832 | ||
|
|
dc28ab61c2 | ||
|
|
94f4c3230b | ||
|
|
62aaeac595 | ||
|
|
694caf635e | ||
|
|
924684b610 | ||
|
|
abc5a31c15 | ||
|
|
3c11eff55a | ||
|
|
001dce7ef5 | ||
|
|
3f24d52f7a | ||
|
|
2a363a6388 | ||
|
|
bebadc219c | ||
|
|
694513832d | ||
|
|
1a9725f540 | ||
|
|
c7a54cf7ec | ||
|
|
7066ec40c9 | ||
|
|
abea1c4341 | ||
|
|
217b9360e6 | ||
|
|
33a06ac834 | ||
|
|
03cfa12852 | ||
|
|
41ba24eee2 | ||
|
|
ed596666ee | ||
|
|
16bd8126b6 | ||
|
|
17150658bf | ||
|
|
f0ad2692b8 | ||
|
|
d033a3c8fc | ||
|
|
1613584c6a | ||
|
|
ebf8493e72 | ||
|
|
7a5a67c394 | ||
|
|
3fcc75f892 | ||
|
|
7b28423572 | ||
|
|
198067519d | ||
|
|
94155dbbf1 | ||
|
|
64943c90d9 | ||
|
|
637e562699 | ||
|
|
9b5e416755 | ||
|
|
d8f321a5e9 | ||
|
|
df2df301e2 | ||
|
|
cefc502db9 | ||
|
|
7d4f787344 | ||
|
|
e297f90e79 | ||
|
|
20e20ce827 | ||
|
|
90ab457ebe | ||
|
|
297ccb9cc1 | ||
|
|
d95068c3f5 | ||
|
|
3de575b697 | ||
|
|
41e652609b | ||
|
|
73ad98d5e4 | ||
|
|
b9557a46b6 | ||
|
|
5a03433f86 | ||
|
|
81567a98df | ||
|
|
bd00cc97ad | ||
|
|
3192fa1a34 | ||
|
|
9af67f0e1d | ||
|
|
382e0b6797 | ||
|
|
f5d80c30fa | ||
|
|
636f736765 | ||
|
|
e9a58fb3e0 | ||
|
|
ab52c4a7c0 | ||
|
|
3bf8a85866 | ||
|
|
d42524dec9 | ||
|
|
8894463f64 | ||
|
|
15aaba2802 | ||
|
|
fa3a9bc5b3 | ||
|
|
33c057feff | ||
|
|
810cda917a | ||
|
|
64ac437b7f | ||
|
|
207be464a5 | ||
|
|
f28a923d15 | ||
|
|
5e0c85a6d7 | ||
|
|
800d3498f4 | ||
|
|
918b584b95 | ||
|
|
83c380f85b | ||
|
|
fd17e47382 | ||
|
|
88787ab705 | ||
|
|
1a3f4dab36 | ||
|
|
3186d880fc | ||
|
|
1e6af7fa7d | ||
|
|
5d86c74df4 | ||
|
|
5cf7cbfd5d | ||
|
|
96364d2df3 | ||
|
|
378175f98f | ||
|
|
72c9dfc50c | ||
|
|
571cdb5f2d | ||
|
|
5f5c7cdcce | ||
|
|
ad3916e1b8 | ||
|
|
6903eeefcd | ||
|
|
c9d7d7234a | ||
|
|
c34fd3c2b0 | ||
|
|
b19c886426 | ||
|
|
96c9cc2aa4 | ||
|
|
d27f7285e0 | ||
|
|
5c736e9154 | ||
|
|
7c91d0b2db | ||
|
|
5ca9c0a6ba | ||
|
|
f54eeb7998 | ||
|
|
0bf7a78553 | ||
|
|
267b0a723d | ||
|
|
5a9725ee17 | ||
|
|
4c160d1723 | ||
|
|
b74e789715 | ||
|
|
0d7fc46a81 | ||
|
|
8bc42d20fb | ||
|
|
dd13993ff3 | ||
|
|
cdaffce463 | ||
|
|
dfb4ccc93f | ||
|
|
3636ed78bb | ||
|
|
03de6fb276 | ||
|
|
789d35450c | ||
|
|
62e8aacd9f | ||
|
|
8eb0aec3e8 | ||
|
|
8a0fc13f23 | ||
|
|
41768d9616 | ||
|
|
a822b2f237 | ||
|
|
dfafe15575 | ||
|
|
e6c833f635 | ||
|
|
7e9babdc79 | ||
|
|
01d76bb0ed | ||
|
|
3768354230 | ||
|
|
87afc3659e | ||
|
|
2e9aa64a2e | ||
|
|
e945f797a2 | ||
|
|
73c88210f7 | ||
|
|
510753ea67 | ||
|
|
c2a3bc7e55 | ||
|
|
b6f5f86c36 | ||
|
|
ba4fda00df | ||
|
|
1fcedeee70 | ||
|
|
2157fdb59c | ||
|
|
c5f74953ce | ||
|
|
ff6f7a8e22 | ||
|
|
8b0904ead0 | ||
|
|
9d5568f6e5 | ||
|
|
ba4bbd0118 | ||
|
|
862cef1f77 | ||
|
|
a19461b29d | ||
|
|
c90146df31 | ||
|
|
8518604155 | ||
|
|
de033267fa | ||
|
|
0d4fd4c537 | ||
|
|
1afcfe4b57 | ||
|
|
79ffbda903 | ||
|
|
2185a8390c | ||
|
|
0d12c6f229 | ||
|
|
0cd9c0c9b5 | ||
|
|
11e7cbde69 | ||
|
|
06fd32b501 | ||
|
|
badb76953d | ||
|
|
a36973916c | ||
|
|
15659a1ee4 | ||
|
|
194c385824 | ||
|
|
d1f9b9ca3d | ||
|
|
97d1e90ceb | ||
|
|
9f57955142 | ||
|
|
0dec545bfd | ||
|
|
3277015ab6 | ||
|
|
691e5878f9 | ||
|
|
bb559c0d7c | ||
|
|
1f85ec01e7 | ||
|
|
58ddb0d6cd | ||
|
|
46366b2430 | ||
|
|
0f4d1aebcd | ||
|
|
89233957dc | ||
|
|
c9e781e752 | ||
|
|
54aace050c | ||
|
|
cba3533869 | ||
|
|
ef6b7dec2c | ||
|
|
4e50e605a6 | ||
|
|
37e2ac80ba | ||
|
|
614a5dc633 | ||
|
|
1729189981 | ||
|
|
90c41dbe32 | ||
|
|
a7ad625023 | ||
|
|
8d0672ac3c | ||
|
|
a75c4b9679 | ||
|
|
46fafa0e68 | ||
|
|
99fc9c6fcb | ||
|
|
615499dce2 | ||
|
|
9907cb2875 | ||
|
|
65d3b406c0 | ||
|
|
b681aafaff | ||
|
|
32fef49ff8 | ||
|
|
4aada31b21 | ||
|
|
128ec43d70 | ||
|
|
0481950324 | ||
|
|
aa354dc4c4 | ||
|
|
2442299539 | ||
|
|
2aa462532a | ||
|
|
d5af19be8a | ||
|
|
e8ef262433 | ||
|
|
912afff173 | ||
|
|
0f8ab61ddf | ||
|
|
c76b0ef36b | ||
|
|
b1b9f01083 | ||
|
|
454e1601bd | ||
|
|
c1bc39f99f | ||
|
|
26667afea4 | ||
|
|
741c0861c2 | ||
|
|
3c1aa35067 | ||
|
|
40033c0183 | ||
|
|
8fb087be14 | ||
|
|
ea0eb66f39 | ||
|
|
70def31942 | ||
|
|
fdd2c79950 | ||
|
|
ae39ac46ba | ||
|
|
4578f5f6f1 | ||
|
|
c07c30baef | ||
|
|
af12f8df52 | ||
|
|
48ff85ab94 | ||
|
|
64a9aac0d5 | ||
|
|
ce197b7093 | ||
|
|
7b797f40f7 | ||
|
|
67829c5807 | ||
|
|
d9d67ed738 | ||
|
|
71d2a50cb4 | ||
|
|
25b9ba70d2 | ||
|
|
59b30706b8 | ||
|
|
f40a5cc7a9 | ||
|
|
6d4cf80c89 | ||
|
|
8ab8cb0225 | ||
|
|
3aa064b40f | ||
|
|
db93e9ab10 | ||
|
|
a7784b40ab | ||
|
|
86b55b9c25 | ||
|
|
7f5e156fa1 | ||
|
|
d67466298b | ||
|
|
9726b93a78 | ||
|
|
e316ed1bc2 | ||
|
|
a5ded24057 | ||
|
|
2113e63a75 | ||
|
|
104556e7a3 | ||
|
|
be83dac209 | ||
|
|
c23ebcd267 | ||
|
|
5a63e44d58 | ||
|
|
244d0ee88a | ||
|
|
089b85676f | ||
|
|
cece723ea4 | ||
|
|
c29231bc6b | ||
|
|
faf27ca323 | ||
|
|
815dce07be | ||
|
|
90b0db6c3b | ||
|
|
c9dc232951 | ||
|
|
469c04faaf | ||
|
|
675cd6ee34 | ||
|
|
193dbfda21 | ||
|
|
7d171b1d09 | ||
|
|
500b9998c5 | ||
|
|
a9ce033cc8 | ||
|
|
9882d99125 | ||
|
|
d0a3e474c7 | ||
|
|
f15a50b4b2 | ||
|
|
9d2cedbefe | ||
|
|
b321167304 |
22
.coveragerc
Normal file
@@ -0,0 +1,22 @@
|
||||
[run]
|
||||
branch = True
|
||||
|
||||
source =
|
||||
hid_parser
|
||||
hidapi
|
||||
keysyms
|
||||
logitech_receiver
|
||||
solaar
|
||||
|
||||
omit =
|
||||
*/tests/*
|
||||
*/setup.py
|
||||
*/__main__.py
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if __name__ == '__main__':
|
||||
if typing.TYPE_CHECKING
|
||||
|
||||
fail_under = 40
|
||||
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -8,13 +8,23 @@ assignees: ''
|
||||
---
|
||||
|
||||
**Information**
|
||||
<!-- Make sure that your issue is not one of the known issues in the Solaar documentation at https://pwr-solaar.github.io/Solaar/ -->
|
||||
<!-- Do not bother opening an issue for a version older than 1.1.8. Upgrade to the latest version and see if your issue persists. -->
|
||||
<!-- If you are not running the current version of Solaar, strongly consider upgrading to the newest version. -->
|
||||
<!-- Make sure that your issue is not one of the known issues in the
|
||||
Solaar documentation at https://pwr-solaar.github.io/Solaar/ -->
|
||||
<!-- Make sure that Solaar's udev rule is running by executing
|
||||
`ls -l /dev/hidraw*` and looking for + as the last character of the permissions. -->
|
||||
<!-- Do not bother opening an issue for a version older than 1.1.14.
|
||||
Upgrade to the current version and see if your issue persists. -->
|
||||
<!-- If you are not running the current version of Solaar,
|
||||
strongly consider upgrading to the current version. -->
|
||||
<!-- Note that some distributions have very old versions of Solaar
|
||||
as their default version. -->
|
||||
|
||||
- Solaar version (`solaar --version` or `git describe --tags` if cloned from this repository):
|
||||
- Distribution:
|
||||
- Kernel version (ex. `uname -srmo`): `KERNEL VERSION HERE`
|
||||
- Kernel version (ex. `uname -srmo`):
|
||||
- Output of `solaar show`:
|
||||
<!-- To run `solaar show` in 1.1.18 you have to clone Solaar from this repository
|
||||
and `run bin/solaar show` from the download directory. -->
|
||||
|
||||
<details>
|
||||
|
||||
@@ -34,11 +44,11 @@ CONTENTS HERE
|
||||
|
||||
|
||||
- Errors or warrnings from Solaar:
|
||||
<!-- Under normal operation Solaar keeps a log of warning and error messages in ~/.tmp
|
||||
while it is running as a file starting with 'Solaar'.
|
||||
<!-- Under normal operation Solaar keeps a log of warning and error messages
|
||||
in ~/.tmp while it is running, as a file starting with 'Solaar'.
|
||||
If this file is not available or does not have useful information you can
|
||||
run Solaar as `solaar -dd`, after killing any running Solaar processes to
|
||||
have Solaar log informational, warning, and error messages to stdout. -->
|
||||
run Solaar as `solaar -ddd`, after killing any running Solaar processes to
|
||||
have Solaar log debug, informational, warning, and error messages to stdout. -->
|
||||
|
||||
|
||||
**Describe the bug**
|
||||
|
||||
52
.github/workflows/gh-pages.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: Deploy to GitHub Pages
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: 'pages'
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install mkdocs mkdocs-rtd-dropdown mkdocs-mermaid2-plugin mkdocstrings[python]
|
||||
|
||||
- name: Build and deploy
|
||||
run: |
|
||||
mkdocs build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: 'site'
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
88
.github/workflows/tests.yml
vendored
@@ -8,7 +8,8 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7, 3.12]
|
||||
python-version: [3.13]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -19,10 +20,16 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install Ubuntu dependencies
|
||||
- name: Install Ubuntu dependencies for python 3.8
|
||||
if: matrix.python-version == '3.8'
|
||||
run: |
|
||||
make install_apt
|
||||
|
||||
- name: Install Ubuntu dependencies for python 3.13
|
||||
if: matrix.python-version == '3.13'
|
||||
run: |
|
||||
make install_apt_python3.13
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
make install_pip PIP_ARGS='.["test"]'
|
||||
@@ -31,30 +38,53 @@ jobs:
|
||||
run: |
|
||||
make test
|
||||
|
||||
# macos-tests:
|
||||
# runs-on: macos-latest
|
||||
#
|
||||
# strategy:
|
||||
# matrix:
|
||||
# python-version: [3.7, 3.12]
|
||||
#
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v4
|
||||
#
|
||||
# - name: Set up Python ${{ matrix.python-version }}
|
||||
# uses: actions/setup-python@v5
|
||||
# with:
|
||||
# python-version: ${{ matrix.python-version }}
|
||||
#
|
||||
# - name: Set up macOS dependencies
|
||||
# run: |
|
||||
# make install_brew
|
||||
#
|
||||
# - name: Install Python dependencies
|
||||
# run: |
|
||||
# make install_pip PIP_ARGS='.["test"]'
|
||||
#
|
||||
# - name: Run tests on macOS
|
||||
# run: |
|
||||
# make test
|
||||
- name: Upload coverage to Codecov
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: codecov/codecov-action@v4.5.0
|
||||
with:
|
||||
directory: ./coverage/reports/
|
||||
env_vars: OS, PYTHON
|
||||
files: ./coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
macos-tests:
|
||||
runs-on: macos-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.13]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Set up macOS dependencies
|
||||
run: |
|
||||
make install_brew
|
||||
- name: Add Homebrew's library directory to dyld search path
|
||||
run: |
|
||||
echo "DYLD_FALLBACK_LIBRARY_PATH=$(brew --prefix)/lib:$DYLD_FALLBACK_LIBRARY_PATH" >> $GITHUB_ENV
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
make install_pip PIP_ARGS='.["test"]'
|
||||
- name: Run tests on macOS
|
||||
run: |
|
||||
pytest --cov --cov-report=xml
|
||||
- name: Upload coverage to Codecov
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: codecov/codecov-action@v4.5.0
|
||||
with:
|
||||
directory: ./coverage/reports/
|
||||
env_vars: OS, PYTHON
|
||||
files: ./coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
6
.gitignore
vendored
@@ -23,3 +23,9 @@ __pycache__/
|
||||
/po/*.po~
|
||||
|
||||
/.idea/
|
||||
|
||||
.DS_Store
|
||||
._*
|
||||
|
||||
Pipfile
|
||||
Pipfile.lock
|
||||
|
||||
180
CHANGELOG.md
@@ -1,3 +1,183 @@
|
||||
# 1.1.19
|
||||
|
||||
* New Georgian translation
|
||||
* Remove test that doesn't work in older Pythons
|
||||
* Update help messages for CLI commands
|
||||
* Allow solaar config to change LED settings
|
||||
* Add python3-devel to install-dnf in Makefile
|
||||
* hidconsole can send an HID command non-interactively
|
||||
* Add info about new lightspeed receiver
|
||||
* Update Swedish and zh_TW translation
|
||||
* Fix bug when showing details about direct-connected device
|
||||
* Drop testing of Python before 3.13
|
||||
* Fix crash in solaar show when showing notification flags. (#3070)
|
||||
|
||||
# 1.1.18
|
||||
|
||||
* Fix crash when showing notification flags
|
||||
|
||||
# 1.1.17
|
||||
|
||||
* Add dark icons
|
||||
* Permit onboard profiles version 5
|
||||
* Update Russian and Polish translations
|
||||
* Add onboard profiles warning to sensitivity tooltip
|
||||
* Better error messages for solaar profile
|
||||
* Remove Solaar name for mice with WPID 4008
|
||||
* Prevent lock failure when showing debug messages
|
||||
* Replace color picker (#3028)
|
||||
* Add setting for HAPTIC feature
|
||||
* Add setting to adjust force needed for force-sensing buttons
|
||||
* Expand new settings type
|
||||
* Add new settings type for structure-backed setting
|
||||
* Use PATH instead of hardcoded absolute paths (#3014)
|
||||
* Add scroll ratchet force setting
|
||||
* Fix debug messages for MouseClick rule
|
||||
* Improve debug message for rule evaluation
|
||||
* App wrapper and launch agent scripts for MacOS
|
||||
* Ignore hidden features
|
||||
* Don't pop up window in response to ADC changes
|
||||
* Update bug report template
|
||||
* Fixed malformed doc file by adding closing tag
|
||||
* Fix error in low-level request for device with no recevier
|
||||
* Update documentation files
|
||||
|
||||
# 1.1.16
|
||||
|
||||
* Add new flags for reprogrammable keys feature
|
||||
* Correctly handle missing battery feature
|
||||
|
||||
# 1.1.15
|
||||
|
||||
* Correctly re-raise permissions exception
|
||||
* Add several new special keys and tasks
|
||||
* Update several translations
|
||||
* Center labels and remove buggy entry resizing logic
|
||||
* Add shape keys from Key POP Icon
|
||||
* Device and Action rule conditions match on codename and name
|
||||
* Fix listing hidpp10 devices - bytes vs string concatenation (#2856)
|
||||
* Add present flag, unset when internal error occurs, set when notification appears
|
||||
* Pause setting up features when error occurs; use ADC message to signal connection and disconnection
|
||||
* Fix listing of hidpp10 peripherals
|
||||
* Complete DEVICE_FEATURES to DeviceFeature transition for hidpp10 devices
|
||||
* Fix NOTIFICATION_FLAG to NotificationFlag transition leftovers
|
||||
* Fix github workflow stopping all matrix jobs when one of them fails
|
||||
* Fix ubuntu github CI
|
||||
* Update index.md
|
||||
* Python documentation appears to be broken so don't set it up
|
||||
* Improve documentation on onboard profiles
|
||||
* Use correct LOD values for extended adjustable dpi
|
||||
* Better support RGB Effects - not readable
|
||||
* Fix crash when asking for help about config
|
||||
* Fix error when updating ChoiceControlBig box
|
||||
* Add uninstallation docs
|
||||
* Handle unknown power switch locations again
|
||||
* Correctly handle selection of [empty] in rule editor
|
||||
* Handle `HIDError` in `hidapi.hidapi_impl._match()` (#2804)
|
||||
* Give ghost devices a path
|
||||
* Guard against typeerror when setting the value of a control box
|
||||
* Recover from errors in ping
|
||||
* Replace spaces by underscores when looking up features
|
||||
* Rewrote string concatenation/format with f strings
|
||||
* Fix logo not showing in about dialog box
|
||||
* Make typing-extensions dependency mandatory
|
||||
* Properly ignore unsupported locale
|
||||
* hidapi: skip unsupported devices and handle exception on open
|
||||
* Ignore macOS junk files and pipenv config
|
||||
* Fix ui desktop notifications test
|
||||
* hidpp20: Remove dependency to NamedInts
|
||||
* Estimate accurate battery level for some rechargable devices (#2745)
|
||||
* Upgrade desktop notifications tests to take notifications availability into account
|
||||
* Update tests to run on Python 3.13
|
||||
* Remove outdated logger enabled checks
|
||||
* Introduce GTK signal types
|
||||
* Introduce error types
|
||||
* Remove alias for SupportedFeature
|
||||
* Refactor process_device_notification
|
||||
* Refactor process_receiver_notification
|
||||
* Refactor receiver event handling
|
||||
* Introduce custom logger
|
||||
* Refactor notifications
|
||||
* Rename variable to full name notification
|
||||
* Test notifications
|
||||
* Test extraction of serial and max. devices
|
||||
* Refactor extraction of serial and max. devices
|
||||
* macOS: Fix int.from_bytes, int.to_bytes for show.py
|
||||
* macOS: Remove udev rule warning
|
||||
* macOS: Add support for Bluetooth devices
|
||||
* Add back and forward mouseclick actions
|
||||
* Speedup lookup of known receivers
|
||||
* Refactor device filtering
|
||||
* Reorder private functions and variable definitions
|
||||
* Turn filter_products_of_interest into a public function
|
||||
* Improve tests of known receivers
|
||||
* Refactor: Remove NamedInts and move enums where used
|
||||
* Add docstrings and type hints
|
||||
* Enforce rules on RuleComponentUI subclasses
|
||||
* Simplify settings UI class
|
||||
* Remove diversion alias
|
||||
* Refactor: Convert Kind to IntEnum
|
||||
* Split up huge settings module
|
||||
* Remove Python 2 specific path handling
|
||||
* Delete logging temp file on exit
|
||||
* Update Swedish translation
|
||||
|
||||
# 1.1.14
|
||||
|
||||
* Handle fake feature enums in show
|
||||
* Fix battery entries in config.yaml
|
||||
* Add ratchet setting for smart shift enhanced devices
|
||||
* Refactor Gesture into enum
|
||||
* Replace ERROR NamedInts by IntEnum (#2645)
|
||||
* Refactor hidpp20 to use enum
|
||||
* Update Polish, Swedish, Norwegian Nynorsk (nn), and Norwegian Bokmål (nb) translations
|
||||
* Use IntEnum for firmware and cidgroup constances
|
||||
* Change pairing error values to intenums
|
||||
* Fix initialization bug for PackedRangeControl
|
||||
* Add tests for feature class, process_notification, and key_is_down
|
||||
* Check all bits for extended report rate
|
||||
* Add type hints
|
||||
* Improve about dialog
|
||||
* Reduce dependencies
|
||||
* Refactor code
|
||||
* Improve testing
|
||||
* Allow unknown keys in Key rule conditions
|
||||
* Improve documentation for cli actions
|
||||
* Cycle sw_id to better guard against duplication of messages
|
||||
* Handle error return on root feature
|
||||
* Clean up documentation
|
||||
* Improve github interactions
|
||||
* Add information about Onboard Profiles overriding some settings
|
||||
* Add wording to README.md that Solaar is not a device driver
|
||||
* Clean up imports
|
||||
* Handle unknown device kinds
|
||||
* Fix broken links to Solaar logo
|
||||
* Use mkdocs for public documentation
|
||||
* Clean up setup.py
|
||||
* Remove Dead links in the AppStream file
|
||||
* Update about.py
|
||||
* Remove check on driver
|
||||
* Improve base module
|
||||
* Remove unnecessary receiver info 'hid_driver'
|
||||
* Convert HIDPPNotification to dataclass
|
||||
* Be defensive when converting battery status to string
|
||||
* Automatically detect packages in /lib
|
||||
* Clean up locale code
|
||||
* Improve rules documentation
|
||||
* Refactor creation of devices
|
||||
* Add headings to structure rules.md
|
||||
* Unify imports in logitech package
|
||||
* Don't ping device when getting name or codename
|
||||
* Use dataclasses and enums where useful
|
||||
* Introduce Device protocol and type hints
|
||||
* Add typing_extensions dependency
|
||||
* Move hidpp10 independent functions to module level
|
||||
* Fix macOS compatibility and reenable CI tests
|
||||
* Unify imports in hidapi package
|
||||
* Move screenshots into dedicated folder and add high-level graph of components
|
||||
* Update French and Chinese translations
|
||||
* Drop support for end-of-life Python 3.7
|
||||
|
||||
# 1.1.13
|
||||
|
||||
* Update Polish and Russian translations.
|
||||
|
||||
11
Makefile
@@ -19,9 +19,14 @@ install_apt:
|
||||
sudo apt update
|
||||
sudo apt install libdbus-1-dev libglib2.0-dev libgtk-3-dev libgirepository1.0-dev
|
||||
|
||||
install_apt_python3.13:
|
||||
@echo "Installing Solaar dependencies via apt"
|
||||
sudo apt update
|
||||
sudo apt install libdbus-1-dev libglib2.0-dev libgtk-3-dev libgirepository-2.0-dev gobject-introspection
|
||||
|
||||
install_dnf:
|
||||
@echo "Installing Solaar dependencies via dn"
|
||||
sudo dnf install gtk3 python3-gobject python3-dbus python3-pyudev python3-psutil python3-xlib python3-yaml
|
||||
@echo "Installing Solaar dependencies via dnf"
|
||||
sudo dnf install gtk3 python3-devel python3-gobject python3-dbus python3-pyudev python3-psutil python3-xlib python3-yaml
|
||||
|
||||
install_brew:
|
||||
@echo "Installing Solaar dependencies via brew"
|
||||
@@ -66,4 +71,4 @@ lint:
|
||||
|
||||
test:
|
||||
@echo "Running Solaar tests"
|
||||
pytest --cov=lib/ tests/
|
||||
pytest --cov --cov-report=xml
|
||||
|
||||
17
README.md
@@ -1,29 +1,32 @@
|
||||
# <img src="https://pwr-solaar.github.io/Solaar/assets/solaar.svg" width="60px"/> Solaar
|
||||
# <img src="https://pwr-solaar.github.io/Solaar/img/solaar.svg" width="60px"/> Solaar
|
||||
|
||||
Solaar is a Linux manager for many Logitech keyboards, mice, and other devices
|
||||
that connect wirelessly to a Unifying, Bolt, Lightspeed or Nano receiver
|
||||
as well as many Logitech devices that connect via a USB cable or Bluetooth.
|
||||
|
||||
Solaar is not a device driver and responds only to special messages from devices
|
||||
that are otherwise ignored by the Linux input system.
|
||||
|
||||
<a href="https://pwr-solaar.github.io/Solaar/index">More Information</a> -
|
||||
<a href="https://pwr-solaar.github.io/Solaar/usage">Usage</a> -
|
||||
<a href="https://pwr-solaar.github.io/Solaar/capabilities">Capabilities</a> -
|
||||
<a href="https://pwr-solaar.github.io/Solaar/rules">Rules</a> -
|
||||
<a href="https://pwr-solaar.github.io/Solaar/installation">Manual Installation</a>
|
||||
<a href="https://pwr-solaar.github.io/Solaar/installation">Manual Installation</a> -
|
||||
<a href="https://pwr-solaar.github.io/Solaar/issues">Known Issues</a>
|
||||
|
||||
|
||||
[](https://codecov.io/gh/pwr-Solaar/Solaar)
|
||||
[](../LICENSE.txt)
|
||||
|
||||
<p align="center">
|
||||
<img src="https://pwr-solaar.github.io/Solaar/Solaar-main-window-multiple.png" width="54%"/>
|
||||
<img src="https://pwr-solaar.github.io/Solaar/screenshots/Solaar-main-window-multiple.png" width="54%"/>
|
||||
 
|
||||
<img src="https://pwr-solaar.github.io/Solaar/Solaar-main-window-receiver.png" width="43%"/>
|
||||
<img src="https://pwr-solaar.github.io/Solaar/screenshots/Solaar-main-window-receiver.png" width="43%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://pwr-solaar.github.io/Solaar/Solaar-main-window-back-divert.png" width="49%"/>
|
||||
<img src="https://pwr-solaar.github.io/Solaar/screenshots/Solaar-main-window-back-divert.png" width="49%"/>
|
||||
 
|
||||
<img src="https://pwr-solaar.github.io/Solaar/Solaar-rule-editor.png" width="48%"/>
|
||||
<img src="https://pwr-solaar.github.io/Solaar/screenshots/Solaar-rule-editor.png" width="48%"/>
|
||||
</p>
|
||||
|
||||
Solaar supports:
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Notes on Major Changes in Releases
|
||||
|
||||
## Version 1.1.18
|
||||
|
||||
* Solaar is only guaranteed to work in Python 3.13 or later.
|
||||
|
||||
## Version 1.1.17
|
||||
|
||||
* Several new features have been added related to the MX Master 4
|
||||
* The scroll ratchet force can be adjusted
|
||||
* The force required to click the button under your thumb can be adjusted
|
||||
* The haptic force can be adjusted
|
||||
* Haptic feeback can be triggered by commands like `solaar config 'mx master 4' haptic-play 'HAPPY ALER'`
|
||||
|
||||
## Version 1.1.16
|
||||
|
||||
* Two bugs that were affecting users in 1.1.15 are fixed.
|
||||
|
||||
## Version 1.1.15
|
||||
|
||||
* Some key names have been changed to match Logitech names. Rules that use removed names will no longer work and will end up with a key of 0.
|
||||
* Device and Action rule conditions match on device codename and name
|
||||
* Solaar supports configuration of Bluetooth devices on macOS.
|
||||
|
||||
## Version 1.1.13
|
||||
|
||||
* Solaar will drop support for Python 3.7 immediately after version 1.1.13.
|
||||
|
||||
28
bin/solaar
@@ -21,30 +21,16 @@
|
||||
|
||||
def init_paths():
|
||||
"""Make the app work in the source tree."""
|
||||
import os.path as _path
|
||||
import os.path
|
||||
import sys
|
||||
|
||||
# Python 2 need conversion from utf-8 filenames
|
||||
# Python 3 might have problems converting back to UTF-8 in case of Unicode surrogates
|
||||
try:
|
||||
decoded_path = sys.path[0]
|
||||
sys.path[0].encode(sys.getfilesystemencoding())
|
||||
|
||||
except UnicodeError:
|
||||
sys.stderr.write(
|
||||
"ERROR: Solaar cannot recognize encoding of filesystem path, "
|
||||
"this may happen because non UTF-8 characters in the pathname.\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
prefix = _path.normpath(_path.join(_path.realpath(decoded_path), ".."))
|
||||
src_lib = _path.join(prefix, "lib")
|
||||
share_lib = _path.join(prefix, "share", "solaar", "lib")
|
||||
root = os.path.join(os.path.realpath(sys.path[0]), "..")
|
||||
prefix = os.path.normpath(root)
|
||||
src_lib = os.path.join(prefix, "lib")
|
||||
share_lib = os.path.join(prefix, "share", "solaar", "lib")
|
||||
for location in src_lib, share_lib:
|
||||
init_py = _path.join(location, "solaar", "__init__.py")
|
||||
# print ("sys.path[0]: checking", init_py)
|
||||
if _path.exists(init_py):
|
||||
# print ("sys.path[0]: found", location, "replacing", sys.path[0])
|
||||
init_py = os.path.join(location, "solaar", "__init__.py")
|
||||
if os.path.exists(init_py):
|
||||
sys.path[0] = location
|
||||
break
|
||||
|
||||
|
||||
132
docs/LICENSE.txt
Normal file
@@ -0,0 +1,132 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
||||
<https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change.
|
||||
b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License.
|
||||
c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
|
||||
b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
|
||||
c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
END OF TERMS AND CONDITIONS
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
one line to give the program's name and an idea of what it does.
|
||||
Copyright (C) yyyy name of author
|
||||
|
||||
This program is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU General Public License
|
||||
as published by the Free Software Foundation; either version 2
|
||||
of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program; if not, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details
|
||||
type `show w'. This is free software, and you are welcome
|
||||
to redistribute it under certain conditions; type `show c'
|
||||
for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright
|
||||
interest in the program `Gnomovision'
|
||||
(which makes passes at compilers) written
|
||||
by James Hacker.
|
||||
|
||||
signature of Moe Ghoul, 1 April 1989
|
||||
Moe Ghoul, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License.
|
||||
@@ -1,9 +0,0 @@
|
||||
title: Solaar
|
||||
description: Linux Device Manager for Logitech Unifying Receivers and Devices.
|
||||
tagline: Linux Device Manager for Logitech Unifying Receivers and Devices.
|
||||
owner: pwr-Solaar
|
||||
owner_url: https://github.com/pwr-Solaar
|
||||
repository: pwr-Solaar/Solaar
|
||||
show_downloads: false
|
||||
encoding: utf-8
|
||||
theme: jekyll-theme-slate
|
||||
@@ -1,53 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ site.lang | default: "en-US" }}">
|
||||
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,maximum-scale=2">
|
||||
<link rel="stylesheet" type="text/css" media="screen" href="{{ '/assets/css/style.css?v=' | append: site.github.build_revision | relative_url }}">
|
||||
<link rel="icon" type="image/png" href="{{ site.baseurl }}/assets/favicon.png" />
|
||||
|
||||
{% seo %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- HEADER -->
|
||||
<div id="header_wrap" class="outer">
|
||||
<header class="inner">
|
||||
{% if site.github.is_project_page %}
|
||||
<a id="forkme_banner" href="{{ site.github.repository_url }}">View on GitHub</a>
|
||||
{% endif %}
|
||||
<h1 id="project_title">
|
||||
<img src="{{ site.baseurl }}/assets/solaar.svg" style="margin-bottom: -10px; width: 48px; height: 48px; border: 0; box-shadow: none;" />
|
||||
{{ site.title | default: site.github.repository_name }}</h1>
|
||||
<h2 id="project_tagline">{{ site.description | default: site.github.project_tagline }}</h2>
|
||||
|
||||
{% if site.show_downloads %}
|
||||
<section id="downloads">
|
||||
<a class="zip_download_link" href="{{ site.github.zip_url }}">Download this project as a .zip file</a>
|
||||
<a class="tar_download_link" href="{{ site.github.tar_url }}">Download this project as a tar.gz file</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
<div id="main_content_wrap" class="outer">
|
||||
<section id="main_content" class="inner">
|
||||
{{ content }}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div id="footer_wrap" class="outer">
|
||||
<footer class="inner">
|
||||
{% if site.github.is_project_page %}
|
||||
<p class="copyright">{{ site.title | default: site.github.repository_name }} maintained by <a href="{{ site.github.owner_url }}">{{ site.github.owner_name }}</a></p>
|
||||
{% endif %}
|
||||
<p>Published with <a href="https://pages.github.com">GitHub Pages</a></p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,47 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ site.lang | default: "en-US" }}">
|
||||
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,maximum-scale=2">
|
||||
<link rel="stylesheet" type="text/css" media="screen" href="{{ '/assets/css/style.css?v=' | append: site.github.build_revision | relative_url }}">
|
||||
<link rel="icon" type="image/png" href="{{ site.baseurl }}/assets/favicon.png" />
|
||||
{% seo %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- HEADER -->
|
||||
<div id="header_wrap" class="outer">
|
||||
<header class="inner">
|
||||
{% if site.github.is_project_page %}
|
||||
<a id="forkme_banner" href="{{ site.github.repository_url }}">View on GitHub</a>
|
||||
{% endif %}
|
||||
|
||||
<h1 id="project_title">
|
||||
<a href="{{ site.baseurl }}" style="color: #fff;">
|
||||
<img src="{{ site.baseurl }}/assets/solaar.svg" style="margin-bottom: -10px; width: 48px; height: 48px; border: 0; box-shadow: none;" />
|
||||
{{ site.title | default: site.github.repository_name }}</h1>
|
||||
</a>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
<div id="main_content_wrap" class="outer">
|
||||
<section id="main_content" class="inner">
|
||||
{{ content }}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div id="footer_wrap" class="outer">
|
||||
<footer class="inner">
|
||||
{% if site.github.is_project_page %}
|
||||
<p class="copyright">{{ site.title | default: site.github.repository_name }} maintained by <a href="{{ site.github.owner_url }}">{{ site.github.owner_name }}</a></p>
|
||||
{% endif %}
|
||||
<p>Published with <a href="https://pages.github.com">GitHub Pages</a></p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -56,12 +56,12 @@ Bluetooth product ID.
|
||||
Solaar is able to pair and unpair devices with
|
||||
receivers as supported by the device and receiver.
|
||||
|
||||
For Unifying receivers, pairing adds a new paired device, but
|
||||
For Unifying and Bolt receivers, pairing adds a new paired device, but
|
||||
only if there is an open slot on the receiver. So these receivers need to
|
||||
be able to unpair devices that they have been paired with or else they will
|
||||
not have any open slots for pairing. Some other receivers, like the
|
||||
Nano receiver with USB ID `046d:c534`, can only pair with particular kinds of
|
||||
devices and pairing a new device replaces whatever device of that kind was
|
||||
not have any open slots for pairing. Some Nano and Lightspeed receivers, like the
|
||||
Nano receiver with USB ID `046d:c534`, can only pair with one keyboard and one mouse
|
||||
and pairing a new device replaces whatever device of that kind was
|
||||
previously paired to the receiver. These receivers cannot unpair. Further,
|
||||
some receivers can pair an unlimited number of times but others can only
|
||||
pair a limited number of times.
|
||||
@@ -69,18 +69,20 @@ pair a limited number of times.
|
||||
Bolt receivers add an authentication phase to pairing,
|
||||
where the user has type a passcode or click some buttons to authenticate the device.
|
||||
|
||||
Only some connections between receivers and devices are possible. In should
|
||||
Only some connections between receivers and devices are possible. It should
|
||||
be possible to connect any device with a Unifying logo on it to any receiver
|
||||
with a Unifying logo on it. Receivers without the Unifying logo probably
|
||||
can connect only to the kind of devices they were bought with and devices
|
||||
without the Unifying logo can probably only connect to the kind of receiver
|
||||
that they were bought with.
|
||||
with a Unifying logo on it and any device with a Bolt logo on it to any receiver
|
||||
with a Bolt logo on it.
|
||||
|
||||
Many receivers without the Unifying or Bolt logo
|
||||
can connect only to the model of devices they were bought with and many devices
|
||||
without the Unifying or Bolt logo can only connect to a receiver
|
||||
that matches the one they were bought with.
|
||||
|
||||
## Device Settings
|
||||
|
||||
Solaar can display quite a few changeable settings of receivers and devices.
|
||||
For a list of HID++ features and their support see [the features page](features).
|
||||
For a list of HID++ features and their support see [the features page](features.md).
|
||||
|
||||
Solaar does not do much beyond using the HID++ protocol to change the
|
||||
behavior of receivers and devices via changing their settings.
|
||||
@@ -178,7 +180,7 @@ For more information on Mouse Gestures rule conditions see
|
||||
|
||||
Solaar uses the standard Logitech names for keyboard keys. Some Logitech keyboards have different icons on some of their keys and have different functionality than suggested by these names.
|
||||
|
||||
Solaar is uses the standard US keyboard layout. This currently only matters for the `Per-key Lighting` setting. Users who want to have the key names for this setting reflect the keyboard layout that they use can create and edit `~/.config/solaar/keys.yaml` which contains a YAML dictionary of key names and locations. For example, switching the `Y` and `Z` keys can be done as:
|
||||
Solaar uses the standard US keyboard layout. This currently only matters for the `Per-key Lighting` setting. Users who want to have the key names for this setting reflect the keyboard layout that they use can create and edit `~/.config/solaar/keys.yaml` which contains a YAML dictionary of key names and locations. For example, switching the `Y` and `Z` keys can be done as:
|
||||
|
||||
Z: 25
|
||||
Y: 26
|
||||
@@ -186,13 +188,13 @@ Solaar is uses the standard US keyboard layout. This currently only matters for
|
||||
This is an experimental feature and may be modified or even eliminated.
|
||||
|
||||
|
||||
### Device Profiles
|
||||
### Onboard Profiles
|
||||
|
||||
Some mice store one or more profiles, which control aspects of the behavior of the device.
|
||||
Some mice store one or more profiles onboard. An onboard profile controls certain aspects of the behavior of the mouse, including the rate at which the mouse reports movement, the resolution of the the movement reports, what the mouse buttons do, LED effects, and maybe more. Solaar has a setting that switches between profiles or disables all profiles.
|
||||
|
||||
Profiles can control the rate at which the mouse reports movement, the resolution of the the movement reports, what the mouse buttons do, and its LED effects. Solaar can dump the entire set of profiles into a YAML file can load an entire set of profiles from a file. Users can edit the file to effect changes to the profiles. Solaar has a setting that switches between profiles or disables all profiles. When switching between profiles or using a button to change resolution Solaar keeps track of the changes in the settings for these features.
|
||||
When an onboard profile is active it may not be possible to change the aspects that the profile controls. This is often seen for the Report Rate setting. For some devices it is possible to make changes to the Sensitivity setting and to LED settings. These changes are likely to only be temporary and may be overridden when the device reconnects or when Solaar is restarted. This is in keeping with the intent of Onboard Profiles as controlling the device behavior. To make the changes to these settings permanent it is necessary to disable onboard profiles. Alternatively, multiple profiles can be set up as described below and these settings controlled by switching between the profiles.
|
||||
|
||||
When profiles are active changes cannot be made to the Report Rate setting. Changes can be made to the Sensitivity setting and to LED settings. To keep the profile values make these setting ignored.
|
||||
Solaar can dump the entire set of profiles into a YAML file and can load the entire set of profiles from a file. Users can edit the file to effect changes to the profiles.
|
||||
|
||||
A profile file has some bookkeeping information, including profile version and the name of the device, and a sequence of profiles.
|
||||
|
||||
@@ -247,7 +249,7 @@ See USB_HID_KEYCODES and HID_CONSUMERCODES in lib/logitech_receiver/special_keys
|
||||
|
||||
Buttons can also execute macros but Solaar does not provide any support for macros.
|
||||
|
||||
Lighting information is a sequence of lighting effects, with the first for the logo LEDs and the second for the side LEDs.
|
||||
Lighting information is a sequence of lighting effects, with the first usually for the logo LEDs and the second usually for the side LEDs.
|
||||
|
||||
The fields possible in an effect are:
|
||||
- ID: The kind of effect:
|
||||
|
||||
@@ -209,6 +209,7 @@ so what is important for support is the USB WPID or Bluetooth model ID.
|
||||
|
||||
| Device | WPID | HID++ |
|
||||
|------------------------------|------|-------|
|
||||
| G604 Wireless Gaming Mouse | 4085 | 4.2 |
|
||||
| PRO X Superlight Wireless | 4093 | 4.2 |
|
||||
|
||||
### Trackballs (Unifying)
|
||||
|
||||
84
docs/devices/G604 Wireless Gaming Mouse 4085.txt
Normal file
@@ -0,0 +1,84 @@
|
||||
solaar version 03cfa128
|
||||
|
||||
1: G604 Wireless Gaming Mouse
|
||||
Device path : /dev/hidraw6
|
||||
WPID : 4085
|
||||
Codename : G604
|
||||
Kind : mouse
|
||||
Protocol : HID++ 4.2
|
||||
Report Rate : 1ms
|
||||
Serial number: XXXXXXXX
|
||||
Model ID: B02440850000
|
||||
Unit ID: XXXXXXXX
|
||||
1: BL1 04.01.B0014
|
||||
0: MPM 21.01.B0014
|
||||
3:
|
||||
The power switch is located on the base.
|
||||
Supports 33 HID++ 2.0 features:
|
||||
0: ROOT {0000} V0
|
||||
1: FEATURE SET {0001} V0
|
||||
2: DEVICE FW VERSION {0003} V2
|
||||
Firmware: 1 BL1 04.01.B0014 0000B01B3067
|
||||
Firmware: 0 MPM 21.01.B0014 4085B01B3067
|
||||
Firmware: 3
|
||||
Unit ID: XXXXXXXX Model ID: B02440850000 Transport IDs: {'btleid': 'B024', 'wpid': '4085'}
|
||||
3: DEVICE NAME {0005} V0
|
||||
Name: G604 Wireless Gaming Mouse
|
||||
Kind: mouse
|
||||
4: WIRELESS DEVICE STATUS {1D4B} V0
|
||||
5: CONFIG CHANGE {0020} V0
|
||||
Configuration: 00000000000000000000000000000000
|
||||
6: BATTERY STATUS {1000} V0
|
||||
Battery: 30%, BatteryStatus.DISCHARGING, next level 15%.
|
||||
7: COLOR LED EFFECTS {8070} V4
|
||||
LED Control (saved): Device
|
||||
LED Control : Device
|
||||
LEDs Primary : None
|
||||
8: LED CONTROL {1300} V0
|
||||
9: ONBOARD PROFILES {8100} V0
|
||||
Device Mode: On-Board
|
||||
Onboard Profiles (saved): Profile 1
|
||||
Onboard Profiles : Profile 1
|
||||
10: MOUSE BUTTON SPY {8110} V0
|
||||
11: REPORT RATE {8060} V0
|
||||
Report Rate: 1ms
|
||||
Report Rate (saved): 1ms
|
||||
Report Rate : 1ms
|
||||
12: ADJUSTABLE DPI {2201} V1
|
||||
Sensitivity (DPI) (saved): 800
|
||||
Sensitivity (DPI) : 800
|
||||
13: DFUCONTROL SIGNED {00C2} V0
|
||||
14: DEVICE RESET {1802} V0
|
||||
15: unknown:1803 {0318} V0 internal, hidden
|
||||
16: OOBSTATE {1805} V0
|
||||
17: CONFIG DEVICE PROPS {1806} V4
|
||||
18: unknown:1813 {1318} V0 internal, hidden
|
||||
19: unknown:1830 {3018} V0 internal, hidden
|
||||
20: unknown:1890 {9018} V0 internal, hidden
|
||||
21: unknown:1891 {9118} V0 internal, hidden
|
||||
22: unknown:1861 {6118} V0 internal, hidden
|
||||
23: unknown:1801 {0118} V0 internal, hidden
|
||||
24: unknown:18B1 {B118} V0 internal, hidden
|
||||
25: unknown:1DF3 {F31D} V0 internal, hidden
|
||||
26: unknown:1E00 {001E} V0 hidden
|
||||
27: unknown:1EB0 {B01E} V0 internal, hidden
|
||||
28: unknown:1E22 {221E} V0 internal, hidden
|
||||
29: HIRES WHEEL {2121} V0
|
||||
Multiplier: 8
|
||||
Has invert: Normal wheel motion
|
||||
Has ratchet switch: Normal wheel mode
|
||||
High resolution mode
|
||||
HID notification
|
||||
Scroll Wheel Direction (saved): False
|
||||
Scroll Wheel Direction : False
|
||||
Scroll Wheel Resolution (saved): True
|
||||
Scroll Wheel Resolution : True
|
||||
Scroll Wheel Diversion (saved): False
|
||||
Scroll Wheel Diversion : False
|
||||
30: unknown:18C0 {C018} V0 internal, hidden
|
||||
31: CHANGE HOST {1814} V1
|
||||
Change Host : 1:host1
|
||||
32: HOSTS INFO {1815} V1
|
||||
Host 0 (unpaired): host1
|
||||
Host 1 (paired):
|
||||
Battery: 30%, BatteryStatus.DISCHARGING, next level 15%.
|
||||
100
docs/devices/MX Anywhere 3 for Business B02D.txt
Normal file
@@ -0,0 +1,100 @@
|
||||
solaar version 1.1.14
|
||||
|
||||
1: MX Anywhere 3 for Business
|
||||
Device path : None
|
||||
WPID : B02D
|
||||
Codename : MX Anywhere 3
|
||||
Kind : mouse
|
||||
Protocol : HID++ 4.5
|
||||
Serial number: 00000000
|
||||
Model ID: B02D00000000
|
||||
Unit ID: 00000000
|
||||
1: BL1 36.01.B0011
|
||||
0: RBM 15.01.B0011
|
||||
3:
|
||||
The power switch is located on the (unknown).
|
||||
Supports 35 HID++ 2.0 features:
|
||||
0: ROOT {0000} V0
|
||||
1: FEATURE SET {0001} V0
|
||||
2: DEVICE FW VERSION {0003} V4
|
||||
Firmware: 1 BL1 36.01.B0011 B02D1EEFD8F8
|
||||
Firmware: 0 RBM 15.01.B0011 B02D1EEFD8F8
|
||||
Firmware: 3
|
||||
Unit ID: 00000000 Model ID: B02D00000000 Transport IDs: {'btleid': 'B02D'}
|
||||
3: DEVICE NAME {0005} V0
|
||||
Name: MX Anywhere 3 for Business
|
||||
Kind: mouse
|
||||
4: WIRELESS DEVICE STATUS {1D4B} V0
|
||||
5: CONFIG CHANGE {0020} V0
|
||||
Configuration: 11000000000000000000000000000000
|
||||
6: CRYPTO ID {0021} V1
|
||||
7: DEVICE FRIENDLY NAME {0007} V0
|
||||
Friendly Name: MX Anywhere 3B
|
||||
8: UNIFIED BATTERY {1004} V3
|
||||
Battery: 75%, 0.
|
||||
9: REPROG CONTROLS V4 {1B04} V5
|
||||
Key/Button Actions : {Left Button:Left Click, Right Button:Right Click, Middle Button:Mouse Middle Button, Back Button:Mouse Back Button, Forward Button:Mouse Forward Button, Smart Shift:Smart Shift}
|
||||
Key/Button Diversion : {Middle Button:Regular, Back Button:Regular, Forward Button:Regular, Smart Shift:Diverted}
|
||||
10: CHANGE HOST {1814} V1
|
||||
Change Host : 2:archlinux
|
||||
11: HOSTS INFO {1815} V2
|
||||
Host 0 (paired): archlinux
|
||||
Host 1 (paired): archlinux
|
||||
Host 2 (unpaired):
|
||||
12: XY STATS {2250} V1
|
||||
13: ADJUSTABLE DPI {2201} V2
|
||||
Sensitivity (DPI) : 1000
|
||||
14: SMART SHIFT ENHANCED {2111} V0
|
||||
Scroll Wheel Ratcheted : Ratcheted
|
||||
Scroll Wheel Ratchet Speed : 15
|
||||
15: HIRES WHEEL {2121} V1
|
||||
Multiplier: 15
|
||||
Has invert: Normal wheel motion
|
||||
Has ratchet switch: Normal wheel mode
|
||||
Low resolution mode
|
||||
HID notification
|
||||
Scroll Wheel Direction : False
|
||||
Scroll Wheel Resolution : False
|
||||
Scroll Wheel Diversion : False
|
||||
16: WHEEL STATS {2251} V0
|
||||
17: DFUCONTROL {00C3} V0
|
||||
18: DEVICE RESET {1802} V0 internal, hidden, unknown:000010
|
||||
19: unknown:1803 {1803} V0 internal, hidden, unknown:000010
|
||||
20: CONFIG DEVICE PROPS {1806} V8 internal, hidden, unknown:000010
|
||||
21: unknown:1816 {1816} V0 internal, hidden, unknown:000010
|
||||
22: OOBSTATE {1805} V0 internal, hidden
|
||||
23: unknown:1830 {1830} V0 internal, hidden, unknown:000010
|
||||
24: unknown:1891 {1891} V7 internal, hidden, unknown:000008
|
||||
25: unknown:18A1 {18A1} V0 internal, hidden, unknown:000010
|
||||
26: unknown:1E00 {1E00} V0 hidden
|
||||
27: unknown:1E02 {1E02} V0 internal, hidden
|
||||
28: unknown:1602 {1602} V0
|
||||
29: unknown:1EB0 {1EB0} V0 internal, hidden, unknown:000010
|
||||
30: unknown:1861 {1861} V1 internal, hidden, unknown:000010
|
||||
31: unknown:9300 {9300} V1 internal, hidden, unknown:000010
|
||||
32: unknown:9001 {9001} V0 internal, hidden, unknown:000010
|
||||
33: unknown:1E22 {1E22} V0 internal, hidden, unknown:000010
|
||||
34: unknown:9205 {9205} V0 internal, hidden, unknown:000010
|
||||
Has 7 reprogrammable keys:
|
||||
0: Left Button , default: Left Click => Left Click
|
||||
mse, analytics key events, pos:0, group:1, group mask:g1
|
||||
reporting: default
|
||||
1: Right Button , default: Right Click => Right Click
|
||||
mse, analytics key events, pos:0, group:1, group mask:g1
|
||||
reporting: default
|
||||
2: Middle Button , default: Mouse Middle Button => Mouse Middle Button
|
||||
mse, reprogrammable, divertable, raw XY, analytics key events, pos:0, group:2, group mask:g1,g2
|
||||
reporting: default
|
||||
3: Back Button , default: Mouse Back Button => Mouse Back Button
|
||||
mse, reprogrammable, divertable, raw XY, analytics key events, unknown:000800, pos:0, group:2, group mask:g1,g2
|
||||
reporting: default
|
||||
4: Forward Button , default: Mouse Forward Button => Mouse Forward Button
|
||||
mse, reprogrammable, divertable, raw XY, analytics key events, unknown:000800, pos:0, group:2, group mask:g1,g2
|
||||
reporting: default
|
||||
5: Smart Shift , default: Smart Shift => Smart Shift
|
||||
mse, reprogrammable, divertable, raw XY, analytics key events, pos:0, group:2, group mask:g1,g2
|
||||
reporting: diverted, raw XY diverted
|
||||
6: Virtual Gesture Button , default: Virtual Gesture Button => Virtual Gesture Button
|
||||
divertable, virtual, raw XY, force raw XY, pos:0, group:3, group mask:empty
|
||||
reporting: default
|
||||
Battery: 75%, 0.
|
||||
BIN
docs/devices/MX Master 4.text
Normal file
@@ -1,59 +1,62 @@
|
||||
Solaar version 1.1.3
|
||||
Solaar version 1.1.14
|
||||
|
||||
1: PRO X Wireless
|
||||
2: PRO X Wireless
|
||||
Device path : None
|
||||
WPID : 4093
|
||||
Codename : PRO X
|
||||
Kind : mouse
|
||||
Protocol : HID++ 4.2
|
||||
Polling rate : 8 ms (125Hz)
|
||||
Serial number: 42F42E12
|
||||
Report Rate : 1ms
|
||||
Serial number: 8B24D1D1
|
||||
Model ID: 4093C0940000
|
||||
Unit ID: 42F42E12
|
||||
Bootloader: BL1 25.00.B0013
|
||||
Other:
|
||||
Firmware: MPM 25.01.B0018
|
||||
Unit ID: 8B24D1D1
|
||||
1: BL1 25.01.B0018
|
||||
3:
|
||||
0: MPM 25.01.B0018
|
||||
Supports 28 HID++ 2.0 features:
|
||||
0: ROOT {0000}
|
||||
1: FEATURE SET {0001}
|
||||
2: DEVICE FW VERSION {0003}
|
||||
Firmware: Bootloader BL1 25.00.B0013 AB00BE657A82
|
||||
Firmware: Other
|
||||
Firmware: Firmware MPM 25.01.B0018 4093FE92436C
|
||||
Unit ID: 42F42E12 Model ID: 4093C0940000 Transport IDs: {'wpid': '4093', 'usbid': 'C094'}
|
||||
3: DEVICE NAME {0005}
|
||||
0: ROOT {0000} V0
|
||||
1: FEATURE SET {0001} V0
|
||||
2: DEVICE FW VERSION {0003} V3
|
||||
Firmware: 1 BL1 25.01.B0018 AB00FE92436C
|
||||
Firmware: 3
|
||||
Firmware: 0 MPM 25.01.B0018 4093FE92436C
|
||||
Unit ID: 8B24D1D1 Model ID: 4093C0940000 Transport IDs: {'wpid': '4093', 'usbid': 'C094'}
|
||||
3: DEVICE NAME {0005} V0
|
||||
Name: PRO X Wireless
|
||||
Kind: mouse
|
||||
4: WIRELESS DEVICE STATUS {1D4B}
|
||||
5: RESET {0020}
|
||||
6: UNIFIED BATTERY {1004}
|
||||
7: COLOR LED EFFECTS {8070} internal, hidden
|
||||
8: ONBOARD PROFILES {8100}
|
||||
Device Mode: Host
|
||||
Onboard Profiles (saved): Disable
|
||||
Onboard Profiles : Disable
|
||||
9: MOUSE BUTTON SPY {8110}
|
||||
10: REPORT RATE {8060}
|
||||
Polling Rate (ms): 1
|
||||
Polling Rate (ms) (saved): 1
|
||||
Polling Rate (ms) : 1
|
||||
11: ADJUSTABLE DPI {2201}
|
||||
Sensitivity (DPI) (saved): 1000
|
||||
Sensitivity (DPI) : 1000
|
||||
12: unknown:1500 {1500}
|
||||
13: DEVICE RESET {1802} internal, hidden
|
||||
14: unknown:1803 {1803} internal, hidden
|
||||
15: CONFIG DEVICE PROPS {1806} internal, hidden
|
||||
16: unknown:1811 {1811} internal, hidden
|
||||
17: OOBSTATE {1805} internal, hidden
|
||||
18: unknown:1830 {1830} internal, hidden
|
||||
19: unknown:1890 {1890} internal, hidden
|
||||
20: unknown:1891 {1891} internal, hidden
|
||||
21: unknown:18A1 {18A1} internal, hidden
|
||||
22: unknown:1801 {1801} internal, hidden
|
||||
23: unknown:18B1 {18B1} internal, hidden
|
||||
24: unknown:1E00 {1E00} hidden
|
||||
25: unknown:1EB0 {1EB0} internal, hidden
|
||||
26: unknown:1863 {1863} internal, hidden
|
||||
27: unknown:1E22 {1E22} internal, hidden
|
||||
Battery: 76%, discharging.
|
||||
4: WIRELESS DEVICE STATUS {1D4B} V0
|
||||
5: CONFIG CHANGE {0020} V0
|
||||
Configuration: 11000000000000000000000000000000
|
||||
6: UNIFIED BATTERY {1004} V1
|
||||
Battery: 71%, 0.
|
||||
7: COLOR LED EFFECTS {8070} V4 internal, hidden
|
||||
LED Control : HID++ error {'number': 2, 'request': 1908, 'error': 5, 'params': b''}
|
||||
8: ONBOARD PROFILES {8100} V0
|
||||
Device Mode: On-Board
|
||||
Onboard Profiles (saved): Profile 1
|
||||
Onboard Profiles : Profile 1
|
||||
9: MOUSE BUTTON SPY {8110} V0
|
||||
10: REPORT RATE {8060} V0
|
||||
Report Rate: 1ms
|
||||
Report Rate (saved): 1ms
|
||||
Report Rate : 1ms
|
||||
11: ADJUSTABLE DPI {2201} V2
|
||||
Sensitivity (DPI) (saved): 800
|
||||
Sensitivity (DPI) : 800
|
||||
12: FORCE PAIRING {1500} V0
|
||||
13: DEVICE RESET {1802} V0 internal, hidden
|
||||
14: unknown:1803 {1803} V0 internal, hidden
|
||||
15: CONFIG DEVICE PROPS {1806} V4 internal, hidden
|
||||
16: unknown:1811 {1811} V0 internal, hidden
|
||||
17: OOBSTATE {1805} V0 internal, hidden
|
||||
18: unknown:1830 {1830} V0 internal, hidden
|
||||
19: unknown:1890 {1890} V5 internal, hidden
|
||||
20: unknown:1891 {1891} V5 internal, hidden
|
||||
21: unknown:18A1 {18A1} V0 internal, hidden
|
||||
22: unknown:1801 {1801} V0 internal, hidden
|
||||
23: unknown:18B1 {18B1} V0 internal, hidden
|
||||
24: unknown:1E00 {1E00} V0 hidden
|
||||
25: unknown:1EB0 {1EB0} V0 internal, hidden
|
||||
26: unknown:1863 {1863} V0 internal, hidden
|
||||
27: unknown:1E22 {1E22} V0 internal, hidden
|
||||
Battery: 71%, 0.
|
||||
|
||||
64
docs/devices/Wireless Mouse M185.text
Normal file
@@ -0,0 +1,64 @@
|
||||
solaar show
|
||||
rules cannot access modifier keys in Wayland, accessing process only works on GNOME with Solaar Gnome extension installed
|
||||
solaar version 1.1.14-2
|
||||
|
||||
Unifying Receiver
|
||||
Device path : /dev/hidraw1
|
||||
USB id : 046d:C52B
|
||||
Serial : EC219AC2
|
||||
C Pending : ff
|
||||
0 : 12.11.B0032
|
||||
1 : 04.16
|
||||
3 : AA.AA
|
||||
Has 2 paired device(s) out of a maximum of 6.
|
||||
Notifications: wireless (0x000100)
|
||||
Device activity counters: 1=195, 2=74
|
||||
|
||||
1: Wireless Mouse M175
|
||||
Device path : /dev/hidraw2
|
||||
WPID : 4008
|
||||
Codename : M175
|
||||
Kind : mouse
|
||||
Protocol : HID++ 2.0
|
||||
Report Rate : 8ms
|
||||
Serial number: 16E46E8C
|
||||
Model ID: 000000000000
|
||||
Unit ID: 00000000
|
||||
0: RQM 40.00.B0016
|
||||
The power switch is located on the base.
|
||||
Supports 21 HID++ 2.0 features:
|
||||
0: ROOT {0000} V0
|
||||
1: FEATURE SET {0001} V0
|
||||
2: DEVICE FW VERSION {0003} V0
|
||||
Firmware: 0 RQM 40.00.B0016 4008
|
||||
Unit ID: 00000000 Model ID: 000000000000 Transport IDs: {}
|
||||
3: DEVICE NAME {0005} V0
|
||||
Name: Wireless Mouse M185
|
||||
Kind: mouse
|
||||
4: BATTERY STATUS {1000} V0
|
||||
Battery: 70%, 0, next level 5%.
|
||||
5: unknown:1830 {1830} V0 internal, hidden
|
||||
6: unknown:1850 {1850} V0 internal, hidden
|
||||
7: unknown:1860 {1860} V0 internal, hidden
|
||||
8: unknown:1890 {1890} V0 internal, hidden
|
||||
9: unknown:18A0 {18A0} V0 internal, hidden
|
||||
10: unknown:18C0 {18C0} V0 internal, hidden
|
||||
11: WIRELESS DEVICE STATUS {1D4B} V0
|
||||
12: unknown:1DF3 {1DF3} V0 internal, hidden
|
||||
13: REPROG CONTROLS {1B00} V0
|
||||
14: REMAINING PAIRING {1DF0} V0 hidden
|
||||
Remaining Pairings: 117
|
||||
15: unknown:1E00 {1E00} V0 hidden
|
||||
16: unknown:1E80 {1E80} V0 internal, hidden
|
||||
17: unknown:1E90 {1E90} V0 internal, hidden
|
||||
18: unknown:1F03 {1F03} V0 internal, hidden
|
||||
19: VERTICAL SCROLLING {2100} V0
|
||||
Roller type: standard
|
||||
Ratchet per turn: 24
|
||||
Scroll lines: 0
|
||||
20: MOUSE POINTER {2200} V0
|
||||
DPI: 1000
|
||||
Acceleration: low
|
||||
Override OS ballistics
|
||||
No vertical tuning, standard mice
|
||||
Battery: 70%, 0, next level 5%.
|
||||
@@ -39,8 +39,8 @@ Feature | ID | Status | Notes
|
||||
`CONFIG_DEVICE_PROPS` | `0x1806` | Unsupported |
|
||||
`CHANGE_HOST` | `0x1814` | Supported | `ChangeHost`
|
||||
`HOSTS_INFO` | `0x1815` | Partial Support | `get_host_names`, partial listing only
|
||||
`BACKLIGHT` | `0x1981` | Unsupported |
|
||||
`BACKLIGHT2` | `0x1982` | Supported | `Backlight2`
|
||||
`BACKLIGHT` | `0x1981` | Supported | `Backlight`
|
||||
`BACKLIGHT2` | `0x1982` | Supported | `Backlight2`, ...
|
||||
`BACKLIGHT3` | `0x1983` | Unsupported |
|
||||
`PRESENTER_CONTROL` | `0x1A00` | Unsupported |
|
||||
`SENSOR_3D` | `0x1A01` | Unsupported |
|
||||
@@ -54,7 +54,7 @@ Feature | ID | Status | Notes
|
||||
`WIRELESS_DEVICE_STATUS` | `0x1D4B` | Read only | status reporting from device
|
||||
`REMAINING_PAIRING` | `0x1DF0` | Unsupported |
|
||||
`FIRMWARE_PROPERTIES` | `0x1F1F` | Unsupported |
|
||||
`ADC_MEASUREMENT` | `0x1F20` | Unsupported |
|
||||
`ADC_MEASUREMENT` | `0x1F20` | Supported | `ADCPower`
|
||||
`LEFT_RIGHT_SWAP` | `0x2001` | Unsupported |
|
||||
`SWAP_BUTTON_CANCEL` | `0x2005` | Unsupported |
|
||||
`POINTER_AXIS_ORIENTATION` | `0x2006` | Unsupported |
|
||||
@@ -97,22 +97,22 @@ Feature | ID | Status | Notes
|
||||
`GESTURE` | `0x6500` | Unsupported |
|
||||
`GESTURE_2` | `0x6501` | Partial Support | `Gesture2Gestures`, `Gesture2Params`
|
||||
`GKEY` | `0x8010` | Partial Support | `DivertGkeys`
|
||||
`MKEYS` | `0x8020` | Unsupported |
|
||||
`MR` | `0x8030` | Unsupported |
|
||||
`BRIGHTNESS_CONTROL` | `0x8040` | Unsupported |
|
||||
`REPORT_RATE` | `0x8060` | Supported | `ReportRate`
|
||||
`COLOR_LED_EFFECTS` | `0x8070` | Unsupported |
|
||||
`RGB_EFFECTS` | `0X8071` | Unsupported |
|
||||
`MKEYS` | `0x8020` | Supported | `MkeyLEDs`
|
||||
`MR` | `0x8030` | Supported | `MRKeyLED`
|
||||
`BRIGHTNESS_CONTROL` | `0x8040` | Supported | `BrightnessControl`
|
||||
`REPORT_RATE` | `0x8060` | Supported | `ReportRate`
|
||||
`COLOR_LED_EFFECTS` | `0x8070` | Supported | `LEDControl`, `LEDZoneSetting`
|
||||
`RGB_EFFECTS` | `0X8071` | Supported | `RGBControl`, `RGBEffectSetting`
|
||||
`PER_KEY_LIGHTING` | `0x8080` | Unsupported |
|
||||
`PER_KEY_LIGHTING_V2` | `0x8081` | Unsupported |
|
||||
`PER_KEY_LIGHTING_V2` | `0x8081` | Supported | `PerKeyLighting`
|
||||
`MODE_STATUS` | `0x8090` | Unsupported |
|
||||
`ONBOARD_PROFILES` | `0x8100` | Unsupported |
|
||||
`ONBOARD_PROFILES` | `0x8100` | Supported |
|
||||
`MOUSE_BUTTON_SPY` | `0x8110` | Unsupported |
|
||||
`LATENCY_MONITORING` | `0x8111` | Unsupported |
|
||||
`GAMING_ATTACHMENTS` | `0x8120` | Unsupported |
|
||||
`FORCE_FEEDBACK` | `0x8123` | Unsupported |
|
||||
`SIDETONE` | `0x8300` | Unsupported |
|
||||
`EQUALIZER` | `0x8310` | Unsupported |
|
||||
`SIDETONE` | `0x8300` | Supported | `Sidetone`
|
||||
`EQUALIZER` | `0x8310` | Supported | `Equalizer`
|
||||
`HEADSET_OUT` | `0x8320` | Unsupported |
|
||||
|
||||
A “read only” note means the feature is a read-only feature.
|
||||
|
||||
@@ -56,16 +56,18 @@ Some of the languages Solaar has been translated to are listed below. A full lis
|
||||
- Italiano: [Michele Olivo][micheleolivo], Lorenzo
|
||||
- Japanese: Ryunosuke Toda
|
||||
- Norsk (Bokmål): [John Erling Blad][jeblad]
|
||||
- Norsk (Nynorsk): [John Erling Blad][jeblad]
|
||||
- Polski: [Adrian Piotrowicz][nexces], Matthaiks
|
||||
- Portuguese: Américo Monteiro
|
||||
- Portuguese-BR: [Drovetto][drovetto], [Josenivaldo Benito Jr.][jrbenito], Vinícius
|
||||
- Română: Daniel Pavel
|
||||
- Russian: [Dimitriy Ryazantcev][DJm00n], Anton Soroko
|
||||
- Serbian: [Renato Kaurić][renatoka]
|
||||
- Slovak: [Jose Riha][jose1711]
|
||||
- Spanish, Castilian: Jose Luis Tirado
|
||||
- Svensk: [Daniel Zippert][zipperten], Emelie Snecker
|
||||
- Swedish: John Erling Blad
|
||||
- Swedish: John Erling Blad, [Daniel Zippert][zipperten], Emelie Snecker, Jonatan Nyberg
|
||||
- Turkish: Osman Karagöz
|
||||
- Ukrainian: Олександр Афанасьєв
|
||||
|
||||
[Rongronggg9]: https://github.com/Rongronggg9
|
||||
[papoteur]: https://github.com/papoteur
|
||||
@@ -80,3 +82,4 @@ Some of the languages Solaar has been translated to are listed below. A full lis
|
||||
[jrbenito]: https://github.com/jrbenito
|
||||
[jeblad]: https://github.com/jeblad
|
||||
[feku]: https://github.com/FerdinaKusumah
|
||||
[renatoka]: https://github.com/renatoka
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
BIN
docs/img/favicon.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -3,12 +3,40 @@ title: Solaar Implementation
|
||||
layout: page
|
||||
---
|
||||
|
||||
TODO: improve the callback mechanism(s) to support the explicit calls of the UI
|
||||
|
||||
# Solaar Implementation
|
||||
|
||||
Solaar has three main components: code mostly about receivers and devices, code for the command line interface, and code for the graphical user interface.
|
||||
|
||||
The following graph shows the main components of Solaar and how they interact.
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph User interface
|
||||
U[UI]
|
||||
C[CLI]
|
||||
end
|
||||
|
||||
subgraph Core
|
||||
U --> S{Solaar}
|
||||
C --> S
|
||||
S --> L[Logitech receiver]
|
||||
L --> R[Receiver]
|
||||
L --> D[Device]
|
||||
S --> B[dbus]
|
||||
end
|
||||
|
||||
subgraph Hardware interface
|
||||
R --> A
|
||||
D --> A
|
||||
A[hidapi]--> P[hid parser]
|
||||
end
|
||||
|
||||
subgraph Peripherals
|
||||
P <-.-> M[Logitech mouse]
|
||||
P <-.-> K[Logitech keyboard]
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
## Receivers and Devices
|
||||
|
||||
The code in `logitech_receiver` is responsible for creating and maintaining receiver (`receiver/Receiver`) and device (`device/Device`) objects for each device on the computer that uses the Logitech HID++ protocol. These objects are discovered in Linux by interacting with the Linux `udev` system using code in `hidapi`.
|
||||
@@ -40,9 +68,9 @@ Many devices allow reprogramming some keys or buttons. One the main reasons for
|
||||
|
||||
Many pointing devices provide a facility for recognizing gestures and sending an HID message for the gesture. The `Gesture` class stores inforation for one gesture and the `Gestures` class stores information for all the gestures on a device. Functions in the Device class request `KeysArray` information and store it on devices. Functions in the Device class request `Gestures` information for a device when appropriate and store it on the device.
|
||||
|
||||
Many gaming devices provide an interface to controlling their LEDs by zone. The `LEDEffectSetting` class stores the current state of one zone of LEDs. This information can come directly from an LED feature but is also part of device profiles so this class provides a byte string interface. Solaar stores this information in YAML so this class provides a YAML interface. The `LEDEffectsInfo` class stores information about what LED zones are on a device and what effects they can perform and provides a method that builds an object by querying a device.
|
||||
Many gaming devices provide an interface to controlling their LEDs by zone. The `LEDEffectSetting` class stores the current state of one zone of LEDs. This information can come directly from an LED feature but is also part of Onboard Profiles so this class provides a byte string interface. Solaar stores this information in YAML so this class provides a YAML interface. The `LEDEffectsInfo` class stores information about what LED zones are on a device and what effects they can perform and provides a method that builds an object by querying a device.
|
||||
|
||||
Many gaming devices can be controlled by selecting one of their profiles. A profile sets up the rate at which the device reports movement, a set of sensitivites of its movement detector, a set of actions to be performed by mouse buttons or G and M keys, and effects for up to two LED zones. The `Button` class stores information about a button or key action. The `OnboardProfile` class stores a single profile, using the `LEDEffectSetting` and `Button` classes. Because retrieving and changing a profile is complex, this class provides a byte string interface. Because Solaar dumps profiles from devices as YAML documents and loads them into devices from YAML documents, this class provides a YAML interface. The `OnboardProfiles` class class stores the entire profiles information for a device. It provides an interface to construct an `OnboardProfiles` object by querying a device.
|
||||
Many gaming devices can be controlled by selecting one of their Onboard Profiles. An Onboard Profile sets up the rate at which the device reports movement, a set of sensitivites of its movement detector, a set of actions to be performed by mouse buttons or G and M keys, and effects for up to two LED zones. The `Button` class stores information about a button or key action. The `OnboardProfile` class stores a single profile, using the `LEDEffectSetting` and `Button` classes. Because retrieving and changing a profile is complex, this class provides a byte string interface. Because Solaar dumps profiles from devices as YAML documents and loads them into devices from YAML documents, this class provides a YAML interface. The `OnboardProfiles` class class stores the entire profiles information for a device. It provides an interface to construct an `OnboardProfiles` object by querying a device.
|
||||
Because Solaar dumps profiles from devices as YAML documents and loads them into devices from YAML documents, these classes also provide a YAML interface.
|
||||
|
||||
#### HID++ 1.0
|
||||
@@ -58,8 +86,6 @@ The module `descriptors` sets up information on device models for which Solaar n
|
||||
|
||||
The module `base_usb` sets up information for most of the receiver models that Solaar supports, including USB id, USB interface used for HID++ messages, what kind of receiver model it is, and some capabilities of the receiver model. Solaar can now support other receivers as long as they are not too unusual. The module lso sets up lists of device models by USB ID and Bluetooth ID and provides a function to determine whether a USB ID or Bluetooth ID is an HID++ device model
|
||||
|
||||
TODO? Move some information down to descriptors?
|
||||
|
||||
The module `base` provides functions that call discovery to enumerate all current receivers and devices and to set up a callback for when new receivers or devices are discovered. It provides functions to open and close I/O channels to receivers and devices, write HID++ messages to receivers and devices, and read HID++ messages from receivers and devices. It provides a function to turn an HID++ message into a notification.
|
||||
|
||||
The module provides a function to send an HID++ message to a receiver or device, constructing the message from parameters to the function, and optionally waiting for and returning a response. The function checks messages from the receiver or device, only terminating at timeout or when a message that appears to be the response is seen. Other messages are turned into notifications if appropriate and ignoreed otherwise. A separate function sends a ping message and waits for a reply to the ping.
|
||||
@@ -79,8 +105,6 @@ After this processing HID++ 2.0 notifications are sent to the `diversion` module
|
||||
|
||||
The `status` module provides the `DeviceStatus` class to record the battery status of a device. It also provides an interface to signal changes to the connection status of the device that can invoke a callback. This callback is used to update the Solaar user interface when the status changes.
|
||||
|
||||
TODO: check why solaar/listener.py sets the callback multiple times
|
||||
|
||||
|
||||
### Settings
|
||||
|
||||
@@ -123,10 +147,6 @@ The Solaar GUI takes these settings and constructs an interface for displaying a
|
||||
|
||||
This setup allows for very quick implementation of simple settings but it bypasses the data stored in a device object.
|
||||
|
||||
TODO: Refactor settings so that they always use data stored in device objects. Set up code to create a device data as easily as simple settings are creates. Set up code to use this to create a setting object for the Solaar GUI. Use callbacks to control GUI redisplay whenever the device data changes.
|
||||
|
||||
|
||||
|
||||
|
||||
### Solaar Rules
|
||||
|
||||
@@ -143,8 +163,6 @@ The module `common.py` provides utility functions, structures, and classes.
|
||||
`FirmwareInfo` provides information about device firmware.
|
||||
`BATTERY_APPROX` provides named integers used for approximate battery levels of devices.
|
||||
|
||||
TODO: Move a couple of things from hidpp20 to here.
|
||||
|
||||
`i18n.py` provides a few strings that need translations and might not otherwise be visible to translation software.
|
||||
|
||||
`special_keys.py` provides named integers for various collections of key codes and colors.
|
||||
@@ -162,8 +180,6 @@ Device and receiver discovery is performed when Solaar starts. While the Solaar
|
||||
|
||||
This code is also responsible for actual writing data to devices and receivers and reading data from them.
|
||||
|
||||
TOD: Is this actually the case?
|
||||
|
||||
|
||||
## Solaar
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Solaar runs as a regular user process, albeit with direct access to the Linux in
|
||||
that lets it directly communicate with the Logitech devices it manages using special
|
||||
Logitech-proprietary (HID++) commands.
|
||||
Each Logitech device implements a different subset of these commands.
|
||||
Solaar is thus only able to make the changes to devices that devices implement.
|
||||
Solaar is thus only able to make the changes that a particular device supports.
|
||||
|
||||
Solaar is not a device driver and does not process normal input from devices.
|
||||
It is thus unable to fix problems that arise from incorrect handling of
|
||||
@@ -46,8 +46,8 @@ and for more information on its capabilities see
|
||||
|
||||
Solaar's GUI normally uses an icon in the system tray and starts with its main window visible.
|
||||
This aspect of Solaar depends on having an active system tray, which is not the default
|
||||
situation for recent versions of Gnome. For information on to set up a system tray under Gnome see
|
||||
[the capabilities page](https://pwr-solaar.github.io/Solaar/capabilities).
|
||||
situation for recent versions of Gnome. For information on how to set up a system tray under
|
||||
Gnome see [the capabilities page](https://pwr-solaar.github.io/Solaar/capabilities).
|
||||
|
||||
Solaar's GUI can be started in several ways
|
||||
|
||||
@@ -95,7 +95,7 @@ which is done using the usual Bluetooth mechanisms.
|
||||
For a partial list of supported devices
|
||||
and their features, see [the devices page](https://pwr-solaar.github.io/Solaar/devices).
|
||||
|
||||
[logo]: https://pwr-solaar.github.io/Solaar/assets/solaar.svg
|
||||
[logo]: https://pwr-solaar.github.io/Solaar/img/solaar.svg
|
||||
|
||||
## Prebuilt packages
|
||||
|
||||
@@ -131,60 +131,6 @@ Solaar uses a standard system tray implementation; solaar-gnome3 is no longer re
|
||||
See [the installation page](https://pwr-solaar.github.io/Solaar/installation)
|
||||
for the step-by-step procedure for manual installation.
|
||||
|
||||
## Known Issues
|
||||
|
||||
- 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
|
||||
|
||||
@@ -7,8 +7,7 @@ layout: page
|
||||
|
||||
An easy way to install the most recent release version of Solaar is from the PyPI repository.
|
||||
First install pip, and then run
|
||||
`pip install --user solaar` or `pipx install --system-site-packages solaar` or
|
||||
If you are using pipx add the `` flag.
|
||||
`pip install --user solaar` or `pipx install --system-site-packages solaar`.
|
||||
|
||||
This will not install the Solaar udev rule, which you will need to install manually by copying
|
||||
`~/.local/lib/udev/rules.d/42-logitech-unify-permissions.rules`
|
||||
@@ -27,6 +26,18 @@ brew update
|
||||
brew install hidapi gtk+3 pygobject3
|
||||
```
|
||||
|
||||
### Optional: Set up macOS launcher
|
||||
* Option A (recommended): Configure a LaunchAgent to automatically start Solaar and keep it running in the background.
|
||||
It will also automatically restart Solaar if it crashed or closed.
|
||||
```
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/pwr-Solaar/Solaar/refs/heads/master/tools/create-macos-launchagent.sh)
|
||||
```
|
||||
* Option B: Create Solaar.app launcher in /Applications.
|
||||
It can be added to Login Items to start on login, but it will not automatically recover on crashes.
|
||||
```
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/pwr-Solaar/Solaar/refs/heads/master/tools/create-macos-app.sh)
|
||||
```
|
||||
|
||||
# Installating from GitHub
|
||||
|
||||
## Downloading
|
||||
@@ -42,8 +53,8 @@ or `make install_dnf` or `make install_brew`.
|
||||
These might not install all needed packages in older versions of your distribution.
|
||||
Next, install the Solaar rule via `make install_udev`.
|
||||
If you are using Wayland instead of X11 you may want to instead `make install_udev_uinput`
|
||||
Finally, install Solaar via `make install_pip` or `make install_pipx`.
|
||||
so that Solaar rules can simulate input in Wayland.
|
||||
Finally, install Solaar via `make install_pip` or `make install_pipx`.
|
||||
|
||||
Parts of the installation process require sudo privileges so you may be asked for your password.
|
||||
|
||||
@@ -73,7 +84,7 @@ If you are running the system version of Python in Debian/Ubuntu you should have
|
||||
In Fedora you need `gtk3` and `python3-gobject`.
|
||||
You may have to install `gcc` and the Python development package (`python3-dev` or `python3-devel`,
|
||||
depending on your distribution).
|
||||
Other system packages may be required depending on your distribution, such as `python-gobject-common-devel`.
|
||||
Other system packages may be required depending on your distribution, such as `python-gobject-common-devel` and `python-typing-extensions'.
|
||||
Although the Solaar CLI does not require Gtk3,
|
||||
`solaar config` does use Gtk3 capabilities to determine whether the Solaar GUI is running
|
||||
and thus should tell the Solaar GUI to update its information about settings
|
||||
@@ -92,10 +103,11 @@ If desktop notifications bindings are also installed
|
||||
(`gir1.2-notify-0.7` for Debian/Ubuntu),
|
||||
you will also see desktop notifications when devices come online and go offline.
|
||||
|
||||
If the `hid_parser` Python package is available, Solaar parses HID report descriptors
|
||||
and can control more HID++ devices that do not use a receiver.
|
||||
This package may not be available in some distributions but can be installed using pip
|
||||
via `pip install --user hid-parser`.
|
||||
Solaar includes its own version of `hid_parser` because the version that is in PyPi
|
||||
(at https://pypi.org/project/hid-parser/) does not have some changes that are in
|
||||
https://github.com/usb-tools/python-hid-parser and are needed for some devices.
|
||||
Do not use pip to install hid_parser!
|
||||
Some distributions (e.g., Fedora) may separately package this code.
|
||||
|
||||
If the `gitinfo` Python package is available, Solaar shows better information
|
||||
about which version of Solaar is running.
|
||||
@@ -131,6 +143,6 @@ and set the LANGUAGE environment variable appropriately when running Solaar.
|
||||
|
||||
Distributions can cause Solaar can be run automatically at user login by installing a desktop file at
|
||||
`/etc/xdg/autostart/solaar.desktop`. An example of this file content can be seen in the repository at
|
||||
[`share/autostart/solaar.desktop`](/share/autostart/solaar.desktop).
|
||||
[`share/autostart/solaar.desktop`](https://github.com/pwr-Solaar/Solaar/blob/master/share/autostart/solaar.desktop).
|
||||
|
||||
If you install Solaar yourself you may need to create or modify this file or install a startup file under your home directory.
|
||||
|
||||
61
docs/issues.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Known Issues
|
||||
layout: page
|
||||
---
|
||||
|
||||
# Known Issues
|
||||
|
||||
- Some internal structures in Solaar have been updated to use more standard Python language features.
|
||||
This has caused some problems and introduced bugs are still being found.
|
||||
|
||||
- Onboard Profiles, when active, can prevent changes to other settings, such as Polling Rate, DPI, and various LED settings. Which settings are affected depends on the device. To make changes to affected settings, disable Onboard Profiles. If Onboard Profiles are later enabled the affected settings may change to the value in the profile.
|
||||
|
||||
- Bluez 5.73 does not remove Bluetooth devices when they disconnect.
|
||||
Solaar 1.1.12 processes the DBus disconnection and connection messages from Bluez and does re-initialize devices when they reconnect.
|
||||
The HID++ driver does not re-initialize devices, which causes problems with smooth scrolling.
|
||||
Until the problem is resolved having Scroll Wheel Resolution set to true (and not ignored) may be helpful.
|
||||
|
||||
- The Linux HID++ driver modifies the Scroll Wheel Resolution setting to
|
||||
implement smooth scrolling. If Solaar changes this setting, scrolling
|
||||
can be either very fast or very slow. To fix this problem
|
||||
click on the icon at the right edge of the setting to set it to
|
||||
"Ignore this setting", which is the default for new devices.
|
||||
The mouse has to be reset (e.g., by turning it off and on again) before this fix will take effect.
|
||||
|
||||
- Solaar expects that it has exclusive control over settings that are not ignored.
|
||||
Running other programs that modify these settings, such as logiops,
|
||||
will likely result in unexpected device behavior.
|
||||
|
||||
- The driver also sets the scrolling direction to its normal setting when implementing smooth scrolling.
|
||||
This can interfere with the Scroll Wheel Direction setting, requiring flipping this setting back and forth
|
||||
to restore reversed scrolling.
|
||||
|
||||
- The driver sends messages to devices that do not conform with the Logitech HID++ specification
|
||||
resulting in responses being sent back that look like other messages. For some devices this causes
|
||||
Solaar to report incorrect battery levels.
|
||||
|
||||
- Solaar normally uses icon names for its icons, which in some system tray implementations
|
||||
results in missing or wrong-sized icons.
|
||||
The `--tray-icon-size` option forces Solaar to use icon files of appropriate size
|
||||
for tray icons instead, which produces better results in some system tray implementations.
|
||||
To use icon files close to 32 pixels in size use `--tray-icon-size=32`.
|
||||
|
||||
- The icon in the system tray can show up as 'black on black' in dark
|
||||
themes or as non-symbolic when the theme uses symbolic icons. This is due to problems
|
||||
in some system tray implementations. Changing to a different theme may help.
|
||||
The `--battery-icons=symbolic` option can be used to force symbolic icons.
|
||||
|
||||
- Solaar will try to use uinput to simulate input from rules under Wayland or if Xtest is not available
|
||||
but this needs write permission on /dev/uinput.
|
||||
For more information see [the rules page](https://pwr-solaar.github.io/Solaar/rules).
|
||||
|
||||
- Diverted keys remain diverted and so do not have their normal behavior when Solaar terminates
|
||||
or a device disconnects from a host that is running Solaar. If necessary, their normal behavior
|
||||
can be reestablished by turning the device off and on again. This is most important to restore
|
||||
the host switching behavior of a host switch key that was diverted, for example to switch away
|
||||
from a host that crashed or was turned off.
|
||||
|
||||
- When a receiver-connected device changes hosts Solaar remembers which diverted keys were down on it.
|
||||
When the device changes back the first time any of these diverted keys is depressed Solaar will not
|
||||
realize that the key was newly depressed. For this reason Solaar rules that can change hosts should
|
||||
trigger on key releasing.
|
||||
109
docs/rules.md
@@ -3,11 +3,10 @@ title: Rule Processing of HID++ Notifications
|
||||
layout: page
|
||||
---
|
||||
|
||||
# Rule Processing of HID++ Notifications
|
||||
Creating and editing most rules can be done in the Solaar GUI, by pressing the 'Rule Editor' button in the
|
||||
Solaar main window.
|
||||
|
||||
Rule processing is an experimental feature. Significant changes might be made in response to problems.
|
||||
|
||||
Note that rule processing only fully works under X11.
|
||||
When running under Wayland with X11 libraries loaded some features will not be available.
|
||||
When running under Wayland without X11 libraries loaded even more features will not be available.
|
||||
@@ -24,8 +23,9 @@ You may have to reboot your system for the write permission to be set up.
|
||||
Another way to get write access to /dev/uinput is to run `sudo setfacl -m u:${USER}:rw /dev/uinput`
|
||||
but this needs to be done every time the system is rebooted.
|
||||
|
||||
Logitech devices that use HID++ version 2.0 or greater produce feature-based
|
||||
notifications that Solaar can process using a simple rule language. For
|
||||
## HID++ notifications and diversion
|
||||
Logitech devices that use HID++ version 2.0 or greater, produce feature-based
|
||||
notifications that Solaar can process using a simple rule language. For
|
||||
example, using rules Solaar can emulate an `XF86_MonBrightnessDown` key tap
|
||||
in response to the pressing of the `Brightness Down` key on Craft keyboards,
|
||||
which normally does not produce any input at all when the keyboard is in
|
||||
@@ -34,7 +34,7 @@ Windows mode.
|
||||
Solaar's rules only trigger on HID++ notifications so device actions that
|
||||
normally produce HID output have to be first be set (diverted) to
|
||||
produce HID++ notifications instead of their normal behavior.
|
||||
Currently Solaar can divert some mouse scroll wheels, some
|
||||
Currently, Solaar can divert some mouse scroll wheels, some
|
||||
mouse thumb wheels, the crown of Craft keyboards, and some keys and buttons.
|
||||
If the scroll wheel, thumb wheel, crown, key, or button is
|
||||
not diverted by setting the appropriate setting then no HID++ notification is
|
||||
@@ -42,50 +42,60 @@ generated and rules will not be triggered by manipulating the wheel, crown, key,
|
||||
Look for `HID++` or `Diversion` settings to see what
|
||||
diversion can be done with your devices.
|
||||
|
||||
### Show notifications
|
||||
Running Solaar with the `-ddd`
|
||||
option will show information about notifications, including their feature
|
||||
name, report number, and data.
|
||||
|
||||
In response to a feature-based HID++ notification Solaar runs a sequence of
|
||||
rules. A `Rule` is a sequence of components, which are either sub-rules,
|
||||
conditions, or actions. Conditions and actions are dictionaries with one
|
||||
rules. A `Rule` is a sequence of components, which are either sub-rules,
|
||||
conditions, or actions. Conditions and actions are dictionaries with one
|
||||
entry whose key is the name of the condition or action and whose value is
|
||||
the argument of the action.
|
||||
|
||||
If the last thing that a rule does is execute an action, no more rules are
|
||||
processed for the notification.
|
||||
|
||||
Rules are evaluated by evaluating each of their components in order. The
|
||||
Rules are evaluated by evaluating each of their components in order. The
|
||||
evaluation of a rule is terminated early if a condition component evaluates
|
||||
to false or the last evaluated sub-component of a component is an action. A
|
||||
rule is false if its last evaluated component evaluates to a false value.
|
||||
to false or the last evaluated subcomponent of a component is an action. A
|
||||
rule is false if its last evaluated component evaluates to false.
|
||||
|
||||
## Conditions
|
||||
|
||||
### Not
|
||||
`Not` conditions take a single component and are true if their component
|
||||
evaluates to a false value.
|
||||
|
||||
### Or
|
||||
`Or` conditions take a sequence of components and are evaluated by
|
||||
evaluating each of their components in order.
|
||||
An Or condition is terminated early if a component evaluates to true or the
|
||||
last evaluated sub-component of a component is an action.
|
||||
last evaluated subcomponent of a component is an action.
|
||||
A Or condition is true if its last evaluated component evaluates to a true
|
||||
value. `And` conditions take a sequence of components which are evaluated the same
|
||||
value. `And` conditions take a sequence of components which are evaluated the same
|
||||
as rules.
|
||||
|
||||
### Feature
|
||||
`Feature` conditions are true if the name of the feature of the current
|
||||
notification is their string argument.
|
||||
`Report` conditions are true if the report number in the current
|
||||
notification is their integer argument.
|
||||
|
||||
### Key
|
||||
`Key` conditions are true if the Logitech name of the current **diverted** key or button being pressed is their
|
||||
string argument. Alternatively, if the argument is a list `[name, action]` where `action`
|
||||
string argument. Alternatively, if the argument is a list `[name, action]` where `action`
|
||||
is either `'pressed'` or `'released'`, the key down or key up events of `name` argument are
|
||||
matched, respectively. Logitech key and button names are shown in the `Key/Button Diversion`
|
||||
setting. These names are also shown in the output of `solaar show` in the 'reprogrammable keys'
|
||||
section. Only keys or buttons that have 'divertable' in their report can be diverted.
|
||||
Some keyboards have Gn, Mn, or MR keys, which are diverted using the 'Divert G Keys' setting.
|
||||
matched, respectively. Logitech key and button names are shown in the `Key/Button Diversion`
|
||||
setting. These names are also shown in the output of `solaar show` in the 'Reprogrammable keys'
|
||||
section. Only keys or buttons that have 'Divertable' in their report can be diverted.
|
||||
Some keyboards have 'Gn', 'Mn', or 'MR' keys, which are diverted using the 'Divert G Keys' setting.
|
||||
|
||||
### Key is down
|
||||
`KeyIsDown` conditions are true if the **diverted** key or button that is their string argument is currently down.
|
||||
Note that this only works for **diverted** keys or buttons, including diverted Gn, Mn, and MR keys.
|
||||
|
||||
### Key and button diversion
|
||||
Solaar can also create special notifications in response to mouse movements on some mice.
|
||||
Setting `Key/Button Diversion` for a key or button to Mouse Gestures causes the key or button to create a `Mouse Gesture`
|
||||
notification for the period that the key or button is depressed.
|
||||
@@ -95,6 +105,7 @@ Pressing a diverted key creates a key event.
|
||||
When the key is released the sequence of events is sent as a synthetic notification
|
||||
that can be matched with `Mouse Gesture` conditions.
|
||||
|
||||
### Mouse gestures
|
||||
`Mouse Gesture` conditions are true if the actions (mouse movements and diverted key presses) taken while a mouse gestures button is held down match the arguments of the condition.
|
||||
Mouse gestures buttons can be set using the 'Key/Button Diversion' setting, by changing the value to `Mouse Gestures`.
|
||||
The arguments of a Mouse Gesture condition can be a direction, i.e., `Mouse Up`, `Mouse Down`, `Mouse Left`, `Mouse Right`, `Mouse Up-Left`, `Mouse Up-Right`, `Mouse Down-Left`, or `Mouse Down-Right`, or the Logitech name of a key.
|
||||
@@ -104,24 +115,32 @@ The condition `Smart Shift` -> `Mouse Down` -> `Back Button` would match pressin
|
||||
Directions and buttons can be mixed and chained together however you like.
|
||||
It's possible to create a `No-op` gesture by clicking 'Delete' on the initial Action when you first create the rule. This gesture will trigger when you simply click a Mouse Gestures button.
|
||||
|
||||
### Key modifiers
|
||||
`Modifiers` conditions take either a string or a sequence of strings, which
|
||||
can only be `Shift`, `Control`, `Alt`, and `Super`.
|
||||
Modifiers conditions are true if their argument is the current keyboard
|
||||
modifiers.
|
||||
|
||||
### Process focused
|
||||
`Process` conditions are true if the process for the focused input window
|
||||
or the window's Window manager class or instance name starts with their string argument.
|
||||
|
||||
### Window under cursor
|
||||
`MouseProcess` conditions are true if the process for the window under the mouse
|
||||
or the window's Window manager class or instance name starts with their string argument.
|
||||
|
||||
### Device notification and device active
|
||||
`Device` conditions are true if a particular device originated the notification.
|
||||
`Active` conditions are true if a particular device is active.
|
||||
`Device` and `Active` conditions take one argument, which is the serial number or unit ID of a device,
|
||||
as shown in Solaar's detail pane.
|
||||
Some older devices do not have a useful serial number or unit ID and so cannot be tested for by these conditions.
|
||||
as shown in Solaar's detail pane, or either of its names, as shown by Solaar.
|
||||
Some older devices do not have a useful serial number or unit ID and so cannot
|
||||
distinguished from other devices with the same names.
|
||||
|
||||
`Host' conditions are true if the computers hostname starts with the condition's argument.
|
||||
### Host
|
||||
`Host` conditions are true if the computers hostname starts with the condition's argument.
|
||||
|
||||
### Solaar device setting
|
||||
`Setting` conditions check the value of a Solaar setting on a device.
|
||||
`Setting` conditions take three or four arguments, depending on the setting:
|
||||
the Serial number or Unit ID of a device, as shown in Solaar's detail pane,
|
||||
@@ -147,16 +166,17 @@ For settings that use gestures as an argument the internal name of the gesture i
|
||||
which can be found in the GESTURE2_GESTURES_LABELS structure in lib/logitech_receiver/settings_templates.
|
||||
For boolean settings '~' can be used to toggle the setting.
|
||||
|
||||
### Test and TestBytes
|
||||
`Test` and `TestBytes` conditions are true if their test evaluates to true on the feature,
|
||||
report, and data of the current notification.
|
||||
`TestBytes` conditions can return a number instead of a boolean.
|
||||
report and data of the current notification.
|
||||
`TestBytes` conditions can return a number instead of a boolean.
|
||||
|
||||
`TestBytes` conditions consist of a sequence of three or four integers and use the first
|
||||
two to select bytes of the notification data.
|
||||
Writing this kind of test condition is not trivial.
|
||||
Three-element `TestBytes` conditions are true if the selected bytes bit-wise anded
|
||||
Three-element `TestBytes` conditions are true if the selected bytes bit-wise AND
|
||||
with its third element is non-zero.
|
||||
The value of these test conditions is the result of the and.
|
||||
The value of these test conditions is the result of the AND.
|
||||
Four-element `TestBytes` conditions are true if the selected bytes form a signed
|
||||
integer between the third and fourth elements.
|
||||
The value of these conditions is the signed value of the selected bytes
|
||||
@@ -184,12 +204,15 @@ This displacement is reset when the thumb wheel is inactive.
|
||||
With a parameter the test is only true if the current thumb wheel displacement is greater than the parameter.
|
||||
The displacement is then lessened by the amount of the parameter.
|
||||
|
||||
## Actions
|
||||
|
||||
### Key press
|
||||
A `KeyPress` action takes either the name of an X11 key symbol, such as "a",
|
||||
a list of X11 key symbols, such as "a" or "Control+a",
|
||||
a list of X11 key symbols, such as "a" or "CTRL + A",
|
||||
or a two-element list with the first element as above
|
||||
and the second element one of 'click', 'depress', or 'release'
|
||||
and the second element one of `'click'`, `'depress'`, or `'release'`
|
||||
and executes key actions on a simulated keyboard to produce these symbols.
|
||||
Use separate `KeyPress` actions for multiple characters,
|
||||
Use separate `KeyPress` actions for multiple characters,
|
||||
i.e., don't use a single `KeyPress` like 'a+b'.
|
||||
The `KeyPress` action normally both depresses and releases (clicks) the keys,
|
||||
but can also just depress the keys or just release the keys.
|
||||
@@ -215,38 +238,43 @@ simulate inputting a key symbol.
|
||||
Unfortunately, this determination can go wrong in several ways and is more likely
|
||||
to go wrong under Wayland than under X11.
|
||||
|
||||
### Mouse scroll
|
||||
A `MouseScroll` action takes a sequence of two numbers and simulates a horizontal and vertical mouse scroll of these amounts.
|
||||
If the previous condition in the parent rule returns a number the scroll amounts are multiplied by this number.
|
||||
|
||||
### Mouse click
|
||||
A `MouseClick` action takes a mouse button name (`left`, `middle` or `right`) and a positive number or 'click', 'depress', or 'release'.
|
||||
The action simulates that number of clicks of the specified button or just one click, depress, or release of the button.
|
||||
A `MouseClick` action takes a mouse button name (`left`, `middle` or `right`) and a positive number, and simulates that number of clicks of the specified button.
|
||||
|
||||
### Execute
|
||||
An `Execute` action takes a program and arguments and executes it asynchronously.
|
||||
|
||||
### Set setting
|
||||
A `Set` action changes a Solaar setting for a device, provided that the device is on-line.
|
||||
`Set` actions take three or four arguments, depending on the setting.
|
||||
The first two are the Serial number or Unit ID of a device, as shown in Solaar's detail pane,
|
||||
or null for the device that initiated rule processing; and
|
||||
the internal name of a setting (which can be found from solaar config \<device\>).
|
||||
the internal name of a setting (which can be found from `solaar config <device>`).
|
||||
Simple settings take one extra argument, the value to set the setting to.
|
||||
For boolean settings '~' can be used to toggle the setting.
|
||||
For boolean settings `~` can be used to toggle the setting.
|
||||
Other simple settings take two extra arguments, a key indicating which sub-setting to set and the value to set it to.
|
||||
For settings that use gestures as an argument the internal name of the gesture is used,
|
||||
which can be found in the GESTURE2_GESTURES_LABELS structure in lib/logitech_receiver/settings_templates.
|
||||
which can be found in the GESTURE2_GESTURES_LABELS structure in `lib/logitech_receiver/settings_templates`.
|
||||
All settings are supported.
|
||||
|
||||
### Later
|
||||
A `Later` action executes rule components later.
|
||||
`Later` actions take an integer delay in seconds between 1 and 100 followed by zero or more rule components that will be executed later.
|
||||
Processing of the rest of the rule continues immediately.
|
||||
|
||||
Solaar has several built-in rules, which are run after user-created rules and so can be overridden by user-created rules.
|
||||
One rule turns
|
||||
## Built-in Rules
|
||||
|
||||
Solaar has a built-in rule, which is run after user-created rules and so can be overridden by user-created rules.
|
||||
This rule turns
|
||||
`Brightness Down` key press notifications into `XF86_MonBrightnessDown` key taps
|
||||
and `Brightness Up` key press notifications into `XF86_MonBrightnessUp` key taps.
|
||||
Another rule makes Craft crown ratchet movements move between tabs when the crown is pressed
|
||||
and up and down otherwise.
|
||||
A third rule turns Craft crown ratchet movements into `XF86_AudioNext` or `XF86_AudioPrev` key taps when the crown is pressed and `XF86_AudioRaiseVolume` or `XF86_AudioLowerVolume` otherwise.
|
||||
A fourth rule doubles the speed of `THUMB WHEEL` movements unless the `Control` modifier is on.
|
||||
All of these rules are only active if the key or feature is diverted, of course.
|
||||
|
||||
## Example Solaar Rule File
|
||||
|
||||
Solaar reads rules from a YAML configuration file (normally `~/.config/solaar/rules.yaml`).
|
||||
This file contains zero or more documents, each a rule.
|
||||
@@ -294,10 +322,11 @@ Here is a file with six rules:
|
||||
...
|
||||
```
|
||||
|
||||
## Button diversion example
|
||||
Here is an example showing how to divert the Back Button on an MX Master 3 so that pressing
|
||||
the button will initiate rule processing and a rule that triggers on this notification and
|
||||
switches the mouse to host 3 after popping up a simple notification.
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB |
40
docs/uninstallation.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: Uninstalling Solaar
|
||||
layout: page
|
||||
---
|
||||
|
||||
# Uninstalling Solaar
|
||||
|
||||
## Uninstalling from Debian systems
|
||||
|
||||
If you installed Solaar using `apt`, you can remove it by running:
|
||||
|
||||
```bash
|
||||
sudo apt remove --purge solaar
|
||||
```
|
||||
|
||||
## Uninstalling from GitHub
|
||||
|
||||
If you cloned and installed Solaar from GitHub manually, navigate to the cloned directory and run:
|
||||
|
||||
```bash
|
||||
sudo make uninstall
|
||||
```
|
||||
|
||||
## Removing Configuration Files
|
||||
|
||||
Solaar may leave behind configuration files in your home directory. To delete them, run:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/solaar
|
||||
```
|
||||
|
||||
## Verifying Uninstallation
|
||||
|
||||
To confirm that Solaar is fully removed, try running:
|
||||
|
||||
```bash
|
||||
which solaar
|
||||
```
|
||||
|
||||
If no output is returned, Solaar has been successfully uninstalled.
|
||||
@@ -22,7 +22,7 @@ The following is an image of the Solaar menu and the icon (the battery
|
||||
symbol is in the system tray at the left of the image). The icon can
|
||||
also be other battery icons or versions of the Logitech Unifying icon.
|
||||
|
||||

|
||||

|
||||
|
||||
Clicking on “Quit” in the Solaar menu terminates the program.
|
||||
Clicking on “About Solaar” pops up a window with further information about Solaar.
|
||||
@@ -64,7 +64,7 @@ To pair with a Bolt receiver you have to type a passcode followed by enter
|
||||
or click the left and right buttons in the correct sequence followed by
|
||||
clicking both buttons simultaneously.
|
||||
|
||||

|
||||

|
||||
|
||||
When a device is selected you can unpair the device if your receiver supports
|
||||
unpairing. To unpair the device, just click on the “Unpair” button and
|
||||
@@ -93,26 +93,26 @@ You can also see and change the settings of devices.
|
||||
Changing settings is performed by clicking on buttons,
|
||||
moving sliders, or selecting from alternatives.
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
Device settings now have a clickable icon that determines whether the
|
||||
setting can be changed and whether the setting is ignored.
|
||||
|
||||

|
||||

|
||||
|
||||
If the selected device that is paired with a receiver is powered down or
|
||||
otherwise disconnected its settings cannot be changed
|
||||
but it still can be unpaired if its receiver allows unpairing.
|
||||
|
||||

|
||||

|
||||
|
||||
If a device is paired with a receiver but directly connected via USB or Bluetooth
|
||||
the receiver pairing will show up as well as the direct connection.
|
||||
The device can only be manipulated using the direct connection.
|
||||
|
||||

|
||||

|
||||
|
||||
#### Remapping key and button actions
|
||||
|
||||
@@ -127,7 +127,7 @@ action is always the one shown first in the list. As with all settings,
|
||||
Solaar will remember past action settings and restore them on the device
|
||||
from then on.
|
||||
|
||||

|
||||

|
||||
|
||||
The names of the keys, buttons, and actions are mostly taken from Logitech
|
||||
documentation and may not be completely obvious.
|
||||
@@ -136,9 +136,9 @@ It is possible to end up with an unusable system, for example by having no
|
||||
way to do a mouse left click, so exercise caution when remapping keys or
|
||||
buttons that are needed to operate your system.
|
||||
|
||||
## Solaar command line interface
|
||||
## Solaar command-line interface
|
||||
|
||||
Solaar also has a command line interface that can do most of what can be
|
||||
Solaar also has a command-line interface that can do most of what can be
|
||||
done using the main window. For more information on the
|
||||
command line interface, run `solaar --help` to see the commands and
|
||||
then `solaar <command> --help` to see the arguments to any of the commands.
|
||||
|
||||
|
Before Width: | Height: | Size: 96 B |
|
Before Width: | Height: | Size: 432 B |
|
Before Width: | Height: | Size: 230 B |
|
Before Width: | Height: | Size: 14 KiB |
@@ -14,17 +14,13 @@ from typing import Dict
|
||||
from typing import Iterable
|
||||
from typing import Iterator
|
||||
from typing import List
|
||||
from typing import Literal
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
from typing import TextIO
|
||||
from typing import Tuple
|
||||
from typing import Union
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
from typing import Literal
|
||||
else: # pragma: no cover
|
||||
from typing_extensions import Literal
|
||||
|
||||
import hid_parser.data
|
||||
|
||||
__version__ = "0.0.3"
|
||||
@@ -564,7 +560,8 @@ class ArrayItem(MainItem):
|
||||
)
|
||||
continue
|
||||
|
||||
if usage in self._usages and all(usage_type not in self._INCOMPATIBLE_TYPES for usage_type in usage.usage_types):
|
||||
not_incompatible_type = all(usage_type not in self._INCOMPATIBLE_TYPES for usage_type in usage.usage_types)
|
||||
if usage in self._usages and not_incompatible_type:
|
||||
usage_values[usage] = UsageValue(self, True)
|
||||
|
||||
return usage_values
|
||||
@@ -824,14 +821,28 @@ class ReportDescriptor:
|
||||
if data is None:
|
||||
raise InvalidReportDescriptor("Invalid output item")
|
||||
self._append_items(
|
||||
offset_output, self._output, report_id, report_count, report_size, usages, data, {**glob, **local}
|
||||
offset_output,
|
||||
self._output,
|
||||
report_id,
|
||||
report_count,
|
||||
report_size,
|
||||
usages,
|
||||
data,
|
||||
{**glob, **local},
|
||||
)
|
||||
|
||||
elif tag == TagMain.FEATURE:
|
||||
if data is None:
|
||||
raise InvalidReportDescriptor("Invalid feature item")
|
||||
self._append_items(
|
||||
offset_feature, self._feature, report_id, report_count, report_size, usages, data, {**glob, **local}
|
||||
offset_feature,
|
||||
self._feature,
|
||||
report_id,
|
||||
report_count,
|
||||
report_size,
|
||||
usages,
|
||||
data,
|
||||
{**glob, **local},
|
||||
)
|
||||
|
||||
# clear local
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
## Copyright (C) 2012-2013 Daniel Pavel
|
||||
##
|
||||
## This program is free software; you can redistribute it and/or modify
|
||||
## it under the terms of the GNU General Public License as published by
|
||||
## the Free Software Foundation; either version 2 of the License, or
|
||||
## (at your option) any later version.
|
||||
##
|
||||
## This program is distributed in the hope that it will be useful,
|
||||
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
## GNU General Public License for more details.
|
||||
##
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
"""Generic Human Interface Device API."""
|
||||
|
||||
import platform as _platform
|
||||
|
||||
if _platform.system() in ("Darwin", "Windows"):
|
||||
from hidapi.hidapi import close # noqa: F401
|
||||
from hidapi.hidapi import enumerate # noqa: F401
|
||||
from hidapi.hidapi import find_paired_node # noqa: F401
|
||||
from hidapi.hidapi import find_paired_node_wpid # noqa: F401
|
||||
from hidapi.hidapi import get_manufacturer # noqa: F401
|
||||
from hidapi.hidapi import get_product # noqa: F401
|
||||
from hidapi.hidapi import get_serial # noqa: F401
|
||||
from hidapi.hidapi import monitor_glib # noqa: F401
|
||||
from hidapi.hidapi import open # noqa: F401
|
||||
from hidapi.hidapi import open_path # noqa: F401
|
||||
from hidapi.hidapi import read # noqa: F401
|
||||
from hidapi.hidapi import write # noqa: F401
|
||||
else:
|
||||
from hidapi.udev import close # noqa: F401
|
||||
from hidapi.udev import enumerate # noqa: F401
|
||||
from hidapi.udev import find_paired_node # noqa: F401
|
||||
from hidapi.udev import find_paired_node_wpid # noqa: F401
|
||||
from hidapi.udev import get_manufacturer # noqa: F401
|
||||
from hidapi.udev import get_product # noqa: F401
|
||||
from hidapi.udev import get_serial # noqa: F401
|
||||
from hidapi.udev import monitor_glib # noqa: F401
|
||||
from hidapi.udev import open # noqa: F401
|
||||
from hidapi.udev import open_path # noqa: F401
|
||||
from hidapi.udev import read # noqa: F401
|
||||
from hidapi.udev import write # noqa: F401
|
||||
|
||||
__version__ = "0.9"
|
||||
|
||||
20
lib/hidapi/common.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class DeviceInfo:
|
||||
path: str
|
||||
bus_id: str | None
|
||||
vendor_id: str
|
||||
product_id: str
|
||||
interface: str | None
|
||||
driver: str | None
|
||||
manufacturer: str | None
|
||||
product: str | None
|
||||
serial: str | None
|
||||
release: str | None
|
||||
isDevice: bool
|
||||
hidpp_short: str | None
|
||||
hidpp_long: str | None
|
||||
@@ -22,43 +22,32 @@ See https://github.com/libusb/hidapi for how to obtain binaries.
|
||||
Parts of this code are adapted from https://github.com/apmorton/pyhidapi
|
||||
which is MIT licensed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import ctypes
|
||||
import logging
|
||||
import platform as _platform
|
||||
import platform
|
||||
import typing
|
||||
|
||||
from collections import namedtuple
|
||||
from threading import Thread
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
|
||||
import gi
|
||||
from hidapi.common import DeviceInfo
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
from gi.repository import GLib # NOQA: E402
|
||||
if typing.TYPE_CHECKING:
|
||||
import gi
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
from gi.repository import GLib # NOQA: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
native_implementation = "hidapi"
|
||||
|
||||
# Device info as expected by Solaar
|
||||
DeviceInfo = namedtuple(
|
||||
"DeviceInfo",
|
||||
[
|
||||
"path",
|
||||
"bus_id",
|
||||
"vendor_id",
|
||||
"product_id",
|
||||
"interface",
|
||||
"driver",
|
||||
"manufacturer",
|
||||
"product",
|
||||
"serial",
|
||||
"release",
|
||||
"isDevice",
|
||||
"hidpp_short",
|
||||
"hidpp_long",
|
||||
],
|
||||
)
|
||||
ACTION_ADD = "add"
|
||||
ACTION_REMOVE = "remove"
|
||||
|
||||
# Global handle to hidapi
|
||||
_hidapi = None
|
||||
@@ -83,7 +72,7 @@ for lib in _library_paths:
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
raise ImportError(f"Unable to load hdiapi library, tried: {' '.join(_library_paths)}")
|
||||
raise ImportError(f"Unable to load hidapi library, tried: {' '.join(_library_paths)}")
|
||||
|
||||
|
||||
# Retrieve version of hdiapi library
|
||||
@@ -172,7 +161,7 @@ atexit.register(_hidapi.hid_exit)
|
||||
# Solaar opens the same device more than once which will fail unless we
|
||||
# allow non-exclusive opening. On windows opening with shared access is
|
||||
# the default, for macOS we need to set it explicitly.
|
||||
if _platform.system() == "Darwin":
|
||||
if platform.system() == "Darwin":
|
||||
_hidapi.hid_darwin_set_open_exclusive.argtypes = [ctypes.c_int]
|
||||
_hidapi.hid_darwin_set_open_exclusive.restype = None
|
||||
_hidapi.hid_darwin_set_open_exclusive(0)
|
||||
@@ -192,15 +181,8 @@ def _enumerate_devices():
|
||||
p = p.contents.next
|
||||
_hidapi.hid_free_enumeration(c_devices)
|
||||
|
||||
keyboard_or_mouse = {d["path"] for d in devices if d["usage_page"] == 1 and d["usage"] in (6, 2)}
|
||||
unique_devices = {}
|
||||
for device in devices:
|
||||
# On macOS we cannot access keyboard or mouse devices without special permissions. Since
|
||||
# we don't need them anyway we remove them so opening them doesn't cause errors later.
|
||||
if device["path"] in keyboard_or_mouse:
|
||||
# print(f"Ignoring keyboard or mouse device: {device}")
|
||||
continue
|
||||
|
||||
# hidapi returns separate entries for each usage page of a device.
|
||||
# Deduplicate by path to only keep one device entry.
|
||||
if device["path"] not in unique_devices:
|
||||
@@ -216,6 +198,7 @@ class _DeviceMonitor(Thread):
|
||||
def __init__(self, device_callback, polling_delay=5.0):
|
||||
self.device_callback = device_callback
|
||||
self.polling_delay = polling_delay
|
||||
self.prev_devices = None
|
||||
# daemon threads are automatically killed when main thread exits
|
||||
super().__init__(daemon=True)
|
||||
|
||||
@@ -228,20 +211,29 @@ class _DeviceMonitor(Thread):
|
||||
current_devices = {tuple(dev.items()): dev for dev in _enumerate_devices()}
|
||||
for key, device in self.prev_devices.items():
|
||||
if key not in current_devices:
|
||||
self.device_callback("remove", device)
|
||||
self.device_callback(ACTION_REMOVE, device)
|
||||
for key, device in current_devices.items():
|
||||
if key not in self.prev_devices:
|
||||
self.device_callback("add", device)
|
||||
self.device_callback(ACTION_ADD, device)
|
||||
self.prev_devices = current_devices
|
||||
sleep(self.polling_delay)
|
||||
|
||||
|
||||
# The filterfn is used to determine whether this is a device of interest to Solaar.
|
||||
# It is given the bus id, vendor id, and product id and returns a dictionary
|
||||
# with the required hid_driver and usb_interface and whether this is a receiver or device.
|
||||
def _match(action, device, filterfn):
|
||||
def _match(
|
||||
action: str,
|
||||
device: dict[str, Any],
|
||||
filter_func: Callable[[int, int, int, bool, bool], dict[str, Any]],
|
||||
):
|
||||
"""
|
||||
The filter_func is used to determine whether this is a device of
|
||||
interest to Solaar. It is given the bus id, vendor id, and product
|
||||
id and returns a dictionary with the required hid_driver and
|
||||
usb_interface and whether this is a receiver or device.
|
||||
"""
|
||||
|
||||
vid = device["vendor_id"]
|
||||
pid = device["product_id"]
|
||||
hid_bus_type = device["bus_type"]
|
||||
|
||||
# Translate hidapi bus_type to the bus_id values Solaar expects
|
||||
if device.get("bus_type") == 0x01:
|
||||
@@ -250,40 +242,65 @@ def _match(action, device, filterfn):
|
||||
bus_id = 0x05 # Bluetooth
|
||||
else:
|
||||
bus_id = None
|
||||
logger.info(f"Device {device['path']} has an unsupported bus type {hid_bus_type:02X}")
|
||||
return None
|
||||
|
||||
# Skip unlikely devices with all-zero VID PID or unsupported bus IDs
|
||||
if vid == 0 and pid == 0:
|
||||
logger.info(f"Device {device['path']} has all-zero VID and PID")
|
||||
logger.info(f"Skipping unlikely device {device['path']} ({bus_id}/{vid:04X}/{pid:04X})")
|
||||
return None
|
||||
|
||||
# Check for hidpp support
|
||||
device["hidpp_short"] = False
|
||||
device["hidpp_long"] = False
|
||||
device_handle = None
|
||||
try:
|
||||
device_handle = open_path(device["path"])
|
||||
report = get_input_report(device_handle, 0x10, 32)
|
||||
|
||||
def check_hidpp_short():
|
||||
report = _get_input_report(device_handle, 0x10, 32)
|
||||
if len(report) == 1 + 6 and report[0] == 0x10:
|
||||
device["hidpp_short"] = True
|
||||
report = get_input_report(device_handle, 0x11, 32)
|
||||
|
||||
def check_hidpp_long():
|
||||
report = _get_input_report(device_handle, 0x11, 32)
|
||||
if len(report) == 1 + 19 and report[0] == 0x11:
|
||||
device["hidpp_long"] = True
|
||||
except HIDError as e: # noqa: F841
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info(f"Error opening device {device['path']} ({bus_id}/{vid:04X}/{pid:04X}) for hidpp check: {e}") # noqa
|
||||
|
||||
try:
|
||||
device_handle = open_path(device["path"])
|
||||
|
||||
for check_func in (check_hidpp_short, check_hidpp_long):
|
||||
try:
|
||||
check_func()
|
||||
except HIDError as e:
|
||||
logger.info(
|
||||
f"Error while {check_func.__name__}"
|
||||
f"on device {device['path']} ({bus_id}/{vid:04X}/{pid:04X}) for hidpp check: {e}"
|
||||
)
|
||||
except HIDError as e:
|
||||
logger.info(f"Error opening device {device['path']} ({bus_id}/{vid:04X}/{pid:04X}) for hidpp check: {e}")
|
||||
finally:
|
||||
if device_handle:
|
||||
close(device_handle)
|
||||
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info(
|
||||
"Found device BID %s VID %04X PID %04X HID++ %s %s", bus_id, vid, pid, device["hidpp_short"], device["hidpp_long"]
|
||||
)
|
||||
logger.info(
|
||||
"Found device BID %s VID %04X PID %04X HID++ SHORT %s LONG %s",
|
||||
bus_id,
|
||||
vid,
|
||||
pid,
|
||||
device["hidpp_short"],
|
||||
device["hidpp_long"],
|
||||
)
|
||||
|
||||
if not device["hidpp_short"] and not device["hidpp_long"]:
|
||||
return None
|
||||
|
||||
filter = filterfn(bus_id, vid, pid, device["hidpp_short"], device["hidpp_long"])
|
||||
if not filter:
|
||||
filtered_result = filter_func(bus_id, vid, pid, device["hidpp_short"], device["hidpp_long"])
|
||||
if not filtered_result:
|
||||
return
|
||||
isDevice = filter.get("isDevice")
|
||||
is_device = filtered_result.get("isDevice")
|
||||
|
||||
if action == "add":
|
||||
if action == ACTION_ADD:
|
||||
d_info = DeviceInfo(
|
||||
path=device["path"].decode(),
|
||||
bus_id=bus_id,
|
||||
@@ -295,13 +312,13 @@ def _match(action, device, filterfn):
|
||||
product=device["product_string"],
|
||||
serial=device["serial_number"],
|
||||
release=device["release_number"],
|
||||
isDevice=isDevice,
|
||||
isDevice=is_device,
|
||||
hidpp_short=device["hidpp_short"],
|
||||
hidpp_long=device["hidpp_long"],
|
||||
)
|
||||
return d_info
|
||||
|
||||
elif action == "remove":
|
||||
elif action == ACTION_REMOVE:
|
||||
d_info = DeviceInfo(
|
||||
path=device["path"].decode(),
|
||||
bus_id=None,
|
||||
@@ -313,31 +330,48 @@ def _match(action, device, filterfn):
|
||||
product=None,
|
||||
serial=None,
|
||||
release=None,
|
||||
isDevice=isDevice,
|
||||
isDevice=is_device,
|
||||
hidpp_short=None,
|
||||
hidpp_long=None,
|
||||
)
|
||||
return d_info
|
||||
|
||||
logger.info(f"Finished checking HIDPP support for device {device['path']} ({bus_id}/{vid:04X}/{pid:04X})")
|
||||
|
||||
def find_paired_node(receiver_path, index, timeout):
|
||||
|
||||
def find_paired_node(receiver_path: str, index: int, timeout: int):
|
||||
"""Find the node of a device paired with a receiver"""
|
||||
return None
|
||||
|
||||
|
||||
def find_paired_node_wpid(receiver_path, index):
|
||||
def find_paired_node_wpid(receiver_path: str, index: int):
|
||||
"""Find the node of a device paired with a receiver, get wpid from udev"""
|
||||
return None
|
||||
|
||||
|
||||
def monitor_glib(callback, filterfn):
|
||||
def device_callback(action, device):
|
||||
# print(f"device_callback({action}): {device}")
|
||||
if action == "add":
|
||||
d_info = _match(action, device, filterfn)
|
||||
def monitor_glib(
|
||||
glib: GLib,
|
||||
callback: Callable,
|
||||
filter_func: Callable[[int, int, int, bool, bool], dict[str, Any]],
|
||||
) -> None:
|
||||
"""Monitor GLib.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
glib
|
||||
GLib instance.
|
||||
callback
|
||||
Called when device found.
|
||||
filter_func
|
||||
Filter devices callback.
|
||||
"""
|
||||
|
||||
def device_callback(action: str, device):
|
||||
if action == ACTION_ADD:
|
||||
d_info = _match(action, device, filter_func)
|
||||
if d_info:
|
||||
GLib.idle_add(callback, action, d_info)
|
||||
elif action == "remove":
|
||||
glib.idle_add(callback, action, d_info)
|
||||
elif action == ACTION_REMOVE:
|
||||
# Removed devices will be detected by Solaar directly
|
||||
pass
|
||||
|
||||
@@ -345,7 +379,7 @@ def monitor_glib(callback, filterfn):
|
||||
monitor.start()
|
||||
|
||||
|
||||
def enumerate(filterfn):
|
||||
def enumerate(filter_func) -> DeviceInfo:
|
||||
"""Enumerate the HID Devices.
|
||||
|
||||
List all the HID devices attached to the system, optionally filtering by
|
||||
@@ -354,7 +388,7 @@ def enumerate(filterfn):
|
||||
:returns: a list of matching ``DeviceInfo`` tuples.
|
||||
"""
|
||||
for device in _enumerate_devices():
|
||||
d_info = _match("add", device, filterfn)
|
||||
d_info = _match(ACTION_ADD, device, filter_func)
|
||||
if d_info:
|
||||
yield d_info
|
||||
|
||||
@@ -375,7 +409,7 @@ def open(vendor_id, product_id, serial=None):
|
||||
return device_handle
|
||||
|
||||
|
||||
def open_path(device_path):
|
||||
def open_path(device_path: str) -> int:
|
||||
"""Open a HID device by its path name.
|
||||
|
||||
:param device_path: the path of a ``DeviceInfo`` tuple returned by enumerate().
|
||||
@@ -391,7 +425,7 @@ def open_path(device_path):
|
||||
return device_handle
|
||||
|
||||
|
||||
def close(device_handle):
|
||||
def close(device_handle) -> None:
|
||||
"""Close a HID device.
|
||||
|
||||
:param device_handle: a device handle returned by open() or open_path().
|
||||
@@ -400,7 +434,7 @@ def close(device_handle):
|
||||
_hidapi.hid_close(device_handle)
|
||||
|
||||
|
||||
def write(device_handle, data):
|
||||
def write(device_handle: int, data: bytes) -> int:
|
||||
"""Write an Output report to a HID device.
|
||||
|
||||
:param device_handle: a device handle returned by open() or open_path().
|
||||
@@ -457,12 +491,11 @@ def read(device_handle, bytes_count, timeout_ms=None):
|
||||
|
||||
if bytes_read < 0:
|
||||
raise HIDError(_hidapi.hid_error(device_handle))
|
||||
return None
|
||||
|
||||
return data.raw[:bytes_read]
|
||||
|
||||
|
||||
def get_input_report(device_handle, report_id, size):
|
||||
def _get_input_report(device_handle, report_id, size):
|
||||
assert device_handle
|
||||
data = ctypes.create_string_buffer(size)
|
||||
data[0] = bytearray((report_id,))
|
||||
@@ -17,17 +17,23 @@
|
||||
import argparse
|
||||
import os
|
||||
import os.path
|
||||
import platform
|
||||
import readline
|
||||
import sys
|
||||
import time
|
||||
|
||||
from binascii import hexlify
|
||||
from binascii import unhexlify
|
||||
from select import select as _select
|
||||
from select import select
|
||||
from threading import Lock
|
||||
from threading import Thread
|
||||
|
||||
import hidapi as _hid
|
||||
if platform.system() == "Linux":
|
||||
import hidapi.udev_impl as hidapi
|
||||
else:
|
||||
import hidapi.hidapi_impl as hidapi
|
||||
|
||||
LOGITECH_VENDOR_ID = 0x046D
|
||||
|
||||
interactive = os.isatty(0)
|
||||
prompt = "?? Input: " if interactive else ""
|
||||
@@ -38,17 +44,13 @@ def strhex(d):
|
||||
return hexlify(d).decode("ascii").upper()
|
||||
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
print_lock = Lock()
|
||||
|
||||
|
||||
def _print(marker, data, scroll=False):
|
||||
t = time.time() - start_time
|
||||
if isinstance(data, str):
|
||||
s = marker + " " + data
|
||||
s = f"{marker} {data}"
|
||||
else:
|
||||
hexs = strhex(data)
|
||||
s = "%s (% 8.3f) [%s %s %s %s] %s" % (marker, t, hexs[0:2], hexs[2:4], hexs[4:8], hexs[8:], repr(data))
|
||||
@@ -86,9 +88,9 @@ def _error(text, scroll=False):
|
||||
def _continuous_read(handle, timeout=2000):
|
||||
while True:
|
||||
try:
|
||||
reply = _hid.read(handle, 128, timeout)
|
||||
reply = hidapi.read(handle, 128, timeout)
|
||||
except OSError as e:
|
||||
_error("Read failed, aborting: " + str(e), True)
|
||||
_error(f"Read failed, aborting: {str(e)}", True)
|
||||
break
|
||||
assert reply is not None
|
||||
if reply:
|
||||
@@ -99,7 +101,7 @@ def _validate_input(line, hidpp=False):
|
||||
try:
|
||||
data = unhexlify(line.encode("ascii"))
|
||||
except Exception as e:
|
||||
_error("Invalid input: " + str(e))
|
||||
_error(f"Invalid input: {str(e)}")
|
||||
return None
|
||||
|
||||
if hidpp:
|
||||
@@ -109,7 +111,7 @@ def _validate_input(line, hidpp=False):
|
||||
if data[:1] not in b"\x10\x11":
|
||||
_error("Invalid HID++ request: first byte must be 0x10 or 0x11")
|
||||
return None
|
||||
if data[1:2] not in b"\xFF\x00\x01\x02\x03\x04\x05\x06\x07":
|
||||
if data[1:2] not in b"\xff\x00\x01\x02\x03\x04\x05\x06\x07":
|
||||
_error("Invalid HID++ request: second byte must be 0xFF or one of 0x00..0x07")
|
||||
return None
|
||||
if data[:1] == b"\x10":
|
||||
@@ -130,13 +132,14 @@ def _validate_input(line, hidpp=False):
|
||||
|
||||
def _open(args):
|
||||
def matchfn(bid, vid, pid, _a, _b):
|
||||
if vid == 0x046D:
|
||||
return {"vid": 0x046D}
|
||||
if vid == LOGITECH_VENDOR_ID:
|
||||
return {"vid": vid}
|
||||
|
||||
device = args.device
|
||||
if args.hidpp and not device:
|
||||
for d in _hid.enumerate(matchfn):
|
||||
if d.driver == "logitech-djreceiver":
|
||||
device = args.path
|
||||
d = None
|
||||
if not device:
|
||||
for d in hidapi.enumerate(matchfn):
|
||||
if (d.hidpp_short or d.hidpp_long) and (args.id is None or args.id.lower() == d.product_id.lower()):
|
||||
device = d.path
|
||||
break
|
||||
if not device:
|
||||
@@ -144,41 +147,38 @@ def _open(args):
|
||||
if not device:
|
||||
sys.exit("!! Device path required.")
|
||||
|
||||
print(".. Opening device", device)
|
||||
handle = _hid.open_path(device)
|
||||
handle = hidapi.open_path(device)
|
||||
if not handle:
|
||||
sys.exit(f"!! Failed to open {device}, aborting.")
|
||||
print(
|
||||
".. Opened handle %r, vendor %r product %r serial %r."
|
||||
% (handle, _hid.get_manufacturer(handle), _hid.get_product(handle), _hid.get_serial(handle))
|
||||
".. Opened device %r, vendor %r product %r serial %r."
|
||||
% (
|
||||
device,
|
||||
hidapi.get_manufacturer(handle) or d.vendor_id if d else None,
|
||||
hidapi.get_product(handle) or d.product_id if d else None,
|
||||
hidapi.get_serial(handle),
|
||||
)
|
||||
)
|
||||
if args.hidpp:
|
||||
if _hid.get_manufacturer(handle) is not None and _hid.get_manufacturer(handle) != b"Logitech":
|
||||
if hidapi.get_manufacturer(handle) is not None and hidapi.get_manufacturer(handle) != b"Logitech":
|
||||
sys.exit("!! Only Logitech devices support the HID++ protocol.")
|
||||
print(".. HID++ validation enabled.")
|
||||
else:
|
||||
if _hid.get_manufacturer(handle) == b"Logitech" and b"Receiver" in _hid.get_product(handle):
|
||||
if hidapi.get_manufacturer(handle) == b"Logitech" and b"Receiver" in hidapi.get_product(handle):
|
||||
args.hidpp = True
|
||||
print(".. Logitech receiver detected, HID++ validation enabled.")
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
def _parse_arguments():
|
||||
arg_parser = argparse.ArgumentParser()
|
||||
arg_parser.add_argument("--history", help="history file (default ~/.hidconsole-history)")
|
||||
arg_parser.add_argument("--hidpp", action="store_true", help="ensure input data is a valid HID++ request")
|
||||
arg_parser.add_argument(
|
||||
"device",
|
||||
nargs="?",
|
||||
help="linux device to connect to (/dev/hidrawX); "
|
||||
"may be omitted if --hidpp is given, in which case it looks for the first Logitech receiver",
|
||||
)
|
||||
arg_parser.add_argument("command", nargs="?", help="command to send (otherwise get commands from input)")
|
||||
group = arg_parser.add_mutually_exclusive_group()
|
||||
group.add_argument("-p", "--path", help="HID raw device to connect to (/dev/hidrawX); ")
|
||||
group.add_argument("-i", "--id", help="product ID of device to connect to (XXXX)")
|
||||
return arg_parser.parse_args()
|
||||
|
||||
|
||||
@@ -186,6 +186,17 @@ def main():
|
||||
args = _parse_arguments()
|
||||
handle = _open(args)
|
||||
|
||||
if args.command: # send a command
|
||||
data = _validate_input(args.command, args.hidpp)
|
||||
if data:
|
||||
hidapi.write(handle, data)
|
||||
reply = hidapi.read(handle, 128, 1)
|
||||
if reply:
|
||||
hexs = strhex(reply)
|
||||
s = "[%s %s %s %s] %s" % (hexs[0:2], hexs[2:4], hexs[4:8], hexs[8:], repr(data))
|
||||
print(s)
|
||||
exit()
|
||||
|
||||
if interactive:
|
||||
print(".. Press ^C/^D to exit, or type hex bytes to write to the device.")
|
||||
|
||||
@@ -218,11 +229,11 @@ def main():
|
||||
continue
|
||||
|
||||
_print("<<", data)
|
||||
_hid.write(handle, data)
|
||||
hidapi.write(handle, data)
|
||||
# wait for some kind of reply
|
||||
if args.hidpp and not interactive:
|
||||
rlist, wlist, xlist = _select([handle], [], [], 1)
|
||||
if data[1:2] == b"\xFF":
|
||||
rlist, wlist, xlist = select([handle], [], [], 1)
|
||||
if data[1:2] == b"\xff":
|
||||
# the receiver will reply very fast, in a few milliseconds
|
||||
time.sleep(0.010)
|
||||
else:
|
||||
@@ -235,8 +246,7 @@ def main():
|
||||
time.sleep(1)
|
||||
|
||||
finally:
|
||||
print(f".. Closing handle {handle!r}")
|
||||
_hid.close(handle)
|
||||
hidapi.close(handle)
|
||||
if interactive:
|
||||
readline.write_history_file(args.history)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
"""Generic Human Interface Device API.
|
||||
|
||||
It is currently a partial pure-Python implementation of the native HID API
|
||||
@@ -22,52 +23,37 @@ The docstrings are mostly copied from the hidapi API header, with changes where
|
||||
necessary.
|
||||
"""
|
||||
|
||||
import errno as _errno
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import os as _os
|
||||
import warnings as _warnings
|
||||
import os
|
||||
import typing
|
||||
import warnings
|
||||
|
||||
|
||||
# the tuple object we'll expose when enumerating devices
|
||||
from collections import namedtuple
|
||||
from select import select as _select
|
||||
from select import select
|
||||
from time import sleep
|
||||
from time import time as _timestamp
|
||||
from time import time
|
||||
from typing import Callable
|
||||
|
||||
import gi
|
||||
import pyudev
|
||||
|
||||
from pyudev import Context as _Context
|
||||
from pyudev import Device as _Device
|
||||
from pyudev import DeviceNotFoundError
|
||||
from pyudev import Devices as _Devices
|
||||
from pyudev import Monitor as _Monitor
|
||||
from hidapi.common import DeviceInfo
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
from gi.repository import GLib # NOQA: E402
|
||||
if typing.TYPE_CHECKING:
|
||||
import gi
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
from gi.repository import GLib # NOQA: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
native_implementation = "udev"
|
||||
fileopen = open
|
||||
|
||||
DeviceInfo = namedtuple(
|
||||
"DeviceInfo",
|
||||
[
|
||||
"path",
|
||||
"bus_id",
|
||||
"vendor_id",
|
||||
"product_id",
|
||||
"interface",
|
||||
"driver",
|
||||
"manufacturer",
|
||||
"product",
|
||||
"serial",
|
||||
"release",
|
||||
"isDevice",
|
||||
"hidpp_short",
|
||||
"hidpp_long",
|
||||
],
|
||||
)
|
||||
ACTION_ADD = "add"
|
||||
ACTION_REMOVE = "remove"
|
||||
|
||||
#
|
||||
# exposed API
|
||||
@@ -93,12 +79,14 @@ def exit():
|
||||
return True
|
||||
|
||||
|
||||
# The filterfn is used to determine whether this is a device of interest to Solaar.
|
||||
# It is given the bus id, vendor id, and product id and returns a dictionary
|
||||
# with the required hid_driver and usb_interface and whether this is a receiver or device.
|
||||
def _match(action, device, filterfn):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(f"Dbus event {action} {device}")
|
||||
def _match(action: str, device, filter_func: typing.Callable[[int, int, int, bool, bool], dict[str, typing.Any]]):
|
||||
"""
|
||||
|
||||
The filter_func is used to determine whether this is a device of
|
||||
interest to Solaar. It is given the bus id, vendor id, and product
|
||||
id and returns a dictionary with the required hid_driver and
|
||||
usb_interface and whether this is a receiver or device."""
|
||||
logger.debug(f"Dbus event {action} {device}")
|
||||
hid_device = device.find_parent("hid")
|
||||
if hid_device is None: # only HID devices are of interest to Solaar
|
||||
return
|
||||
@@ -111,14 +99,14 @@ def _match(action, device, filterfn):
|
||||
return # these are devices connected through a receiver so don't pick them up here
|
||||
|
||||
try: # if report descriptor does not indicate HID++ capabilities then this device is not of interest to Solaar
|
||||
from hid_parser import ReportDescriptor as _ReportDescriptor
|
||||
from hid_parser import ReportDescriptor
|
||||
|
||||
hidpp_short = hidpp_long = False
|
||||
devfile = "/sys" + hid_device.properties.get("DEVPATH") + "/report_descriptor"
|
||||
with fileopen(devfile, "rb") as fd:
|
||||
with _warnings.catch_warnings():
|
||||
_warnings.simplefilter("ignore")
|
||||
rd = _ReportDescriptor(fd.read())
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
rd = ReportDescriptor(fd.read())
|
||||
hidpp_short = 0x10 in rd.input_report_ids and 6 * 8 == int(rd.get_input_report_size(0x10))
|
||||
# and _Usage(0xFF00, 0x0001) in rd.get_input_items(0x10)[0].usages # be more permissive
|
||||
hidpp_long = 0x11 in rd.input_report_ids and 19 * 8 == int(rd.get_input_report_size(0x11))
|
||||
@@ -126,43 +114,39 @@ def _match(action, device, filterfn):
|
||||
if not hidpp_short and not hidpp_long:
|
||||
return
|
||||
except Exception as e: # if can't process report descriptor fall back to old scheme
|
||||
hidpp_short = hidpp_long = None
|
||||
hidpp_short = None
|
||||
hidpp_long = None
|
||||
logger.info(
|
||||
"Report Descriptor not processed for DEVICE %s BID %s VID %s PID %s: %s", device.device_node, bid, vid, pid, e
|
||||
"Report Descriptor not processed for DEVICE %s BID %s VID %s PID %s: %s",
|
||||
device.device_node,
|
||||
bid,
|
||||
vid,
|
||||
pid,
|
||||
e,
|
||||
)
|
||||
|
||||
filter = filterfn(int(bid, 16), int(vid, 16), int(pid, 16), hidpp_short, hidpp_long)
|
||||
if not filter:
|
||||
filtered_result = filter_func(int(bid, 16), int(vid, 16), int(pid, 16), hidpp_short, hidpp_long)
|
||||
if not filtered_result:
|
||||
return
|
||||
hid_driver = filter.get("hid_driver")
|
||||
interface_number = filter.get("usb_interface")
|
||||
isDevice = filter.get("isDevice")
|
||||
interface_number = filtered_result.get("usb_interface")
|
||||
isDevice = filtered_result.get("isDevice")
|
||||
|
||||
if action == "add":
|
||||
if action == ACTION_ADD:
|
||||
hid_driver_name = hid_device.properties.get("DRIVER")
|
||||
# print ("** found hid", action, device, "hid:", hid_device, hid_driver_name)
|
||||
if hid_driver:
|
||||
if isinstance(hid_driver, tuple):
|
||||
if hid_driver_name not in hid_driver:
|
||||
return
|
||||
elif hid_driver_name != hid_driver:
|
||||
return
|
||||
|
||||
intf_device = device.find_parent("usb", "usb_interface")
|
||||
usb_interface = None if intf_device is None else intf_device.attributes.asint("bInterfaceNumber")
|
||||
# print('*** usb interface', action, device, 'usb_interface:', intf_device, usb_interface, interface_number)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info(
|
||||
"Found device %s BID %s VID %s PID %s HID++ %s %s USB %s %s",
|
||||
device.device_node,
|
||||
bid,
|
||||
vid,
|
||||
pid,
|
||||
hidpp_short,
|
||||
hidpp_long,
|
||||
usb_interface,
|
||||
interface_number,
|
||||
)
|
||||
logger.info(
|
||||
"Found device %s BID %s VID %s PID %s HID++ %s %s USB %s %s",
|
||||
device.device_node,
|
||||
bid,
|
||||
vid,
|
||||
pid,
|
||||
hidpp_short,
|
||||
hidpp_long,
|
||||
usb_interface,
|
||||
interface_number,
|
||||
)
|
||||
if not (hidpp_short or hidpp_long or interface_number is None or interface_number == usb_interface):
|
||||
return
|
||||
attrs = intf_device.attributes if intf_device is not None else None
|
||||
@@ -184,9 +168,7 @@ def _match(action, device, filterfn):
|
||||
)
|
||||
return d_info
|
||||
|
||||
elif action == "remove":
|
||||
# print (dict(device), dict(usb_device))
|
||||
|
||||
elif action == ACTION_REMOVE:
|
||||
d_info = DeviceInfo(
|
||||
path=device.device_node,
|
||||
bus_id=None,
|
||||
@@ -205,31 +187,31 @@ def _match(action, device, filterfn):
|
||||
return d_info
|
||||
|
||||
|
||||
def find_paired_node(receiver_path, index, timeout):
|
||||
def find_paired_node(receiver_path: str, index: int, timeout: int):
|
||||
"""Find the node of a device paired with a receiver"""
|
||||
context = _Context()
|
||||
receiver_phys = _Devices.from_device_file(context, receiver_path).find_parent("hid").get("HID_PHYS")
|
||||
context = pyudev.Context()
|
||||
receiver_phys = pyudev.Devices.from_device_file(context, receiver_path).find_parent("hid").get("HID_PHYS")
|
||||
|
||||
if not receiver_phys:
|
||||
return None
|
||||
|
||||
phys = f"{receiver_phys}:{index}" # noqa: E231
|
||||
timeout += _timestamp()
|
||||
delta = _timestamp()
|
||||
timeout += time()
|
||||
delta = time()
|
||||
while delta < timeout:
|
||||
for dev in context.list_devices(subsystem="hidraw"):
|
||||
dev_phys = dev.find_parent("hid").get("HID_PHYS")
|
||||
if dev_phys and dev_phys == phys:
|
||||
return dev.device_node
|
||||
delta = _timestamp()
|
||||
delta = time()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_paired_node_wpid(receiver_path, index):
|
||||
def find_paired_node_wpid(receiver_path: str, index: int):
|
||||
"""Find the node of a device paired with a receiver, get wpid from udev"""
|
||||
context = _Context()
|
||||
receiver_phys = _Devices.from_device_file(context, receiver_path).find_parent("hid").get("HID_PHYS")
|
||||
context = pyudev.Context()
|
||||
receiver_phys = pyudev.Devices.from_device_file(context, receiver_path).find_parent("hid").get("HID_PHYS")
|
||||
|
||||
if not receiver_phys:
|
||||
return None
|
||||
@@ -247,55 +229,48 @@ def find_paired_node_wpid(receiver_path, index):
|
||||
return None
|
||||
|
||||
|
||||
def monitor_glib(callback, filterfn):
|
||||
c = _Context()
|
||||
def monitor_glib(glib: GLib, callback: Callable, filter_func: Callable):
|
||||
"""Monitor GLib.
|
||||
|
||||
# already existing devices
|
||||
# for device in c.list_devices(subsystem='hidraw'):
|
||||
# # print (device, dict(device), dict(device.attributes))
|
||||
# for filter in device_filters:
|
||||
# d_info = _match('add', device, *filter)
|
||||
# if d_info:
|
||||
# GLib.idle_add(callback, 'add', d_info)
|
||||
# break
|
||||
|
||||
m = _Monitor.from_netlink(c)
|
||||
Parameters
|
||||
----------
|
||||
glib
|
||||
GLib instance.
|
||||
"""
|
||||
c = pyudev.Context()
|
||||
m = pyudev.Monitor.from_netlink(c)
|
||||
m.filter_by(subsystem="hidraw")
|
||||
|
||||
def _process_udev_event(monitor, condition, cb, filterfn):
|
||||
if condition == GLib.IO_IN:
|
||||
def _process_udev_event(monitor, condition, cb, filter_func):
|
||||
if condition == glib.IO_IN:
|
||||
event = monitor.receive_device()
|
||||
if event:
|
||||
action, device = event
|
||||
# print ("***", action, device)
|
||||
if action == "add":
|
||||
d_info = _match(action, device, filterfn)
|
||||
if action == ACTION_ADD:
|
||||
d_info = _match(action, device, filter_func)
|
||||
if d_info:
|
||||
GLib.idle_add(cb, action, d_info)
|
||||
elif action == "remove":
|
||||
glib.idle_add(cb, action, d_info)
|
||||
elif action == ACTION_REMOVE:
|
||||
# the GLib notification does _not_ match!
|
||||
pass
|
||||
return True
|
||||
|
||||
try:
|
||||
# io_add_watch_full may not be available...
|
||||
GLib.io_add_watch_full(m, GLib.PRIORITY_LOW, GLib.IO_IN, _process_udev_event, callback, filterfn)
|
||||
# print ("did io_add_watch_full")
|
||||
glib.io_add_watch_full(m, glib.PRIORITY_LOW, glib.IO_IN, _process_udev_event, callback, filter_func)
|
||||
except AttributeError:
|
||||
try:
|
||||
# and the priority parameter appeared later in the API
|
||||
GLib.io_add_watch(m, GLib.PRIORITY_LOW, GLib.IO_IN, _process_udev_event, callback, filterfn)
|
||||
# print ("did io_add_watch with priority")
|
||||
glib.io_add_watch(m, glib.PRIORITY_LOW, glib.IO_IN, _process_udev_event, callback, filter_func)
|
||||
except Exception:
|
||||
GLib.io_add_watch(m, GLib.IO_IN, _process_udev_event, callback, filterfn)
|
||||
# print ("did io_add_watch")
|
||||
glib.io_add_watch(m, glib.IO_IN, _process_udev_event, callback, filter_func)
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Starting dbus monitoring")
|
||||
logger.debug("Starting dbus monitoring")
|
||||
m.start()
|
||||
|
||||
|
||||
def enumerate(filterfn):
|
||||
def enumerate(filter_func: typing.Callable[[int, int, int, bool, bool], dict[str, typing.Any]]):
|
||||
"""Enumerate the HID Devices.
|
||||
|
||||
List all the HID devices attached to the system, optionally filtering by
|
||||
@@ -304,10 +279,9 @@ def enumerate(filterfn):
|
||||
:returns: a list of matching ``DeviceInfo`` tuples.
|
||||
"""
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Starting dbus enumeration")
|
||||
for dev in _Context().list_devices(subsystem="hidraw"):
|
||||
dev_info = _match("add", dev, filterfn)
|
||||
logger.debug("Starting dbus enumeration")
|
||||
for dev in pyudev.Context().list_devices(subsystem="hidraw"):
|
||||
dev_info = _match(ACTION_ADD, dev, filter_func)
|
||||
if dev_info:
|
||||
yield dev_info
|
||||
|
||||
@@ -343,22 +317,22 @@ def open_path(device_path):
|
||||
while retrycount < 3:
|
||||
retrycount += 1
|
||||
try:
|
||||
return _os.open(device_path, _os.O_RDWR | _os.O_SYNC)
|
||||
return os.open(device_path, os.O_RDWR | os.O_SYNC)
|
||||
except OSError as e:
|
||||
logger.info("OPEN PATH FAILED %s ERROR %s %s", device_path, e.errno, e)
|
||||
if e.errno == _errno.EACCES:
|
||||
if e.errno == errno.EACCES:
|
||||
sleep(0.1)
|
||||
else:
|
||||
raise
|
||||
raise e
|
||||
|
||||
|
||||
def close(device_handle):
|
||||
def close(device_handle) -> None:
|
||||
"""Close a HID device.
|
||||
|
||||
:param device_handle: a device handle returned by open() or open_path().
|
||||
"""
|
||||
assert device_handle
|
||||
_os.close(device_handle)
|
||||
os.close(device_handle)
|
||||
|
||||
|
||||
def write(device_handle, data):
|
||||
@@ -390,14 +364,14 @@ def write(device_handle, data):
|
||||
while retrycount < 3:
|
||||
try:
|
||||
retrycount += 1
|
||||
bytes_written = _os.write(device_handle, data)
|
||||
bytes_written = os.write(device_handle, data)
|
||||
except OSError as e:
|
||||
if e.errno == _errno.EPIPE:
|
||||
if e.errno == errno.EPIPE:
|
||||
sleep(0.1)
|
||||
else:
|
||||
break
|
||||
if bytes_written != len(data):
|
||||
raise OSError(_errno.EIO, f"written {int(bytes_written)} bytes out of expected {len(data)}")
|
||||
raise OSError(errno.EIO, f"written {int(bytes_written)} bytes out of expected {len(data)}")
|
||||
|
||||
|
||||
def read(device_handle, bytes_count, timeout_ms=-1):
|
||||
@@ -418,15 +392,15 @@ def read(device_handle, bytes_count, timeout_ms=-1):
|
||||
"""
|
||||
assert device_handle
|
||||
timeout = None if timeout_ms < 0 else timeout_ms / 1000.0
|
||||
rlist, wlist, xlist = _select([device_handle], [], [device_handle], timeout)
|
||||
rlist, wlist, xlist = select([device_handle], [], [device_handle], timeout)
|
||||
|
||||
if xlist:
|
||||
assert xlist == [device_handle]
|
||||
raise OSError(_errno.EIO, f"exception on file descriptor {int(device_handle)}")
|
||||
raise OSError(errno.EIO, f"exception on file descriptor {int(device_handle)}")
|
||||
|
||||
if rlist:
|
||||
assert rlist == [device_handle]
|
||||
data = _os.read(device_handle, bytes_count)
|
||||
data = os.read(device_handle, bytes_count)
|
||||
assert data is not None
|
||||
assert isinstance(data, bytes), (repr(data), type(data))
|
||||
return data
|
||||
@@ -482,10 +456,10 @@ def get_indexed_string(device_handle, index):
|
||||
return None
|
||||
|
||||
assert device_handle
|
||||
stat = _os.fstat(device_handle)
|
||||
stat = os.fstat(device_handle)
|
||||
try:
|
||||
dev = _Device.from_device_number(_Context(), "char", stat.st_rdev)
|
||||
except (DeviceNotFoundError, ValueError):
|
||||
dev = pyudev.Devices.from_device_number(pyudev.Context(), "char", stat.st_rdev)
|
||||
except (pyudev.DeviceNotFoundError, ValueError):
|
||||
return None
|
||||
|
||||
hid_dev = dev.find_parent("hid")
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract key symbol encodings from X11 header files."""
|
||||
|
||||
from pathlib import Path
|
||||
from pprint import pprint
|
||||
from re import findall
|
||||
from subprocess import run
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
repo = "https://github.com/freedesktop/xorg-proto-x11proto.git"
|
||||
xx = "https://gitlab.freedesktop.org/xorg/proto/xorgproto/-/tree/master/include/X11/"
|
||||
repo = "https://gitlab.freedesktop.org/xorg/proto/xorgproto.git"
|
||||
pattern = r"#define XK_(\w+)\s+0x(\w+)(?:\s+/\*\s+U\+(\w+))?"
|
||||
xf86pattern = r"#define XF86XK_(\w+)\s+0x(\w+)(?:\s+/\*\s+U\+(\w+))?"
|
||||
@@ -14,28 +14,24 @@ xf86pattern = r"#define XF86XK_(\w+)\s+0x(\w+)(?:\s+/\*\s+U\+(\w+))?"
|
||||
|
||||
def main():
|
||||
keysymdef = {}
|
||||
keysym_files = [
|
||||
("include/X11/keysymdef.h", pattern, ""),
|
||||
("include/X11/XF86keysym.h", xf86pattern, "XF86_"),
|
||||
]
|
||||
|
||||
with TemporaryDirectory() as temp:
|
||||
run(["git", "clone", repo, "."], cwd=temp)
|
||||
# text = Path(temp, 'keysymdef.h').read_text()
|
||||
text = Path(temp, "include/X11/keysymdef.h").read_text()
|
||||
for name, sym, uni in findall(pattern, text):
|
||||
sym = int(sym, 16)
|
||||
uni = int(uni, 16) if uni else None
|
||||
if keysymdef.get(name, None):
|
||||
print("KEY DUP", name)
|
||||
keysymdef[name] = sym
|
||||
# text = Path(temp, 'keysymdef.h').read_text()
|
||||
text = Path(temp, "include/X11/XF86keysym.h").read_text()
|
||||
for name, sym, uni in findall(xf86pattern, text):
|
||||
sym = int(sym, 16)
|
||||
uni = int(uni, 16) if uni else None
|
||||
if keysymdef.get("XF86_" + name, None):
|
||||
print("KEY DUP", "XF86_" + name)
|
||||
keysymdef["XF86_" + name] = sym
|
||||
|
||||
for filename, extraction_pattern, prefix in keysym_files:
|
||||
text = Path(temp, filename).read_text()
|
||||
for name, sym, _ in findall(extraction_pattern, text):
|
||||
sym = int(sym, 16)
|
||||
if keysymdef.get(f"{prefix}{name}", None):
|
||||
print(f"KEY DUP {prefix}{name}")
|
||||
keysymdef[f"{prefix}{name}"] = sym
|
||||
|
||||
with open("keysymdef.py", "w") as f:
|
||||
f.write("# flake8: noqa\nkeysymdef = \\\n")
|
||||
f.write("# flake8: noqa\nkey_symbols = \\\n")
|
||||
pprint(keysymdef, f)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# flake8: noqa
|
||||
keysymdef = {
|
||||
key_symbols = {
|
||||
"0": 48,
|
||||
"1": 49,
|
||||
"2": 50,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
"""Low-level interface for devices using Logitech HID++ protocol.
|
||||
|
||||
Uses the HID api exposed through hidapi.py, a Python thin layer over a native
|
||||
Uses the HID api exposed through hidapi_impl.py, a Python thin layer over a native
|
||||
implementation.
|
||||
"""
|
||||
|
||||
|
||||
@@ -14,84 +14,80 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
# Base low-level functions used by the API proper.
|
||||
# Unlikely to be used directly unless you're expanding the API.
|
||||
"""Base low-level functions as API for upper layers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import threading as _threading
|
||||
import platform
|
||||
import struct
|
||||
import threading
|
||||
import typing
|
||||
|
||||
from collections import namedtuple
|
||||
from contextlib import contextmanager
|
||||
from random import getrandbits as _random_bits
|
||||
from struct import pack as _pack
|
||||
from time import time as _timestamp
|
||||
|
||||
import hidapi as _hid
|
||||
from random import getrandbits
|
||||
from time import time
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
|
||||
from . import base_usb
|
||||
from . import common
|
||||
from . import descriptors
|
||||
from . import exceptions
|
||||
from . import hidpp10_constants as _hidpp10_constants
|
||||
from . import hidpp20
|
||||
from . import hidpp20_constants as _hidpp20_constants
|
||||
from .base_usb import ALL as _RECEIVER_USB_IDS
|
||||
from .common import strhex as _strhex
|
||||
from .descriptors import DEVICES as _DEVICES
|
||||
from .common import LOGITECH_VENDOR_ID
|
||||
from .common import BusID
|
||||
from .hidpp10_constants import ErrorCode as Hidpp10ErrorCode
|
||||
from .hidpp20_constants import ErrorCode as Hidpp20ErrorCode
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import gi
|
||||
|
||||
from hidapi.common import DeviceInfo
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
from gi.repository import GLib # NOQA: E402
|
||||
|
||||
if platform.system() == "Linux":
|
||||
import hidapi.udev_impl as hidapi
|
||||
else:
|
||||
import hidapi.hidapi_impl as hidapi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_hidpp20 = hidpp20.Hidpp20()
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
class HIDProtocol(typing.Protocol):
|
||||
def find_paired_node_wpid(self, receiver_path: str, index: int):
|
||||
...
|
||||
|
||||
def find_paired_node(self, receiver_path: str, index: int, timeout: int):
|
||||
...
|
||||
|
||||
def open(self, vendor_id, product_id, serial=None):
|
||||
...
|
||||
|
||||
def open_path(self, path) -> int:
|
||||
...
|
||||
|
||||
def enumerate(self, filter_func: Callable[[int, int, int, bool, bool], dict[str, typing.Any]]) -> DeviceInfo:
|
||||
...
|
||||
|
||||
def monitor_glib(
|
||||
self, glib: GLib, callback: Callable, filter_func: Callable[[int, int, int, bool, bool], dict[str, typing.Any]]
|
||||
) -> None:
|
||||
...
|
||||
|
||||
def read(self, device_handle, bytes_count, timeout_ms):
|
||||
...
|
||||
|
||||
def write(self, device_handle: int, data: bytes) -> int:
|
||||
...
|
||||
|
||||
def close(self, device_handle) -> None:
|
||||
...
|
||||
|
||||
|
||||
def _wired_device(product_id, interface):
|
||||
return {"vendor_id": 1133, "product_id": product_id, "bus_id": 3, "usb_interface": interface, "isDevice": True}
|
||||
|
||||
|
||||
def _bt_device(product_id):
|
||||
return {"vendor_id": 1133, "product_id": product_id, "bus_id": 5, "isDevice": True}
|
||||
|
||||
|
||||
DEVICE_IDS = []
|
||||
|
||||
for _ignore, d in _DEVICES.items():
|
||||
if d.usbid:
|
||||
DEVICE_IDS.append(_wired_device(d.usbid, d.interface if d.interface else 2))
|
||||
if d.btid:
|
||||
DEVICE_IDS.append(_bt_device(d.btid))
|
||||
|
||||
|
||||
def other_device_check(bus_id, vendor_id, product_id):
|
||||
"""Check whether product is a Logitech USB-connected or Bluetooth device based on bus, vendor, and product IDs
|
||||
This allows Solaar to support receiverless HID++ 2.0 devices that it knows nothing about"""
|
||||
if vendor_id != 0x46D: # Logitech
|
||||
return
|
||||
if bus_id == 0x3: # USB
|
||||
if product_id >= 0xC07D and product_id <= 0xC094 or product_id >= 0xC32B and product_id <= 0xC344:
|
||||
return _wired_device(product_id, 2)
|
||||
elif bus_id == 0x5: # Bluetooth
|
||||
if product_id >= 0xB012 and product_id <= 0xB0FF or product_id >= 0xB317 and product_id <= 0xB3FF:
|
||||
return _bt_device(product_id)
|
||||
|
||||
|
||||
def product_information(usb_id: int | str) -> dict:
|
||||
if isinstance(usb_id, str):
|
||||
usb_id = int(usb_id, 16)
|
||||
|
||||
for r in _RECEIVER_USB_IDS:
|
||||
if usb_id == r.get("product_id"):
|
||||
return r
|
||||
return {}
|
||||
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
_SHORT_MESSAGE_SIZE = 7
|
||||
SHORT_MESSAGE_SIZE = 7
|
||||
_LONG_MESSAGE_SIZE = 20
|
||||
_MEDIUM_MESSAGE_SIZE = 15
|
||||
_MAX_READ_SIZE = 32
|
||||
@@ -100,13 +96,7 @@ HIDPP_SHORT_MESSAGE_ID = 0x10
|
||||
HIDPP_LONG_MESSAGE_ID = 0x11
|
||||
DJ_MESSAGE_ID = 0x20
|
||||
|
||||
# mapping from report_id to message length
|
||||
report_lengths = {
|
||||
HIDPP_SHORT_MESSAGE_ID: _SHORT_MESSAGE_SIZE,
|
||||
HIDPP_LONG_MESSAGE_ID: _LONG_MESSAGE_SIZE,
|
||||
DJ_MESSAGE_ID: _MEDIUM_MESSAGE_SIZE,
|
||||
0x21: _MAX_READ_SIZE,
|
||||
}
|
||||
|
||||
"""Default timeout on read (in seconds)."""
|
||||
DEFAULT_TIMEOUT = 4
|
||||
# the receiver itself should reply very fast, within 500ms
|
||||
@@ -116,12 +106,111 @@ _DEVICE_REQUEST_TIMEOUT = DEFAULT_TIMEOUT
|
||||
# when pinging, be extra patient (no longer)
|
||||
_PING_TIMEOUT = DEFAULT_TIMEOUT
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
hidapi = typing.cast(HIDProtocol, hidapi)
|
||||
|
||||
request_lock = threading.Lock() # serialize all requests
|
||||
handles_lock = {}
|
||||
|
||||
|
||||
def match(record, bus_id, vendor_id, product_id):
|
||||
@dataclasses.dataclass
|
||||
class HIDPPNotification:
|
||||
report_id: int
|
||||
devnumber: int
|
||||
sub_id: int
|
||||
address: int
|
||||
data: bytes
|
||||
|
||||
def __str__(self):
|
||||
text_as_hex = common.strhex(self.data)
|
||||
return f"Notification({self.report_id:02x},{self.devnumber},{self.sub_id:02X},{self.address:02X},{text_as_hex})"
|
||||
|
||||
|
||||
def _usb_device(product_id: int, usb_interface: int) -> dict[str, Any]:
|
||||
return {
|
||||
"vendor_id": LOGITECH_VENDOR_ID,
|
||||
"product_id": product_id,
|
||||
"bus_id": BusID.USB,
|
||||
"usb_interface": usb_interface,
|
||||
"isDevice": True,
|
||||
}
|
||||
|
||||
|
||||
def _bluetooth_device(product_id: int) -> dict[str, Any]:
|
||||
return {"vendor_id": LOGITECH_VENDOR_ID, "product_id": product_id, "bus_id": BusID.BLUETOOTH, "isDevice": True}
|
||||
|
||||
|
||||
KNOWN_DEVICE_IDS = []
|
||||
|
||||
for _ignore, d in descriptors.DEVICES.items():
|
||||
if d.usbid:
|
||||
usb_interface = d.interface if d.interface else 2
|
||||
KNOWN_DEVICE_IDS.append(_usb_device(d.usbid, usb_interface))
|
||||
if d.btid:
|
||||
KNOWN_DEVICE_IDS.append(_bluetooth_device(d.btid))
|
||||
|
||||
|
||||
def product_information(usb_id: int) -> dict[str, Any]:
|
||||
"""Returns hardcoded information from USB receiver."""
|
||||
return base_usb.get_receiver_info(usb_id)
|
||||
|
||||
|
||||
def receivers():
|
||||
"""Enumerate all the receivers attached to the machine."""
|
||||
yield from hidapi.enumerate(get_known_receiver_info)
|
||||
|
||||
|
||||
def filter_products_of_interest(
|
||||
bus_id: int, vendor_id: int, product_id: int, hidpp_short: bool = False, hidpp_long: bool = False
|
||||
) -> dict[str, Any] | None:
|
||||
"""Check that this product is of interest and if so return the device record for further checking"""
|
||||
|
||||
recv = get_known_receiver_info(bus_id, vendor_id, product_id, hidpp_short, hidpp_long)
|
||||
if recv: # known or unknown receiver
|
||||
return recv
|
||||
|
||||
device = get_known_device_info(bus_id, vendor_id, product_id)
|
||||
if device:
|
||||
return device
|
||||
|
||||
if hidpp_short or hidpp_long:
|
||||
return get_unknown_hid_device_info(bus_id, vendor_id, product_id)
|
||||
|
||||
if hidpp_short is None and hidpp_long is None:
|
||||
return get_unknown_logitech_device_info(bus_id, vendor_id, product_id)
|
||||
return None
|
||||
|
||||
|
||||
def get_known_device_info(bus_id: int, vendor_id: int, product_id: int) -> dict[str, Any]:
|
||||
for recv in KNOWN_DEVICE_IDS:
|
||||
if _match_device(recv, bus_id, vendor_id, product_id):
|
||||
return recv
|
||||
|
||||
|
||||
def get_unknown_hid_device_info(bus_id: int, vendor_id: int, product_id: int) -> dict[str, Any]:
|
||||
return {"vendor_id": vendor_id, "product_id": product_id, "bus_id": bus_id, "isDevice": True}
|
||||
|
||||
|
||||
def get_unknown_logitech_device_info(bus_id: int, vendor_id: int, product_id: int) -> dict[str, Any] | None:
|
||||
"""Get info from unknown device in Logitech product range.
|
||||
|
||||
Check whether product is a Logitech USB-connected or Bluetooth
|
||||
device based on bus, vendor, and product ID. This allows Solaar to
|
||||
support receiverless HID++ 2.0 devices that it knows nothing about.
|
||||
"""
|
||||
if vendor_id != LOGITECH_VENDOR_ID:
|
||||
return None
|
||||
|
||||
if bus_id == BusID.USB.value and (0xC07D <= product_id <= 0xC094 or 0xC32B <= product_id <= 0xC344):
|
||||
device_info = _usb_device(product_id, 2)
|
||||
return device_info
|
||||
elif bus_id == BusID.BLUETOOTH.value and (0xB012 <= product_id <= 0xB0FF or 0xB317 <= product_id <= 0xB3FF):
|
||||
device_info = _bluetooth_device(product_id)
|
||||
return device_info
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _match_device(record: dict[str, Any], bus_id: int, vendor_id: int, product_id: int):
|
||||
return (
|
||||
(record.get("bus_id") is None or record.get("bus_id") == bus_id)
|
||||
and (record.get("vendor_id") is None or record.get("vendor_id") == vendor_id)
|
||||
@@ -129,50 +218,44 @@ def match(record, bus_id, vendor_id, product_id):
|
||||
)
|
||||
|
||||
|
||||
def filter_receivers(bus_id, vendor_id, product_id, hidpp_short=False, hidpp_long=False):
|
||||
"""Check that this product is a Logitech receiver and if so return the receiver record for further checking"""
|
||||
for record in _RECEIVER_USB_IDS: # known receivers
|
||||
if match(record, bus_id, vendor_id, product_id):
|
||||
def get_known_receiver_info(
|
||||
bus_id: int, vendor_id: int, product_id: int, _hidpp_short: bool = False, _hidpp_long: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Check that this product is a Logitech receiver and return it.
|
||||
|
||||
Filters based on bus_id, vendor_id and product_id.
|
||||
|
||||
If so return the receiver record for further checking.
|
||||
"""
|
||||
try:
|
||||
record = base_usb.get_receiver_info(product_id)
|
||||
if _match_device(record, bus_id, vendor_id, product_id):
|
||||
return record
|
||||
if vendor_id == 0x046D and 0xC500 <= product_id <= 0xC5FF: # unknown receiver
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if vendor_id == LOGITECH_VENDOR_ID and 0xC500 <= product_id <= 0xC5FF: # unknown receiver
|
||||
return {"vendor_id": vendor_id, "product_id": product_id, "bus_id": bus_id, "isDevice": False}
|
||||
|
||||
|
||||
def receivers():
|
||||
"""Enumerate all the receivers attached to the machine."""
|
||||
yield from _hid.enumerate(filter_receivers)
|
||||
|
||||
|
||||
def filter(bus_id, vendor_id, product_id, hidpp_short=False, hidpp_long=False):
|
||||
"""Check that this product is of interest and if so return the device record for further checking"""
|
||||
record = filter_receivers(bus_id, vendor_id, product_id, hidpp_short, hidpp_long)
|
||||
if record: # known or unknown receiver
|
||||
return record
|
||||
for record in DEVICE_IDS: # known devices
|
||||
if match(record, bus_id, vendor_id, product_id):
|
||||
return record
|
||||
if hidpp_short or hidpp_long: # unknown devices that use HID++
|
||||
return {"vendor_id": vendor_id, "product_id": product_id, "bus_id": bus_id, "isDevice": True}
|
||||
elif hidpp_short is None and hidpp_long is None: # unknown devices in correct range of IDs
|
||||
return other_device_check(bus_id, vendor_id, product_id)
|
||||
return None
|
||||
|
||||
|
||||
def receivers_and_devices():
|
||||
"""Enumerate all the receivers and devices directly attached to the machine."""
|
||||
yield from _hid.enumerate(filter)
|
||||
yield from hidapi.enumerate(filter_products_of_interest)
|
||||
|
||||
|
||||
def notify_on_receivers_glib(callback):
|
||||
"""Watch for matching devices and notifies the callback on the GLib thread."""
|
||||
return _hid.monitor_glib(callback, filter)
|
||||
def notify_on_receivers_glib(glib: GLib, callback: Callable):
|
||||
"""Watch for matching devices and notifies the callback on the GLib thread.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
glib
|
||||
GLib instance.
|
||||
"""
|
||||
return hidapi.monitor_glib(glib, callback, filter_products_of_interest)
|
||||
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
def open_path(path):
|
||||
def open_path(path) -> int:
|
||||
"""Checks if the given Linux device path points to the right UR device.
|
||||
|
||||
:param path: the Linux device path.
|
||||
@@ -185,7 +268,7 @@ def open_path(path):
|
||||
:returns: an open receiver handle if this is the right Linux device, or
|
||||
``None``.
|
||||
"""
|
||||
return _hid.open_path(path)
|
||||
return hidapi.open_path(path)
|
||||
|
||||
|
||||
def open():
|
||||
@@ -204,13 +287,11 @@ def close(handle):
|
||||
if handle:
|
||||
try:
|
||||
if isinstance(handle, int):
|
||||
_hid.close(handle)
|
||||
hidapi.close(handle)
|
||||
else:
|
||||
handle.close()
|
||||
# logger.info("closed receiver handle %r", handle)
|
||||
return True
|
||||
except Exception:
|
||||
# logger.exception("closing receiver handle %r", handle)
|
||||
pass
|
||||
|
||||
return False
|
||||
@@ -233,15 +314,22 @@ def write(handle, devnumber, data, long_message=False):
|
||||
assert data is not None
|
||||
assert isinstance(data, bytes), (repr(data), type(data))
|
||||
|
||||
if long_message or len(data) > _SHORT_MESSAGE_SIZE - 2 or data[:1] == b"\x82":
|
||||
wdata = _pack("!BB18s", HIDPP_LONG_MESSAGE_ID, devnumber, data)
|
||||
if long_message or len(data) > SHORT_MESSAGE_SIZE - 2 or data[:1] == b"\x82":
|
||||
wdata = struct.pack("!BB18s", HIDPP_LONG_MESSAGE_ID, devnumber, data)
|
||||
else:
|
||||
wdata = _pack("!BB5s", HIDPP_SHORT_MESSAGE_ID, devnumber, data)
|
||||
wdata = struct.pack("!BB5s", HIDPP_SHORT_MESSAGE_ID, devnumber, data)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("(%s) <= w[%02X %02X %s %s]", handle, ord(wdata[:1]), devnumber, _strhex(wdata[2:4]), _strhex(wdata[4:]))
|
||||
logger.debug(
|
||||
"(%s) <= w[%02X %02X %s %s]",
|
||||
handle,
|
||||
ord(wdata[:1]),
|
||||
devnumber,
|
||||
common.strhex(wdata[2:4]),
|
||||
common.strhex(wdata[4:]),
|
||||
)
|
||||
|
||||
try:
|
||||
_hid.write(int(handle), wdata)
|
||||
hidapi.write(int(handle), wdata)
|
||||
except Exception as reason:
|
||||
logger.error("write failed, assuming handle %r no longer available", handle)
|
||||
close(handle)
|
||||
@@ -266,19 +354,31 @@ def read(handle, timeout=DEFAULT_TIMEOUT):
|
||||
return reply
|
||||
|
||||
|
||||
# sanity checks on message report id and size
|
||||
def check_message(data):
|
||||
def _is_relevant_message(data: bytes) -> bool:
|
||||
"""Checks if given id is a HID++ or DJ message.
|
||||
|
||||
Applies sanity checks on message report ID and message size.
|
||||
"""
|
||||
assert isinstance(data, bytes), (repr(data), type(data))
|
||||
|
||||
# mapping from report_id to message length
|
||||
report_lengths = {
|
||||
HIDPP_SHORT_MESSAGE_ID: SHORT_MESSAGE_SIZE,
|
||||
HIDPP_LONG_MESSAGE_ID: _LONG_MESSAGE_SIZE,
|
||||
DJ_MESSAGE_ID: _MEDIUM_MESSAGE_SIZE,
|
||||
0x21: _MAX_READ_SIZE,
|
||||
}
|
||||
|
||||
report_id = ord(data[:1])
|
||||
if report_id in report_lengths: # is this an HID++ or DJ message?
|
||||
if report_id in report_lengths:
|
||||
if report_lengths.get(report_id) == len(data):
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"unexpected message size: report_id {report_id:02X} message {_strhex(data)}")
|
||||
logger.warning(f"unexpected message size: report_id {report_id:02X} message {common.strhex(data)}")
|
||||
return False
|
||||
|
||||
|
||||
def _read(handle, timeout):
|
||||
def _read(handle, timeout) -> tuple[int, int, bytes]:
|
||||
"""Read an incoming packet from the receiver.
|
||||
|
||||
:returns: a tuple of (report_id, devnumber, data), or `None`.
|
||||
@@ -290,73 +390,48 @@ def _read(handle, timeout):
|
||||
try:
|
||||
# convert timeout to milliseconds, the hidapi expects it
|
||||
timeout = int(timeout * 1000)
|
||||
data = _hid.read(int(handle), _MAX_READ_SIZE, timeout)
|
||||
data = hidapi.read(int(handle), _MAX_READ_SIZE, timeout)
|
||||
except Exception as reason:
|
||||
logger.warning("read failed, assuming handle %r no longer available", handle)
|
||||
close(handle)
|
||||
raise exceptions.NoReceiver(reason=reason) from reason
|
||||
|
||||
if data and check_message(data): # ignore messages that fail check
|
||||
if data and _is_relevant_message(data): # ignore messages that fail check
|
||||
report_id = ord(data[:1])
|
||||
devnumber = ord(data[1:2])
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG) and (
|
||||
report_id != DJ_MESSAGE_ID or ord(data[2:3]) > 0x10
|
||||
): # ignore DJ input messages
|
||||
logger.debug("(%s) => r[%02X %02X %s %s]", handle, report_id, devnumber, _strhex(data[2:4]), _strhex(data[4:]))
|
||||
logger.debug(
|
||||
"(%s) => r[%02X %02X %s %s]",
|
||||
handle,
|
||||
report_id,
|
||||
devnumber,
|
||||
common.strhex(data[2:4]),
|
||||
common.strhex(data[4:]),
|
||||
)
|
||||
|
||||
return report_id, devnumber, data[2:]
|
||||
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
def _skip_incoming(handle, ihandle, notifications_hook):
|
||||
"""Read anything already in the input buffer.
|
||||
|
||||
Used by request() and ping() before their write.
|
||||
"""
|
||||
|
||||
while True:
|
||||
try:
|
||||
# read whatever is already in the buffer, if any
|
||||
data = _hid.read(ihandle, _MAX_READ_SIZE, 0)
|
||||
except Exception as reason:
|
||||
logger.error("read failed, assuming receiver %s no longer available", handle)
|
||||
close(handle)
|
||||
raise exceptions.NoReceiver(reason=reason) from reason
|
||||
|
||||
if data:
|
||||
if check_message(data): # only process messages that pass check
|
||||
# report_id = ord(data[:1])
|
||||
if notifications_hook:
|
||||
n = make_notification(ord(data[:1]), ord(data[1:2]), data[2:])
|
||||
if n:
|
||||
notifications_hook(n)
|
||||
else:
|
||||
# nothing in the input buffer, we're done
|
||||
return
|
||||
|
||||
|
||||
def make_notification(report_id, devnumber, data):
|
||||
def make_notification(report_id: int, devnumber: int, data: bytes) -> HIDPPNotification | None:
|
||||
"""Guess if this is a notification (and not just a request reply), and
|
||||
return a Notification tuple if it is."""
|
||||
return a Notification if it is."""
|
||||
|
||||
sub_id = ord(data[:1])
|
||||
if sub_id & 0x80 == 0x80:
|
||||
# this is either a HID++1.0 register r/w, or an error reply
|
||||
return
|
||||
return None
|
||||
|
||||
# DJ input records are not notifications
|
||||
if report_id == DJ_MESSAGE_ID and (sub_id < 0x10):
|
||||
return
|
||||
return None
|
||||
|
||||
address = ord(data[1:2])
|
||||
if sub_id == 0x00 and (address & 0x0F == 0x00):
|
||||
# this is a no-op notification - don't do anything with it
|
||||
return
|
||||
return None
|
||||
|
||||
if (
|
||||
# standard HID++ 1.0 notification, SubId may be 0x40 - 0x7F
|
||||
@@ -371,24 +446,8 @@ def make_notification(report_id, devnumber, data):
|
||||
# HID++ 2.0 feature notifications have the SoftwareID 0
|
||||
(address & 0x0F == 0x00)
|
||||
): # noqa: E129
|
||||
return _HIDPP_Notification(report_id, devnumber, sub_id, address, data[2:])
|
||||
|
||||
|
||||
_HIDPP_Notification = namedtuple("_HIDPP_Notification", ("report_id", "devnumber", "sub_id", "address", "data"))
|
||||
_HIDPP_Notification.__str__ = lambda self: "Notification(%02x,%d,%02X,%02X,%s)" % (
|
||||
self.report_id,
|
||||
self.devnumber,
|
||||
self.sub_id,
|
||||
self.address,
|
||||
_strhex(self.data),
|
||||
)
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
request_lock = _threading.Lock() # serialize all requests
|
||||
handles_lock = {}
|
||||
return HIDPPNotification(report_id, devnumber, sub_id, address, data[2:])
|
||||
return None
|
||||
|
||||
|
||||
def handle_lock(handle):
|
||||
@@ -396,7 +455,7 @@ def handle_lock(handle):
|
||||
if handles_lock.get(handle) is None:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("New lock %s", repr(handle))
|
||||
handles_lock[handle] = _threading.Lock() # Serialize requests on the handle
|
||||
handles_lock[handle] = threading.Lock() # Serialize requests on the handle
|
||||
return handles_lock[handle]
|
||||
|
||||
|
||||
@@ -413,8 +472,30 @@ def acquire_timeout(lock, handle, timeout):
|
||||
lock.release()
|
||||
|
||||
|
||||
def find_paired_node(receiver_path: str, index: int, timeout: int):
|
||||
"""Find the node of a device paired with a receiver."""
|
||||
return hidapi.find_paired_node(receiver_path, index, timeout)
|
||||
|
||||
|
||||
def find_paired_node_wpid(receiver_path: str, index: int):
|
||||
"""Find the node of a device paired with a receiver.
|
||||
|
||||
Get wpid from udev.
|
||||
"""
|
||||
return hidapi.find_paired_node_wpid(receiver_path, index)
|
||||
|
||||
|
||||
# a very few requests (e.g., host switching) do not expect a reply, but use no_reply=True with extreme caution
|
||||
def request(handle, devnumber, request_id, *params, no_reply=False, return_error=False, long_message=False, protocol=1.0):
|
||||
def request(
|
||||
handle,
|
||||
devnumber,
|
||||
request_id: int,
|
||||
*params,
|
||||
no_reply: bool = False,
|
||||
return_error: bool = False,
|
||||
long_message: bool = False,
|
||||
protocol: float = 1.0,
|
||||
):
|
||||
"""Makes a feature call to a device and waits for a matching reply.
|
||||
:param handle: an open UR handle.
|
||||
:param devnumber: attached device number.
|
||||
@@ -422,19 +503,14 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
|
||||
:param params: parameters for the feature call, 3 to 16 bytes.
|
||||
:returns: the reply data, or ``None`` if some error occurred. or no reply expected
|
||||
"""
|
||||
|
||||
# import inspect as _inspect
|
||||
# print ('\n '.join(str(s) for s in _inspect.stack()))
|
||||
|
||||
with acquire_timeout(handle_lock(handle), handle, 10.0):
|
||||
assert isinstance(request_id, int)
|
||||
if (devnumber != 0xFF or protocol >= 2.0) and request_id < 0x8000:
|
||||
# For HID++ 2.0 feature requests, randomize the SoftwareId to make it
|
||||
# easier to recognize the reply for this request. also, always set the
|
||||
# most significant bit (8) in SoftwareId, to make notifications easier
|
||||
# to distinguish from request replies.
|
||||
# Always set the most significant bit (8) in SoftwareId,
|
||||
# to make notifications easier to distinguish from request replies.
|
||||
# This only applies to peripheral requests, ofc.
|
||||
request_id = (request_id & 0xFFF0) | 0x08 | _random_bits(3)
|
||||
sw_id = _get_next_sw_id()
|
||||
request_id = (request_id & 0xFFF0) | sw_id # was 0x08 | getrandbits(3)
|
||||
|
||||
timeout = _RECEIVER_REQUEST_TIMEOUT if devnumber == 0xFF else _DEVICE_REQUEST_TIMEOUT
|
||||
# be extra patient on long register read
|
||||
@@ -442,17 +518,15 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
|
||||
timeout *= 2
|
||||
|
||||
if params:
|
||||
params = b"".join(_pack("B", p) if isinstance(p, int) else p for p in params)
|
||||
params = b"".join(struct.pack("B", p) if isinstance(p, int) else p for p in params)
|
||||
else:
|
||||
params = b""
|
||||
# if logger.isEnabledFor(logging.DEBUG):
|
||||
# logger.debug("(%s) device %d request_id {%04X} params [%s]", handle, devnumber, request_id, _strhex(params))
|
||||
request_data = _pack("!H", request_id) + params
|
||||
request_data = struct.pack("!H", request_id) + params
|
||||
|
||||
ihandle = int(handle)
|
||||
notifications_hook = getattr(handle, "notifications_hook", None)
|
||||
try:
|
||||
_skip_incoming(handle, ihandle, notifications_hook)
|
||||
_read_input_buffer(handle, ihandle, notifications_hook)
|
||||
except exceptions.NoReceiver:
|
||||
logger.warning("device or receiver disconnected")
|
||||
return None
|
||||
@@ -462,18 +536,17 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
|
||||
return None
|
||||
|
||||
# we consider timeout from this point
|
||||
request_started = _timestamp()
|
||||
request_started = time()
|
||||
delta = 0
|
||||
|
||||
while delta < timeout:
|
||||
reply = _read(handle, timeout)
|
||||
|
||||
if reply:
|
||||
report_id, reply_devnumber, reply_data = reply
|
||||
if reply_devnumber == devnumber or reply_devnumber == devnumber ^ 0xFF: # BT device returning 0x00
|
||||
if (
|
||||
report_id == HIDPP_SHORT_MESSAGE_ID
|
||||
and reply_data[:1] == b"\x8F"
|
||||
and reply_data[:1] == b"\x8f"
|
||||
and reply_data[1:3] == request_data[:2]
|
||||
):
|
||||
error = ord(reply_data[3:4])
|
||||
@@ -485,10 +558,10 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
|
||||
devnumber,
|
||||
request_id,
|
||||
error,
|
||||
_hidpp10_constants.ERROR[error],
|
||||
Hidpp10ErrorCode(error),
|
||||
)
|
||||
return _hidpp10_constants.ERROR[error] if return_error else None
|
||||
if reply_data[:1] == b"\xFF" and reply_data[1:3] == request_data[:2]:
|
||||
return Hidpp10ErrorCode(error) if return_error else None
|
||||
if reply_data[:1] == b"\xff" and reply_data[1:3] == request_data[:2]:
|
||||
# a HID++ 2.0 feature call returned with an error
|
||||
error = ord(reply_data[3:4])
|
||||
logger.error(
|
||||
@@ -497,9 +570,14 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
|
||||
devnumber,
|
||||
request_id,
|
||||
error,
|
||||
_hidpp20_constants.ERROR[error],
|
||||
Hidpp20ErrorCode(error),
|
||||
)
|
||||
raise exceptions.FeatureCallError(
|
||||
number=devnumber,
|
||||
request=request_id,
|
||||
error=error,
|
||||
params=params,
|
||||
)
|
||||
raise exceptions.FeatureCallError(number=devnumber, request=request_id, error=error, params=params)
|
||||
|
||||
if reply_data[:2] == request_data[:2]:
|
||||
if devnumber == 0xFF:
|
||||
@@ -517,20 +595,13 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
|
||||
else:
|
||||
# a reply was received, but did not match our request in any way
|
||||
# reset the timeout starting point
|
||||
request_started = _timestamp()
|
||||
request_started = time()
|
||||
|
||||
if notifications_hook:
|
||||
n = make_notification(report_id, reply_devnumber, reply_data)
|
||||
if n:
|
||||
notifications_hook(n)
|
||||
# elif logger.isEnabledFor(logging.DEBUG):
|
||||
# logger.debug("(%s) ignoring reply %02X [%s]", handle, reply_devnumber, _strhex(reply_data))
|
||||
# elif logger.isEnabledFor(logging.DEBUG):
|
||||
# logger.debug("(%s) ignoring reply %02X [%s]", handle, reply_devnumber, _strhex(reply_data))
|
||||
|
||||
delta = _timestamp() - request_started
|
||||
# if logger.isEnabledFor(logging.DEBUG):
|
||||
# logger.debug("(%s) still waiting for reply, delta %f", handle, delta)
|
||||
delta = time() - request_started
|
||||
|
||||
logger.warning(
|
||||
"timeout (%0.2f/%0.2f) on device %d request {%04X} params [%s]",
|
||||
@@ -538,12 +609,12 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
|
||||
timeout,
|
||||
devnumber,
|
||||
request_id,
|
||||
_strhex(params),
|
||||
common.strhex(params),
|
||||
)
|
||||
# raise DeviceUnreachable(number=devnumber, request=request_id)
|
||||
|
||||
|
||||
def ping(handle, devnumber, long_message=False):
|
||||
def ping(handle, devnumber, long_message: bool = False):
|
||||
"""Check if a device is connected to the receiver.
|
||||
:returns: The HID protocol supported by the device, as a floating point number, if the device is active.
|
||||
"""
|
||||
@@ -552,19 +623,18 @@ def ping(handle, devnumber, long_message=False):
|
||||
with acquire_timeout(handle_lock(handle), handle, 10.0):
|
||||
notifications_hook = getattr(handle, "notifications_hook", None)
|
||||
try:
|
||||
_skip_incoming(handle, int(handle), notifications_hook)
|
||||
_read_input_buffer(handle, int(handle), notifications_hook)
|
||||
except exceptions.NoReceiver:
|
||||
logger.warning("device or receiver disconnected")
|
||||
return
|
||||
|
||||
# randomize the SoftwareId and mark byte to be able to identify the ping
|
||||
# reply, and set most significant (0x8) bit in SoftwareId so that the reply
|
||||
# is always distinguishable from notifications
|
||||
request_id = 0x0018 | _random_bits(3)
|
||||
request_data = _pack("!HBBB", request_id, 0, 0, _random_bits(8))
|
||||
# randomize the mark byte to be able to identify the ping reply
|
||||
sw_id = _get_next_sw_id()
|
||||
request_id = 0x0010 | sw_id # was 0x0018 | getrandbits(3)
|
||||
request_data = struct.pack("!HBBB", request_id, 0, 0, getrandbits(8))
|
||||
write(int(handle), devnumber, request_data, long_message)
|
||||
|
||||
request_started = _timestamp() # we consider timeout from this point
|
||||
request_started = time() # we consider timeout from this point
|
||||
delta = 0
|
||||
while delta < _PING_TIMEOUT:
|
||||
reply = _read(handle, _PING_TIMEOUT)
|
||||
@@ -577,18 +647,16 @@ def ping(handle, devnumber, long_message=False):
|
||||
|
||||
if (
|
||||
report_id == HIDPP_SHORT_MESSAGE_ID
|
||||
and reply_data[:1] == b"\x8F"
|
||||
and reply_data[:1] == b"\x8f"
|
||||
and reply_data[1:3] == request_data[:2]
|
||||
): # error response
|
||||
error = ord(reply_data[3:4])
|
||||
if error == _hidpp10_constants.ERROR.invalid_SubID__command: # a valid reply from a HID++ 1.0 device
|
||||
if error == Hidpp10ErrorCode.INVALID_SUB_ID_COMMAND:
|
||||
# a valid reply from a HID++ 1.0 device
|
||||
return 1.0
|
||||
if (
|
||||
error == _hidpp10_constants.ERROR.resource_error
|
||||
or error == _hidpp10_constants.ERROR.connection_request_failed
|
||||
):
|
||||
if error in [Hidpp10ErrorCode.RESOURCE_ERROR, Hidpp10ErrorCode.CONNECTION_REQUEST_FAILED]:
|
||||
return # device unreachable
|
||||
if error == _hidpp10_constants.ERROR.unknown_device: # no paired device with that number
|
||||
if error == Hidpp10ErrorCode.UNKNOWN_DEVICE: # no paired device with that number
|
||||
logger.error("(%s) device %d error on ping request: unknown device", handle, devnumber)
|
||||
raise exceptions.NoSuchDevice(number=devnumber, request=request_id)
|
||||
|
||||
@@ -596,9 +664,50 @@ def ping(handle, devnumber, long_message=False):
|
||||
n = make_notification(report_id, reply_devnumber, reply_data)
|
||||
if n:
|
||||
notifications_hook(n)
|
||||
# elif logger.isEnabledFor(logging.DEBUG):
|
||||
# logger.debug("(%s) ignoring reply %02X [%s]", handle, reply_devnumber, _strhex(reply_data))
|
||||
|
||||
delta = _timestamp() - request_started
|
||||
delta = time() - request_started
|
||||
|
||||
logger.warning("(%s) timeout (%0.2f/%0.2f) on device %d ping", handle, delta, _PING_TIMEOUT, devnumber)
|
||||
|
||||
|
||||
def _read_input_buffer(handle, ihandle, notifications_hook):
|
||||
"""Consume anything already in the input buffer.
|
||||
|
||||
Used by request() and ping() before their write.
|
||||
"""
|
||||
|
||||
while True:
|
||||
try:
|
||||
# read whatever is already in the buffer, if any
|
||||
data = hidapi.read(ihandle, _MAX_READ_SIZE, 0)
|
||||
except Exception as reason:
|
||||
logger.error("read failed, assuming receiver %s no longer available", handle)
|
||||
close(handle)
|
||||
raise exceptions.NoReceiver(reason=reason) from reason
|
||||
|
||||
if data:
|
||||
if _is_relevant_message(data): # only process messages that pass check
|
||||
# report_id = ord(data[:1])
|
||||
if notifications_hook:
|
||||
n = make_notification(ord(data[:1]), ord(data[1:2]), data[2:])
|
||||
if n:
|
||||
notifications_hook(n)
|
||||
else:
|
||||
# nothing in the input buffer, we're done
|
||||
return
|
||||
|
||||
|
||||
def _get_next_sw_id() -> int:
|
||||
"""Returns 'random' software ID to separate replies from different devices.
|
||||
|
||||
Cycle the HID++ 2.0 software ID from 0x2 to 0xF to separate
|
||||
results and notifications.
|
||||
"""
|
||||
if not hasattr(_get_next_sw_id, "software_id"):
|
||||
_get_next_sw_id.software_id = 0xF
|
||||
|
||||
if _get_next_sw_id.software_id < 0xF:
|
||||
_get_next_sw_id.software_id += 1
|
||||
else:
|
||||
_get_next_sw_id.software_id = 2
|
||||
return _get_next_sw_id.software_id
|
||||
|
||||
@@ -14,34 +14,41 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
## According to Logitech, they use the following product IDs (as of September 2020)
|
||||
## USB product IDs for receivers: 0xC526 - 0xC5xx
|
||||
## Wireless PIDs for hidpp10 devices: 0x2006 - 0x2019
|
||||
## Wireless PIDs for hidpp20 devices: 0x4002 - 0x4097, 0x4101 - 0x4102
|
||||
## USB product IDs for hidpp20 devices: 0xC07D - 0xC094, 0xC32B - 0xC344
|
||||
## Bluetooth product IDs (for hidpp20 devices): 0xB012 - 0xB0xx, 0xB32A - 0xB3xx
|
||||
"""Collection of known Logitech product IDs.
|
||||
|
||||
# USB ids of Logitech wireless receivers.
|
||||
# Only receivers supporting the HID++ protocol can go in here.
|
||||
According to Logitech, they use the following product IDs (as of September 2020)
|
||||
USB product IDs for receivers: 0xC526 - 0xC5xx
|
||||
Wireless PIDs for hidpp10 devices: 0x2006 - 0x2019
|
||||
Wireless PIDs for hidpp20 devices: 0x4002 - 0x4097, 0x4101 - 0x4102
|
||||
USB product IDs for hidpp20 devices: 0xC07D - 0xC094, 0xC32B - 0xC344
|
||||
Bluetooth product IDs (for hidpp20 devices): 0xB012 - 0xB0xx, 0xB32A - 0xB3xx
|
||||
|
||||
USB ids of Logitech wireless receivers.
|
||||
Only receivers supporting the HID++ protocol can go in here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from solaar.i18n import _
|
||||
|
||||
# max_devices is only used for receivers that do not support reading from _R.receiver_info offset 0x03, default to 1
|
||||
# may_unpair is only used for receivers that do not support reading from _R.receiver_info offset 0x03, default to False
|
||||
# unpair is for receivers that do support reading from _R.receiver_info offset 0x03, no default
|
||||
## should this last be changed so that may_unpair is used for all receivers? writing to _R.receiver_pairing doesn't seem right
|
||||
# re_pairs determines whether a receiver pairs by replacing existing pairings, default to False
|
||||
## currently only one receiver is so marked - should there be more?
|
||||
# max_devices is only used for receivers that do not support reading from Registers.RECEIVER_INFO offset 0x03, default
|
||||
# to 1.
|
||||
# may_unpair is only used for receivers that do not support reading from Registers.RECEIVER_INFO offset 0x03,
|
||||
# default to False.
|
||||
# unpair is for receivers that do support reading from Registers.RECEIVER_INFO offset 0x03, no default.
|
||||
## should this last be changed so that may_unpair is used for all receivers? writing to Registers.RECEIVER_PAIRING
|
||||
## doesn't seem right
|
||||
|
||||
DRIVER = ("hid-generic", "generic-usb", "logitech-djreceiver")
|
||||
LOGITECH_VENDOR_ID = 0x046D
|
||||
|
||||
|
||||
def _bolt_receiver(product_id):
|
||||
def _bolt_receiver(product_id: int) -> dict:
|
||||
return {
|
||||
"vendor_id": 1133,
|
||||
"vendor_id": LOGITECH_VENDOR_ID,
|
||||
"product_id": product_id,
|
||||
"usb_interface": 2,
|
||||
"hid_driver": DRIVER,
|
||||
"name": _("Bolt Receiver"),
|
||||
"receiver_kind": "bolt",
|
||||
"max_devices": 6,
|
||||
@@ -49,24 +56,22 @@ def _bolt_receiver(product_id):
|
||||
}
|
||||
|
||||
|
||||
def _unifying_receiver(product_id):
|
||||
def _unifying_receiver(product_id: int) -> dict:
|
||||
return {
|
||||
"vendor_id": 1133,
|
||||
"vendor_id": LOGITECH_VENDOR_ID,
|
||||
"product_id": product_id,
|
||||
"usb_interface": 2,
|
||||
"hid_driver": DRIVER,
|
||||
"name": _("Unifying Receiver"),
|
||||
"receiver_kind": "unifying",
|
||||
"may_unpair": True,
|
||||
}
|
||||
|
||||
|
||||
def _nano_receiver(product_id):
|
||||
def _nano_receiver(product_id: int) -> dict:
|
||||
return {
|
||||
"vendor_id": 1133,
|
||||
"vendor_id": LOGITECH_VENDOR_ID,
|
||||
"product_id": product_id,
|
||||
"usb_interface": 1,
|
||||
"hid_driver": DRIVER,
|
||||
"name": _("Nano Receiver"),
|
||||
"receiver_kind": "nano",
|
||||
"may_unpair": False,
|
||||
@@ -74,12 +79,11 @@ def _nano_receiver(product_id):
|
||||
}
|
||||
|
||||
|
||||
def _nano_receiver_no_unpair(product_id):
|
||||
def _nano_receiver_no_unpair(product_id: int) -> dict:
|
||||
return {
|
||||
"vendor_id": 1133,
|
||||
"vendor_id": LOGITECH_VENDOR_ID,
|
||||
"product_id": product_id,
|
||||
"usb_interface": 1,
|
||||
"hid_driver": DRIVER,
|
||||
"name": _("Nano Receiver"),
|
||||
"receiver_kind": "nano",
|
||||
"may_unpair": False,
|
||||
@@ -88,12 +92,11 @@ def _nano_receiver_no_unpair(product_id):
|
||||
}
|
||||
|
||||
|
||||
def _nano_receiver_max2(product_id):
|
||||
def _nano_receiver_max2(product_id: int) -> dict:
|
||||
return {
|
||||
"vendor_id": 1133,
|
||||
"vendor_id": LOGITECH_VENDOR_ID,
|
||||
"product_id": product_id,
|
||||
"usb_interface": 1,
|
||||
"hid_driver": DRIVER,
|
||||
"name": _("Nano Receiver"),
|
||||
"receiver_kind": "nano",
|
||||
"max_devices": 2,
|
||||
@@ -102,50 +105,34 @@ def _nano_receiver_max2(product_id):
|
||||
}
|
||||
|
||||
|
||||
def _nano_receiver_maxn(product_id, max):
|
||||
def _lenovo_receiver(product_id: int) -> dict:
|
||||
return {
|
||||
"vendor_id": 1133,
|
||||
"vendor_id": 6127,
|
||||
"product_id": product_id,
|
||||
"usb_interface": 1,
|
||||
"hid_driver": DRIVER,
|
||||
"name": _("Nano Receiver"),
|
||||
"receiver_kind": "nano",
|
||||
"max_devices": max,
|
||||
"may_unpair": False,
|
||||
}
|
||||
|
||||
|
||||
def _lightspeed_receiver(product_id: int) -> dict:
|
||||
return {
|
||||
"vendor_id": LOGITECH_VENDOR_ID,
|
||||
"product_id": product_id,
|
||||
"usb_interface": 2,
|
||||
"receiver_kind": "lightspeed",
|
||||
"name": _("Lightspeed Receiver"),
|
||||
"may_unpair": False,
|
||||
"re_pairs": True,
|
||||
}
|
||||
|
||||
|
||||
def _lenovo_receiver(product_id):
|
||||
def _ex100_receiver(product_id: int) -> dict:
|
||||
return {
|
||||
"vendor_id": 6127,
|
||||
"vendor_id": LOGITECH_VENDOR_ID,
|
||||
"product_id": product_id,
|
||||
"usb_interface": 1,
|
||||
"hid_driver": DRIVER,
|
||||
"name": _("Nano Receiver"),
|
||||
"receiver_kind": "nano",
|
||||
"may_unpair": False,
|
||||
}
|
||||
|
||||
|
||||
def _lightspeed_receiver(product_id):
|
||||
return {
|
||||
"vendor_id": 1133,
|
||||
"product_id": product_id,
|
||||
"usb_interface": 2,
|
||||
"hid_driver": DRIVER,
|
||||
"receiver_kind": "lightspeed",
|
||||
"name": _("Lightspeed Receiver"),
|
||||
"may_unpair": False,
|
||||
}
|
||||
|
||||
|
||||
def _ex100_receiver(product_id):
|
||||
return {
|
||||
"vendor_id": 1133,
|
||||
"product_id": product_id,
|
||||
"usb_interface": 1,
|
||||
"hid_driver": DRIVER,
|
||||
"name": _("EX100 Receiver 27 Mhz"),
|
||||
"receiver_kind": "27Mhz",
|
||||
"max_devices": 4,
|
||||
@@ -155,7 +142,7 @@ def _ex100_receiver(product_id):
|
||||
|
||||
|
||||
# Receivers added here should also be listed in
|
||||
# share/solaar/io.github.pwr_solaar.solaar.metainfo.xml
|
||||
# share/solaar/io.github.pwr_solaar.solaar.meta-info.xml
|
||||
# Look in https://github.com/torvalds/linux/blob/master/drivers/hid/hid-ids.h
|
||||
|
||||
# Bolt receivers (marked with the yellow lightning bolt logo)
|
||||
@@ -178,7 +165,6 @@ NANO_RECEIVER_C531 = _nano_receiver(0xC531)
|
||||
NANO_RECEIVER_C534 = _nano_receiver_max2(0xC534)
|
||||
NANO_RECEIVER_C535 = _nano_receiver(0xC535) # branded as Dell
|
||||
NANO_RECEIVER_C537 = _nano_receiver(0xC537)
|
||||
# NANO_RECEIVER_C542 = _nano_receiver(0xc542) # does not use HID++
|
||||
NANO_RECEIVER_6042 = _lenovo_receiver(0x6042)
|
||||
|
||||
# Lightspeed receivers (usually sold with gaming devices)
|
||||
@@ -189,36 +175,64 @@ LIGHTSPEED_RECEIVER_C53F = _lightspeed_receiver(0xC53F)
|
||||
LIGHTSPEED_RECEIVER_C541 = _lightspeed_receiver(0xC541)
|
||||
LIGHTSPEED_RECEIVER_C545 = _lightspeed_receiver(0xC545)
|
||||
LIGHTSPEED_RECEIVER_C547 = _lightspeed_receiver(0xC547)
|
||||
LIGHTSPEED_RECEIVER_C54D = _lightspeed_receiver(0xC54D)
|
||||
|
||||
# EX100 old style receiver pre-unifying protocol
|
||||
# EX100_27MHZ_RECEIVER_C50C = _ex100_receiver(0xc50C) # in hid/hid-ids.h
|
||||
EX100_27MHZ_RECEIVER_C517 = _ex100_receiver(0xC517)
|
||||
# EX100_27MHZ_RECEIVER_C51B = _ex100_receiver(0xc51B) # in hid/hid-ids.h
|
||||
|
||||
ALL = (
|
||||
BOLT_RECEIVER_C548,
|
||||
UNIFYING_RECEIVER_C52B,
|
||||
UNIFYING_RECEIVER_C532,
|
||||
NANO_RECEIVER_ADVANCED,
|
||||
NANO_RECEIVER_C518,
|
||||
NANO_RECEIVER_C51A,
|
||||
NANO_RECEIVER_C51B,
|
||||
NANO_RECEIVER_C521,
|
||||
NANO_RECEIVER_C525,
|
||||
NANO_RECEIVER_C526,
|
||||
NANO_RECEIVER_C52E,
|
||||
NANO_RECEIVER_C531,
|
||||
NANO_RECEIVER_C534,
|
||||
NANO_RECEIVER_C535,
|
||||
NANO_RECEIVER_C537,
|
||||
# NANO_RECEIVER_C542, # does not use HID++
|
||||
NANO_RECEIVER_6042,
|
||||
LIGHTSPEED_RECEIVER_C539,
|
||||
LIGHTSPEED_RECEIVER_C53A,
|
||||
LIGHTSPEED_RECEIVER_C53D,
|
||||
LIGHTSPEED_RECEIVER_C53F,
|
||||
LIGHTSPEED_RECEIVER_C541,
|
||||
LIGHTSPEED_RECEIVER_C545,
|
||||
LIGHTSPEED_RECEIVER_C547,
|
||||
EX100_27MHZ_RECEIVER_C517,
|
||||
)
|
||||
KNOWN_RECEIVERS = {
|
||||
0xC548: BOLT_RECEIVER_C548,
|
||||
0xC52B: UNIFYING_RECEIVER_C52B,
|
||||
0xC532: UNIFYING_RECEIVER_C532,
|
||||
0xC52F: NANO_RECEIVER_ADVANCED,
|
||||
0xC518: NANO_RECEIVER_C518,
|
||||
0xC51A: NANO_RECEIVER_C51A,
|
||||
0xC51B: NANO_RECEIVER_C51B,
|
||||
0xC521: NANO_RECEIVER_C521,
|
||||
0xC525: NANO_RECEIVER_C525,
|
||||
0xC526: NANO_RECEIVER_C526,
|
||||
0xC52E: NANO_RECEIVER_C52E,
|
||||
0xC531: NANO_RECEIVER_C531,
|
||||
0xC534: NANO_RECEIVER_C534,
|
||||
0xC535: NANO_RECEIVER_C535,
|
||||
0xC537: NANO_RECEIVER_C537,
|
||||
0x6042: NANO_RECEIVER_6042,
|
||||
0xC539: LIGHTSPEED_RECEIVER_C539,
|
||||
0xC53A: LIGHTSPEED_RECEIVER_C53A,
|
||||
0xC53D: LIGHTSPEED_RECEIVER_C53D,
|
||||
0xC53F: LIGHTSPEED_RECEIVER_C53F,
|
||||
0xC541: LIGHTSPEED_RECEIVER_C541,
|
||||
0xC545: LIGHTSPEED_RECEIVER_C545,
|
||||
0xC547: LIGHTSPEED_RECEIVER_C547,
|
||||
0xC54D: LIGHTSPEED_RECEIVER_C54D,
|
||||
0xC517: EX100_27MHZ_RECEIVER_C517,
|
||||
}
|
||||
|
||||
|
||||
def get_receiver_info(product_id: int) -> dict[str, Any]:
|
||||
"""Returns hardcoded information about a Logitech receiver.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
product_id
|
||||
Product ID (pid) of the receiver, e.g. 0xC548 for a Logitech
|
||||
Bolt receiver.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[str, Any]
|
||||
Receiver info with mandatory fields:
|
||||
- vendor_id
|
||||
- product_id
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the product ID is unknown.
|
||||
"""
|
||||
try:
|
||||
return KNOWN_RECEIVERS[product_id]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
raise ValueError(f"Unknown product ID '0x{product_id:02X}'")
|
||||
|
||||
@@ -14,27 +14,27 @@
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
from __future__ import annotations
|
||||
|
||||
# Some common functions and types.
|
||||
import binascii
|
||||
import dataclasses
|
||||
import typing
|
||||
|
||||
from binascii import hexlify as _hexlify
|
||||
from collections import namedtuple
|
||||
from dataclasses import dataclass
|
||||
from enum import Flag
|
||||
from enum import IntEnum
|
||||
from typing import Generator
|
||||
from typing import Iterable
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
import yaml as _yaml
|
||||
import yaml
|
||||
|
||||
from solaar.i18n import _
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from logitech_receiver.hidpp20_constants import FirmwareKind
|
||||
|
||||
def is_string(d):
|
||||
return isinstance(d, str)
|
||||
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
LOGITECH_VENDOR_ID = 0x046D
|
||||
|
||||
|
||||
def crc16(data: bytes):
|
||||
@@ -314,7 +314,7 @@ class NamedInt(int):
|
||||
(case-insensitive)."""
|
||||
|
||||
def __new__(cls, value, name):
|
||||
assert is_string(name)
|
||||
assert isinstance(name, str)
|
||||
obj = int.__new__(cls, value)
|
||||
obj.name = str(name)
|
||||
return obj
|
||||
@@ -329,11 +329,11 @@ class NamedInt(int):
|
||||
return int(self) == int(other) and self.name == other.name
|
||||
if isinstance(other, int):
|
||||
return int(self) == int(other)
|
||||
if is_string(other):
|
||||
if isinstance(other, str):
|
||||
return self.name.lower() == other.lower()
|
||||
# this should catch comparisons with bytes in Py3
|
||||
if other is not None:
|
||||
raise TypeError("Unsupported type " + str(type(other)))
|
||||
raise TypeError(f"Unsupported type {str(type(other))}")
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
@@ -357,8 +357,8 @@ class NamedInt(int):
|
||||
return dumper.represent_mapping("!NamedInt", {"value": int(data), "name": data.name}, flow_style=True)
|
||||
|
||||
|
||||
_yaml.SafeLoader.add_constructor("!NamedInt", NamedInt.from_yaml)
|
||||
_yaml.add_representer(NamedInt, NamedInt.to_yaml)
|
||||
yaml.SafeLoader.add_constructor("!NamedInt", NamedInt.from_yaml)
|
||||
yaml.add_representer(NamedInt, NamedInt.to_yaml)
|
||||
|
||||
|
||||
class NamedInts:
|
||||
@@ -377,12 +377,12 @@ class NamedInts:
|
||||
|
||||
__slots__ = ("__dict__", "_values", "_indexed", "_fallback", "_is_sorted")
|
||||
|
||||
def __init__(self, dict=None, **kwargs):
|
||||
def __init__(self, dict_=None, **kwargs):
|
||||
def _readable_name(n):
|
||||
return n.replace("__", "/").replace("_", " ")
|
||||
|
||||
# print (repr(kwargs))
|
||||
elements = dict if dict else kwargs
|
||||
elements = dict_ if dict_ else kwargs
|
||||
values = {k: NamedInt(v, _readable_name(k)) for (k, v) in elements.items()}
|
||||
self.__dict__ = values
|
||||
self._is_sorted = False
|
||||
@@ -430,7 +430,7 @@ class NamedInts:
|
||||
self._sort_values()
|
||||
return value
|
||||
|
||||
elif is_string(index):
|
||||
elif isinstance(index, str):
|
||||
if index in self.__dict__:
|
||||
return self.__dict__[index]
|
||||
return next((x for x in self._values if str(x) == index), None)
|
||||
@@ -467,9 +467,9 @@ class NamedInts:
|
||||
def __setitem__(self, index, name):
|
||||
assert isinstance(index, int), type(index)
|
||||
if isinstance(name, NamedInt):
|
||||
assert int(index) == int(name), repr(index) + " " + repr(name)
|
||||
assert int(index) == int(name), f"{repr(index)} {repr(name)}"
|
||||
value = name
|
||||
elif is_string(name):
|
||||
elif isinstance(name, str):
|
||||
value = NamedInt(index, name)
|
||||
else:
|
||||
raise TypeError("name must be a string")
|
||||
@@ -490,7 +490,7 @@ class NamedInts:
|
||||
return self[value] == value
|
||||
elif isinstance(value, int):
|
||||
return value in self._indexed
|
||||
elif is_string(value):
|
||||
elif isinstance(value, str):
|
||||
return value in self.__dict__ or value in self._values
|
||||
|
||||
def __iter__(self):
|
||||
@@ -506,7 +506,32 @@ class NamedInts:
|
||||
return NamedInts(**self.__dict__, **other.__dict__)
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(self) == type(other) and self._values == other._values
|
||||
return isinstance(other, self.__class__) and self._values == other._values
|
||||
|
||||
|
||||
def flag_names(enum_class: Iterable, value: int) -> Generator[str]:
|
||||
"""Extracts single bit flags from a (binary) number.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
enum_class
|
||||
Enum class to extract flags from.
|
||||
value
|
||||
Number to extract binary flags from.
|
||||
"""
|
||||
indexed = {item.value: item.name for item in enum_class}
|
||||
|
||||
unknown_bits = value
|
||||
for k in indexed:
|
||||
# Ensure that the key (flag value) is a power of 2 (a single bit flag)
|
||||
assert bin(k).count("1") == 1
|
||||
if k & value == k:
|
||||
unknown_bits &= ~k
|
||||
yield indexed[k].lower()
|
||||
|
||||
# Yield any remaining unknown bits
|
||||
if unknown_bits != 0:
|
||||
yield f"unknown:{unknown_bits:06X}"
|
||||
|
||||
|
||||
class UnsortedNamedInts(NamedInts):
|
||||
@@ -521,7 +546,7 @@ class UnsortedNamedInts(NamedInts):
|
||||
def strhex(x):
|
||||
assert x is not None
|
||||
"""Produce a hex-string representation of a sequence of bytes."""
|
||||
return _hexlify(x).decode("ascii").upper()
|
||||
return binascii.hexlify(x).decode("ascii").upper()
|
||||
|
||||
|
||||
def bytes2int(x, signed=False):
|
||||
@@ -550,63 +575,102 @@ class KwException(Exception):
|
||||
return self.args[0].get(k) # was self.args[0][k]
|
||||
|
||||
|
||||
"""Firmware information."""
|
||||
FirmwareInfo = namedtuple("FirmwareInfo", ["kind", "name", "version", "extras"])
|
||||
class FirmwareKind(IntEnum):
|
||||
Firmware = 0x00
|
||||
Bootloader = 0x01
|
||||
Hardware = 0x02
|
||||
Other = 0x03
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclasses.dataclass
|
||||
class FirmwareInfo:
|
||||
kind: FirmwareKind
|
||||
name: str
|
||||
version: str
|
||||
extras: str | None
|
||||
|
||||
|
||||
class BatteryStatus(Flag):
|
||||
DISCHARGING = 0x00
|
||||
RECHARGING = 0x01
|
||||
ALMOST_FULL = 0x02
|
||||
FULL = 0x03
|
||||
SLOW_RECHARGE = 0x04
|
||||
INVALID_BATTERY = 0x05
|
||||
THERMAL_ERROR = 0x06
|
||||
|
||||
|
||||
class BatteryLevelApproximation(IntEnum):
|
||||
EMPTY = 0
|
||||
CRITICAL = 5
|
||||
LOW = 20
|
||||
GOOD = 50
|
||||
FULL = 90
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Battery:
|
||||
"""Information about the current state of a battery"""
|
||||
|
||||
level: Optional[Union[NamedInt, int]]
|
||||
ATTENTION_LEVEL = 5
|
||||
|
||||
level: Optional[Union[BatteryLevelApproximation, int]]
|
||||
next_level: Optional[Union[NamedInt, int]]
|
||||
status: Optional[NamedInt]
|
||||
status: Optional[BatteryStatus]
|
||||
voltage: Optional[int]
|
||||
light_level: Optional[int] = None # light level for devices with solaar recharging
|
||||
|
||||
def __post_init__(self):
|
||||
if self.level is None: # infer level from status if needed and possible
|
||||
if self.status == Battery.STATUS.full:
|
||||
self.level = Battery.APPROX.full
|
||||
elif self.status in (Battery.STATUS.almost_full, Battery.STATUS.recharging):
|
||||
self.level = Battery.APPROX.good
|
||||
elif self.status == Battery.STATUS.slow_recharge:
|
||||
self.level = Battery.APPROX.low
|
||||
if self.status == BatteryStatus.FULL:
|
||||
self.level = BatteryLevelApproximation.FULL
|
||||
elif self.status in (BatteryStatus.ALMOST_FULL, BatteryStatus.RECHARGING):
|
||||
self.level = BatteryLevelApproximation.GOOD
|
||||
elif self.status == BatteryStatus.SLOW_RECHARGE:
|
||||
self.level = BatteryLevelApproximation.LOW
|
||||
|
||||
STATUS = NamedInts(
|
||||
discharging=0x00,
|
||||
recharging=0x01,
|
||||
almost_full=0x02,
|
||||
full=0x03,
|
||||
slow_recharge=0x04,
|
||||
invalid_battery=0x05,
|
||||
thermal_error=0x06,
|
||||
)
|
||||
|
||||
APPROX = NamedInts(empty=0, critical=5, low=20, good=50, full=90)
|
||||
|
||||
ATTENTION_LEVEL = 5
|
||||
|
||||
def ok(self):
|
||||
return self.status not in (Battery.STATUS.invalid_battery, Battery.STATUS.thermal_error) and (
|
||||
def ok(self) -> bool:
|
||||
return self.status not in (BatteryStatus.INVALID_BATTERY, BatteryStatus.THERMAL_ERROR) and (
|
||||
self.level is None or self.level > Battery.ATTENTION_LEVEL
|
||||
)
|
||||
|
||||
def charging(self):
|
||||
def charging(self) -> bool:
|
||||
return self.status in (
|
||||
Battery.STATUS.recharging,
|
||||
Battery.STATUS.almost_full,
|
||||
Battery.STATUS.full,
|
||||
Battery.STATUS.slow_recharge,
|
||||
BatteryStatus.RECHARGING,
|
||||
BatteryStatus.ALMOST_FULL,
|
||||
BatteryStatus.FULL,
|
||||
BatteryStatus.SLOW_RECHARGE,
|
||||
)
|
||||
|
||||
def to_str(self):
|
||||
if isinstance(self.level, NamedInt):
|
||||
return _("Battery: %(level)s (%(status)s)") % {"level": _(self.level), "status": _(self.status)}
|
||||
def to_str(self) -> str:
|
||||
if isinstance(self.level, BatteryLevelApproximation):
|
||||
level = self.level.name.lower()
|
||||
status = self.status.name.lower().replace("_", " ") if self.status is not None else "Unknown"
|
||||
return _("Battery: %(level)s (%(status)s)") % {"level": _(level), "status": _(status)}
|
||||
elif isinstance(self.level, int):
|
||||
return _("Battery: %(percent)d%% (%(status)s)") % {"percent": self.level, "status": _(self.status)}
|
||||
else:
|
||||
return ""
|
||||
status = self.status.name.lower().replace("_", " ") if self.status is not None else "Unknown"
|
||||
return _("Battery: %(percent)d%% (%(status)s)") % {"percent": self.level, "status": _(status)}
|
||||
return ""
|
||||
|
||||
|
||||
ALERT = NamedInts(NONE=0x00, NOTIFICATION=0x01, SHOW_WINDOW=0x02, ATTENTION=0x04, ALL=0xFF)
|
||||
class Alert(IntEnum):
|
||||
NONE = 0x00
|
||||
NOTIFICATION = 0x01
|
||||
SHOW_WINDOW = 0x02
|
||||
ATTENTION = 0x04
|
||||
ALL = 0xFF
|
||||
|
||||
|
||||
class Notification(IntEnum):
|
||||
NO_OPERATION = 0x00
|
||||
CONNECT_DISCONNECT = 0x40
|
||||
DJ_PAIRING = 0x41
|
||||
CONNECTED = 0x42
|
||||
RAW_INPUT = 0x49
|
||||
PAIRING_LOCK = 0x4A
|
||||
POWER = 0x4B
|
||||
|
||||
|
||||
class BusID(IntEnum):
|
||||
USB = 0x03
|
||||
BLUETOOTH = 0x05
|
||||
|
||||
@@ -14,20 +14,18 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
#
|
||||
# Devices (not receivers) known to Solaar.
|
||||
# Solaar can handle many recent devices without having any entry here.
|
||||
# An entry should only be added to fix problems, such as
|
||||
# - the device's device ID or WPID falls outside the range that Solaar searches
|
||||
# - the device uses a USB interface other than 2
|
||||
# - the name or codename should be different from what the device reports
|
||||
|
||||
from .hidpp10_constants import DEVICE_KIND as _DK
|
||||
from .hidpp10_constants import REGISTERS as _R
|
||||
"""Devices (not receivers) known to Solaar.
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
Solaar can handle many recent devices without having any entry here.
|
||||
An entry should only be added to fix problems, such as
|
||||
- the device's device ID or WPID falls outside the range that Solaar searches
|
||||
- the device uses a USB interface other than 2
|
||||
- the name or codename should be different from what the device reports
|
||||
"""
|
||||
|
||||
from .hidpp10_constants import DEVICE_KIND
|
||||
from .hidpp10_constants import Registers as Reg
|
||||
|
||||
|
||||
class _DeviceDescriptor:
|
||||
@@ -73,15 +71,15 @@ def _D(
|
||||
):
|
||||
if kind is None:
|
||||
kind = (
|
||||
_DK.mouse
|
||||
DEVICE_KIND.mouse
|
||||
if "Mouse" in name
|
||||
else _DK.keyboard
|
||||
else DEVICE_KIND.keyboard
|
||||
if "Keyboard" in name
|
||||
else _DK.numpad
|
||||
else DEVICE_KIND.numpad
|
||||
if "Number Pad" in name
|
||||
else _DK.touchpad
|
||||
else DEVICE_KIND.touchpad
|
||||
if "Touchpad" in name
|
||||
else _DK.trackball
|
||||
else DEVICE_KIND.trackball
|
||||
if "Trackball" in name
|
||||
else None
|
||||
)
|
||||
@@ -94,9 +92,12 @@ def _D(
|
||||
assert w[0:1] == "4", f"{name} has protocol {protocol:0.1f}, wpid {w}"
|
||||
else:
|
||||
if w[0:1] == "1":
|
||||
assert kind == _DK.mouse, f"{name} has protocol {protocol:0.1f}, wpid {w}"
|
||||
assert kind == DEVICE_KIND.mouse, f"{name} has protocol {protocol:0.1f}, wpid {w}"
|
||||
elif w[0:1] == "2":
|
||||
assert kind in (_DK.keyboard, _DK.numpad), f"{name} has protocol {protocol:0.1f}, wpid {w}"
|
||||
assert kind in (
|
||||
DEVICE_KIND.keyboard,
|
||||
DEVICE_KIND.numpad,
|
||||
), f"{name} has protocol {protocol:0.1f}, wpid {w}"
|
||||
|
||||
device_descriptor = _DeviceDescriptor(
|
||||
name=name,
|
||||
@@ -192,24 +193,24 @@ def get_btid(btid):
|
||||
|
||||
# Keyboards
|
||||
|
||||
_D("Wireless Keyboard EX110", codename="EX110", protocol=1.0, wpid="0055", registers=(_R.battery_status,))
|
||||
_D("Wireless Keyboard S510", codename="S510", protocol=1.0, wpid="0056", registers=(_R.battery_status,))
|
||||
_D("Wireless Wave Keyboard K550", codename="K550", protocol=1.0, wpid="0060", registers=(_R.battery_status,))
|
||||
_D("Wireless Keyboard EX100", codename="EX100", protocol=1.0, wpid="0065", registers=(_R.battery_status,))
|
||||
_D("Wireless Keyboard MK300", codename="MK300", protocol=1.0, wpid="0068", registers=(_R.battery_status,))
|
||||
_D("Number Pad N545", codename="N545", protocol=1.0, wpid="2006", registers=(_R.battery_status,))
|
||||
_D("Wireless Compact Keyboard K340", codename="K340", protocol=1.0, wpid="2007", registers=(_R.battery_status,))
|
||||
_D("Wireless Keyboard MK700", codename="MK700", protocol=1.0, wpid="2008", registers=(_R.battery_status,))
|
||||
_D("Wireless Wave Keyboard K350", codename="K350", protocol=1.0, wpid="200A", registers=(_R.battery_status,))
|
||||
_D("Wireless Keyboard MK320", codename="MK320", protocol=1.0, wpid="200F", registers=(_R.battery_status,))
|
||||
_D("Wireless Keyboard EX110", codename="EX110", protocol=1.0, wpid="0055", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Keyboard S510", codename="S510", protocol=1.0, wpid="0056", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Wave Keyboard K550", codename="K550", protocol=1.0, wpid="0060", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Keyboard EX100", codename="EX100", protocol=1.0, wpid="0065", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Keyboard MK300", codename="MK300", protocol=1.0, wpid="0068", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Number Pad N545", codename="N545", protocol=1.0, wpid="2006", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Compact Keyboard K340", codename="K340", protocol=1.0, wpid="2007", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Keyboard MK700", codename="MK700", protocol=1.0, wpid="2008", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Wave Keyboard K350", codename="K350", protocol=1.0, wpid="200A", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Keyboard MK320", codename="MK320", protocol=1.0, wpid="200F", registers=(Reg.BATTERY_STATUS,))
|
||||
_D(
|
||||
"Wireless Illuminated Keyboard K800",
|
||||
codename="K800",
|
||||
protocol=1.0,
|
||||
wpid="2010",
|
||||
registers=(_R.battery_status, _R.three_leds),
|
||||
registers=(Reg.BATTERY_STATUS, Reg.THREE_LEDS),
|
||||
)
|
||||
_D("Wireless Keyboard K520", codename="K520", protocol=1.0, wpid="2011", registers=(_R.battery_status,))
|
||||
_D("Wireless Keyboard K520", codename="K520", protocol=1.0, wpid="2011", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Solar Keyboard K750", codename="K750", protocol=2.0, wpid="4002")
|
||||
_D("Wireless Keyboard K270 (unifying)", codename="K270", protocol=2.0, wpid="4003")
|
||||
_D("Wireless Keyboard K360", codename="K360", protocol=2.0, wpid="4004")
|
||||
@@ -224,7 +225,13 @@ _D("Craft Advanced Keyboard", codename="Craft", protocol=4.5, wpid="4066", btid=
|
||||
_D("Wireless Illuminated Keyboard K800 new", codename="K800 new", protocol=4.5, wpid="406E")
|
||||
_D("Wireless Keyboard K470", codename="K470", protocol=4.5, wpid="4075")
|
||||
_D("MX Keys Keyboard", codename="MX Keys", protocol=4.5, wpid="408A", btid=0xB35B)
|
||||
_D("G915 TKL LIGHTSPEED Wireless RGB Mechanical Gaming Keyboard", codename="G915 TKL", protocol=4.2, wpid="408E", usbid=0xC343)
|
||||
_D(
|
||||
"G915 TKL LIGHTSPEED Wireless RGB Mechanical Gaming Keyboard",
|
||||
codename="G915 TKL",
|
||||
protocol=4.2,
|
||||
wpid="408E",
|
||||
usbid=0xC343,
|
||||
)
|
||||
_D("Illuminated Keyboard", codename="Illuminated", protocol=1.0, usbid=0xC318, interface=1)
|
||||
_D("G213 Prodigy Gaming Keyboard", codename="G213", usbid=0xC336, interface=1)
|
||||
_D("G512 RGB Mechanical Gaming Keyboard", codename="G512", usbid=0xC33C, interface=1)
|
||||
@@ -234,52 +241,118 @@ _D("K845 Mechanical Keyboard", codename="K845", usbid=0xC341, interface=3)
|
||||
|
||||
# Mice
|
||||
|
||||
_D("LX5 Cordless Mouse", codename="LX5", protocol=1.0, wpid="0036", registers=(_R.battery_status,))
|
||||
_D("LX7 Cordless Laser Mouse", codename="LX7", protocol=1.0, wpid="0039", registers=(_R.battery_status,))
|
||||
_D("Wireless Wave Mouse M550", codename="M550", protocol=1.0, wpid="003C", registers=(_R.battery_status,))
|
||||
_D("Wireless Mouse EX100", codename="EX100m", protocol=1.0, wpid="003F", registers=(_R.battery_status,))
|
||||
_D("Wireless Mouse M30", codename="M30", protocol=1.0, wpid="0085", registers=(_R.battery_status,))
|
||||
_D("MX610 Laser Cordless Mouse", codename="MX610", protocol=1.0, wpid="1001", registers=(_R.battery_status,))
|
||||
_D("G7 Cordless Laser Mouse", codename="G7", protocol=1.0, wpid="1002", registers=(_R.battery_status,))
|
||||
_D("V400 Laser Cordless Mouse", codename="V400", protocol=1.0, wpid="1003", registers=(_R.battery_status,))
|
||||
_D("MX610 Left-Handled Mouse", codename="MX610L", protocol=1.0, wpid="1004", registers=(_R.battery_status,))
|
||||
_D("V450 Laser Cordless Mouse", codename="V450", protocol=1.0, wpid="1005", registers=(_R.battery_status,))
|
||||
_D("LX5 Cordless Mouse", codename="LX5", protocol=1.0, wpid="0036", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("LX7 Cordless Laser Mouse", codename="LX7", protocol=1.0, wpid="0039", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Wave Mouse M550", codename="M550", protocol=1.0, wpid="003C", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Mouse EX100", codename="EX100m", protocol=1.0, wpid="003F", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Mouse M30", codename="M30", protocol=1.0, wpid="0085", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("MX610 Laser Cordless Mouse", codename="MX610", protocol=1.0, wpid="1001", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("G7 Cordless Laser Mouse", codename="G7", protocol=1.0, wpid="1002", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("V400 Laser Cordless Mouse", codename="V400", protocol=1.0, wpid="1003", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("MX610 Left-Handled Mouse", codename="MX610L", protocol=1.0, wpid="1004", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("V450 Laser Cordless Mouse", codename="V450", protocol=1.0, wpid="1005", registers=(Reg.BATTERY_STATUS,))
|
||||
_D(
|
||||
"VX Revolution",
|
||||
codename="VX Revolution",
|
||||
kind=_DK.mouse,
|
||||
kind=DEVICE_KIND.mouse,
|
||||
protocol=1.0,
|
||||
wpid=("1006", "100D", "0612"),
|
||||
registers=(_R.battery_charge,),
|
||||
registers=(Reg.BATTERY_CHARGE,),
|
||||
)
|
||||
_D(
|
||||
"MX Air",
|
||||
codename="MX Air",
|
||||
protocol=1.0,
|
||||
kind=DEVICE_KIND.mouse,
|
||||
wpid=("1007", "100E"),
|
||||
registers=Reg.BATTERY_CHARGE,
|
||||
)
|
||||
_D("MX Air", codename="MX Air", protocol=1.0, kind=_DK.mouse, wpid=("1007", "100E"), registers=(_R.battery_charge,))
|
||||
_D(
|
||||
"MX Revolution",
|
||||
codename="MX Revolution",
|
||||
protocol=1.0,
|
||||
kind=_DK.mouse,
|
||||
kind=DEVICE_KIND.mouse,
|
||||
wpid=("1008", "100C"),
|
||||
registers=(_R.battery_charge,),
|
||||
registers=(Reg.BATTERY_CHARGE,),
|
||||
)
|
||||
_D(
|
||||
"MX620 Laser Cordless Mouse",
|
||||
codename="MX620",
|
||||
protocol=1.0,
|
||||
wpid=("100A", "1016"),
|
||||
registers=(Reg.BATTERY_CHARGE,),
|
||||
)
|
||||
_D(
|
||||
"VX Nano Cordless Laser Mouse",
|
||||
codename="VX Nano",
|
||||
protocol=1.0,
|
||||
wpid=("100B", "100F"),
|
||||
registers=(Reg.BATTERY_CHARGE,),
|
||||
)
|
||||
_D(
|
||||
"V450 Nano Cordless Laser Mouse",
|
||||
codename="V450 Nano",
|
||||
protocol=1.0,
|
||||
wpid="1011",
|
||||
registers=(Reg.BATTERY_CHARGE,),
|
||||
)
|
||||
_D(
|
||||
"V550 Nano Cordless Laser Mouse",
|
||||
codename="V550 Nano",
|
||||
protocol=1.0,
|
||||
wpid="1013",
|
||||
registers=(Reg.BATTERY_CHARGE,),
|
||||
)
|
||||
_D("MX620 Laser Cordless Mouse", codename="MX620", protocol=1.0, wpid=("100A", "1016"), registers=(_R.battery_charge,))
|
||||
_D("VX Nano Cordless Laser Mouse", codename="VX Nano", protocol=1.0, wpid=("100B", "100F"), registers=(_R.battery_charge,))
|
||||
_D("V450 Nano Cordless Laser Mouse", codename="V450 Nano", protocol=1.0, wpid="1011", registers=(_R.battery_charge,))
|
||||
_D("V550 Nano Cordless Laser Mouse", codename="V550 Nano", protocol=1.0, wpid="1013", registers=(_R.battery_charge,))
|
||||
_D(
|
||||
"MX 1100 Cordless Laser Mouse",
|
||||
codename="MX 1100",
|
||||
protocol=1.0,
|
||||
kind=_DK.mouse,
|
||||
kind=DEVICE_KIND.mouse,
|
||||
wpid="1014",
|
||||
registers=(_R.battery_charge,),
|
||||
registers=(Reg.BATTERY_CHARGE,),
|
||||
)
|
||||
_D("Anywhere Mouse MX", codename="Anywhere MX", protocol=1.0, wpid="1017", registers=(Reg.BATTERY_CHARGE,))
|
||||
_D(
|
||||
"Performance Mouse MX",
|
||||
codename="Performance MX",
|
||||
protocol=1.0,
|
||||
wpid="101A",
|
||||
registers=(Reg.BATTERY_STATUS, Reg.THREE_LEDS),
|
||||
)
|
||||
_D(
|
||||
"Marathon Mouse M705 (M-R0009)",
|
||||
codename="M705 (M-R0009)",
|
||||
protocol=1.0,
|
||||
wpid="101B",
|
||||
registers=(Reg.BATTERY_CHARGE,),
|
||||
)
|
||||
_D(
|
||||
"Wireless Mouse M350",
|
||||
codename="M350",
|
||||
protocol=1.0,
|
||||
wpid="101C",
|
||||
registers=(Reg.BATTERY_CHARGE,),
|
||||
)
|
||||
_D(
|
||||
"Wireless Mouse M505",
|
||||
codename="M505/B605",
|
||||
protocol=1.0,
|
||||
wpid="101D",
|
||||
registers=(Reg.BATTERY_CHARGE,),
|
||||
)
|
||||
_D(
|
||||
"Wireless Mouse M305",
|
||||
codename="M305",
|
||||
protocol=1.0,
|
||||
wpid="101F",
|
||||
registers=(Reg.BATTERY_STATUS,),
|
||||
)
|
||||
_D(
|
||||
"Wireless Mouse M215",
|
||||
codename="M215",
|
||||
protocol=1.0,
|
||||
wpid="1020",
|
||||
)
|
||||
_D("Anywhere Mouse MX", codename="Anywhere MX", protocol=1.0, wpid="1017", registers=(_R.battery_charge,))
|
||||
_D("Performance Mouse MX", codename="Performance MX", protocol=1.0, wpid="101A", registers=(_R.battery_status, _R.three_leds))
|
||||
_D("Marathon Mouse M705 (M-R0009)", codename="M705 (M-R0009)", protocol=1.0, wpid="101B", registers=(_R.battery_charge,))
|
||||
_D("Wireless Mouse M350", codename="M350", protocol=1.0, wpid="101C", registers=(_R.battery_charge,))
|
||||
_D("Wireless Mouse M505", codename="M505/B605", protocol=1.0, wpid="101D", registers=(_R.battery_charge,))
|
||||
_D("Wireless Mouse M305", codename="M305", protocol=1.0, wpid="101F", registers=(_R.battery_status,))
|
||||
_D("Wireless Mouse M215", codename="M215", protocol=1.0, wpid="1020")
|
||||
_D(
|
||||
"G700 Gaming Mouse",
|
||||
codename="G700",
|
||||
@@ -288,12 +361,12 @@ _D(
|
||||
usbid=0xC06B,
|
||||
interface=1,
|
||||
registers=(
|
||||
_R.battery_status,
|
||||
_R.three_leds,
|
||||
Reg.BATTERY_STATUS,
|
||||
Reg.THREE_LEDS,
|
||||
),
|
||||
)
|
||||
_D("Wireless Mouse M310", codename="M310", protocol=1.0, wpid="1024", registers=(_R.battery_status,))
|
||||
_D("Wireless Mouse M510", codename="M510", protocol=1.0, wpid="1025", registers=(_R.battery_status,))
|
||||
_D("Wireless Mouse M310", codename="M310", protocol=1.0, wpid="1024", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Wireless Mouse M510", codename="M510", protocol=1.0, wpid="1025", registers=(Reg.BATTERY_STATUS,))
|
||||
_D("Fujitsu Sonic Mouse", codename="Sonic", protocol=1.0, wpid="1029")
|
||||
_D(
|
||||
"G700s Gaming Mouse",
|
||||
@@ -303,12 +376,12 @@ _D(
|
||||
usbid=0xC07C,
|
||||
interface=1,
|
||||
registers=(
|
||||
_R.battery_status,
|
||||
_R.three_leds,
|
||||
Reg.BATTERY_STATUS,
|
||||
Reg.THREE_LEDS,
|
||||
),
|
||||
)
|
||||
_D("Couch Mouse M515", codename="M515", protocol=2.0, wpid="4007")
|
||||
_D("Wireless Mouse M175", codename="M175", protocol=2.0, wpid="4008")
|
||||
# _D("Wireless Mouse M175", codename="M175", protocol=2.0, wpid="4008")
|
||||
_D("Wireless Mouse M325", codename="M325", protocol=2.0, wpid="400A")
|
||||
_D("Wireless Mouse M525", codename="M525", protocol=2.0, wpid="4013")
|
||||
_D("Wireless Mouse M345", codename="M345", protocol=2.0, wpid="4017")
|
||||
@@ -348,7 +421,7 @@ _D("G502 Lightspeed Gaming Mouse", codename="G502 Lightspeed", usbid=0xC08D)
|
||||
_D("MX518 Gaming Mouse", codename="MX518", usbid=0xC08E, interface=1)
|
||||
_D("G703 Hero Gaming Mouse", codename="G703 Hero", usbid=0xC090)
|
||||
_D("G903 Hero Gaming Mouse", codename="G903 Hero", usbid=0xC091)
|
||||
_D(None, kind=_DK.mouse, usbid=0xC092, interface=1) # two mice share this ID
|
||||
_D(None, kind=DEVICE_KIND.mouse, usbid=0xC092, interface=1) # two mice share this ID
|
||||
_D("M500S Mouse", codename="M500S", usbid=0xC093, interface=1)
|
||||
# _D('G600 Gaming Mouse', codename='G600 Gaming', usbid=0xc24a, interface=1) # not an HID++ device
|
||||
_D("G500s Gaming Mouse", codename="G500s Gaming", usbid=0xC24E, interface=1, protocol=1.0)
|
||||
@@ -365,13 +438,29 @@ _D("Wireless Trackball M570", codename="M570")
|
||||
|
||||
_D("Wireless Touchpad", codename="Wireless Touch", protocol=2.0, wpid="4011")
|
||||
_D("Wireless Rechargeable Touchpad T650", codename="T650", protocol=2.0, wpid="4101")
|
||||
_D("G Powerplay", codename="Powerplay", protocol=2.0, kind=_DK.touchpad, wpid="405F") # To override self-identification
|
||||
_D(
|
||||
"G Powerplay", codename="Powerplay", protocol=2.0, kind=DEVICE_KIND.touchpad, wpid="405F"
|
||||
) # To override self-identification
|
||||
|
||||
# Headset
|
||||
|
||||
_D("G533 Gaming Headset", codename="G533 Headset", protocol=2.0, interface=3, kind=_DK.headset, usbid=0x0A66)
|
||||
_D("G535 Gaming Headset", codename="G535 Headset", protocol=2.0, interface=3, kind=_DK.headset, usbid=0x0AC4)
|
||||
_D("G935 Gaming Headset", codename="G935 Headset", protocol=2.0, interface=3, kind=_DK.headset, usbid=0x0A87)
|
||||
_D("G733 Gaming Headset", codename="G733 Headset", protocol=2.0, interface=3, kind=_DK.headset, usbid=0x0AB5)
|
||||
_D("G733 Gaming Headset", codename="G733 Headset New", protocol=2.0, interface=3, kind=_DK.headset, usbid=0x0AFE)
|
||||
_D("PRO X Wireless Gaming Headset", codename="PRO Headset", protocol=2.0, interface=3, kind=_DK.headset, usbid=0x0ABA)
|
||||
_D("G533 Gaming Headset", codename="G533 Headset", protocol=2.0, interface=3, kind=DEVICE_KIND.headset, usbid=0x0A66)
|
||||
_D("G535 Gaming Headset", codename="G535 Headset", protocol=2.0, interface=3, kind=DEVICE_KIND.headset, usbid=0x0AC4)
|
||||
_D("G935 Gaming Headset", codename="G935 Headset", protocol=2.0, interface=3, kind=DEVICE_KIND.headset, usbid=0x0A87)
|
||||
_D("G733 Gaming Headset", codename="G733 Headset", protocol=2.0, interface=3, kind=DEVICE_KIND.headset, usbid=0x0AB5)
|
||||
_D(
|
||||
"G733 Gaming Headset",
|
||||
codename="G733 Headset New",
|
||||
protocol=2.0,
|
||||
interface=3,
|
||||
kind=DEVICE_KIND.headset,
|
||||
usbid=0x0AFE,
|
||||
)
|
||||
_D(
|
||||
"PRO X Wireless Gaming Headset",
|
||||
codename="PRO Headset",
|
||||
protocol=2.0,
|
||||
interface=3,
|
||||
kind=DEVICE_KIND.headset,
|
||||
usbid=0x0ABA,
|
||||
)
|
||||
|
||||
@@ -14,29 +14,46 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
"""Implements the desktop notification service."""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("Notify", "0.7")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import GLib # this import is allowed to fail making the entire feature unavailable
|
||||
from gi.repository import Gtk # this import is allowed to fail making the entire feature unavailable
|
||||
from gi.repository import Notify # this import is allowed to fail making the entire feature unavailable
|
||||
def notifications_available():
|
||||
"""Checks if notification service is available."""
|
||||
notifications_supported = False
|
||||
try:
|
||||
import gi
|
||||
|
||||
available = True
|
||||
except (ValueError, ImportError):
|
||||
available = False
|
||||
gi.require_version("Notify", "0.7")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
importlib.util.find_spec("gi.repository.GLib")
|
||||
importlib.util.find_spec("gi.repository.Gtk")
|
||||
importlib.util.find_spec("gi.repository.Notify")
|
||||
|
||||
notifications_supported = True
|
||||
except ValueError as e:
|
||||
logger.warning(f"Notification service is not available: {e}")
|
||||
return notifications_supported
|
||||
|
||||
|
||||
available = notifications_available()
|
||||
|
||||
if available:
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Notify
|
||||
|
||||
# cache references to shown notifications here to allow reuse
|
||||
_notifications = {}
|
||||
_ICON_LISTS = {}
|
||||
|
||||
def init():
|
||||
"""Init the notifications system."""
|
||||
"""Initialize desktop notifications."""
|
||||
global available
|
||||
if available:
|
||||
if not Notify.is_initted():
|
||||
@@ -50,13 +67,14 @@ if available:
|
||||
return available and Notify.is_initted()
|
||||
|
||||
def uninit():
|
||||
"""Stop desktop notifications."""
|
||||
if available and Notify.is_initted():
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("stopping desktop notifications")
|
||||
_notifications.clear()
|
||||
Notify.uninit()
|
||||
|
||||
def show(dev, message, icon=None):
|
||||
def show(dev, message: str, icon=None):
|
||||
"""Show a notification with title and text."""
|
||||
if available and (Notify.is_initted() or init()):
|
||||
summary = dev.name
|
||||
@@ -68,13 +86,9 @@ if available:
|
||||
n.set_urgency(Notify.Urgency.NORMAL)
|
||||
n.set_hint("desktop-entry", GLib.Variant("s", "solaar")) # replace with better name late
|
||||
try:
|
||||
# if logger.isEnabledFor(logging.DEBUG):
|
||||
# logger.debug("showing %s", n)
|
||||
n.show()
|
||||
return n.show()
|
||||
except Exception:
|
||||
logger.exception("showing %s", n)
|
||||
|
||||
_ICON_LISTS = {}
|
||||
logger.exception(f"showing {n}")
|
||||
|
||||
def device_icon_list(name="_", kind=None):
|
||||
icon_list = _ICON_LISTS.get(name)
|
||||
@@ -82,16 +96,17 @@ if available:
|
||||
# names of possible icons, in reverse order of likelihood
|
||||
# the theme will hopefully pick up the most appropriate
|
||||
icon_list = ["preferences-desktop-peripherals"]
|
||||
kind = str(kind)
|
||||
if kind:
|
||||
if str(kind) == "numpad":
|
||||
if kind == "numpad":
|
||||
icon_list += ("input-keyboard", "input-dialpad")
|
||||
elif str(kind) == "touchpad":
|
||||
elif kind == "touchpad":
|
||||
icon_list += ("input-mouse", "input-tablet")
|
||||
elif str(kind) == "trackball":
|
||||
elif kind == "trackball":
|
||||
icon_list += ("input-mouse",)
|
||||
elif str(kind) == "headset":
|
||||
elif kind == "headset":
|
||||
icon_list += ("audio-headphones", "audio-headset")
|
||||
icon_list += ("input-" + str(kind),)
|
||||
icon_list += (f"input-{kind}",)
|
||||
_ICON_LISTS[name] = icon_list
|
||||
return icon_list
|
||||
|
||||
@@ -15,64 +15,104 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
import errno as _errno
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import threading as _threading
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
|
||||
from typing import Callable
|
||||
from typing import Optional
|
||||
from typing import Protocol
|
||||
|
||||
import hidapi as _hid
|
||||
import solaar.configuration as _configuration
|
||||
from solaar import configuration
|
||||
|
||||
from . import base
|
||||
from . import descriptors
|
||||
from . import exceptions
|
||||
from . import hidpp10
|
||||
from . import hidpp10_constants
|
||||
from . import hidpp20
|
||||
from . import hidpp20_constants
|
||||
from . import settings
|
||||
from .common import ALERT
|
||||
from . import settings_templates
|
||||
from .common import Alert
|
||||
from .common import Battery
|
||||
from .settings_templates import check_feature_settings as _check_feature_settings
|
||||
from .hidpp10_constants import NotificationFlag
|
||||
from .hidpp20_constants import SupportedFeature
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from logitech_receiver import common
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_hidpp10 = hidpp10.Hidpp10()
|
||||
_hidpp20 = hidpp20.Hidpp20()
|
||||
_R = hidpp10_constants.REGISTERS
|
||||
_IR = hidpp10_constants.INFO_SUBREGISTERS
|
||||
|
||||
|
||||
class DeviceFactory:
|
||||
@staticmethod
|
||||
def create_device(device_info, setting_callback=None):
|
||||
"""Opens a Logitech Device found attached to the machine, by Linux device path.
|
||||
:returns: An open file handle for the found receiver, or None.
|
||||
"""
|
||||
try:
|
||||
handle = base.open_path(device_info.path)
|
||||
if handle:
|
||||
# a direct connected device might not be online (as reported by user)
|
||||
return Device(None, None, None, handle=handle, device_info=device_info, setting_callback=setting_callback)
|
||||
except OSError as e:
|
||||
logger.exception("open %s", device_info)
|
||||
if e.errno == _errno.EACCES:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("open %s", device_info)
|
||||
class LowLevelInterface(Protocol):
|
||||
def open_path(self, path) -> int:
|
||||
...
|
||||
|
||||
def find_paired_node(self, receiver_path: str, index: int, timeout: int):
|
||||
...
|
||||
|
||||
def ping(self, handle, number, long_message: bool):
|
||||
...
|
||||
|
||||
def request(self, handle, devnumber, request_id, *params, **kwargs):
|
||||
...
|
||||
|
||||
def close(self, handle, *args, **kwargs) -> bool:
|
||||
...
|
||||
|
||||
|
||||
def create_device(low_level: LowLevelInterface, device_info, setting_callback=None):
|
||||
"""Opens a Logitech Device found attached to the machine, by Linux device path.
|
||||
:returns: An open file handle for the found receiver, or None.
|
||||
"""
|
||||
try:
|
||||
handle = low_level.open_path(device_info.path)
|
||||
if handle:
|
||||
# a direct connected device might not be online (as reported by user)
|
||||
return Device(
|
||||
low_level,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
handle=handle,
|
||||
device_info=device_info,
|
||||
setting_callback=setting_callback,
|
||||
)
|
||||
except OSError as e:
|
||||
logger.exception("open %s", device_info)
|
||||
if e.errno == errno.EACCES:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.exception("open %s", device_info)
|
||||
raise e
|
||||
|
||||
|
||||
class Device:
|
||||
instances = []
|
||||
read_register = hidpp10.read_register
|
||||
write_register = hidpp10.write_register
|
||||
read_register: Callable = hidpp10.read_register
|
||||
write_register: Callable = hidpp10.write_register
|
||||
|
||||
def __init__(self, receiver, number, online, pairing_info=None, handle=None, device_info=None, setting_callback=None):
|
||||
def __init__(
|
||||
self,
|
||||
low_level: LowLevelInterface,
|
||||
receiver,
|
||||
number,
|
||||
online,
|
||||
pairing_info=None,
|
||||
handle=None,
|
||||
device_info=None,
|
||||
setting_callback=None,
|
||||
):
|
||||
assert receiver or device_info
|
||||
if receiver:
|
||||
assert 0 < number <= 15 # some receivers have devices past their max # of devices
|
||||
self.low_level = low_level
|
||||
self.number = number # will be None at this point for directly connected devices
|
||||
self.online = online # is the device online? - gates many atempts to contact the device
|
||||
self.descriptor = None
|
||||
@@ -100,30 +140,31 @@ class Device:
|
||||
self._modelId = None # model id (contains identifiers for the transports of the device)
|
||||
self._tid_map = None # map from transports to product identifiers
|
||||
self._persister = None # persister holds settings
|
||||
self._led_effects = self._firmware = self._keys = self._remap_keys = self._gestures = None
|
||||
self._led_effects = self._firmware = self._keys = self._remap_keys = self._gestures = self._force_buttons = None
|
||||
self._profiles = self._backlight = self._settings = None
|
||||
self.registers = []
|
||||
self.notification_flags = None
|
||||
self.battery_info = None
|
||||
self.link_encrypted = None
|
||||
self._active = None # lags self.online - is used to help determine when to setup devices
|
||||
self.present = True # used for devices that are integral with their receiver but that separately be disconnected
|
||||
|
||||
self._feature_settings_checked = False
|
||||
self._gestures_lock = _threading.Lock()
|
||||
self._settings_lock = _threading.Lock()
|
||||
self._persister_lock = _threading.Lock()
|
||||
self._gestures_lock = threading.Lock()
|
||||
self._settings_lock = threading.Lock()
|
||||
self._persister_lock = threading.Lock()
|
||||
self._notification_handlers = {} # See `add_notification_handler`
|
||||
self.cleanups = [] # functions to run on the device when it is closed
|
||||
|
||||
if not self.path:
|
||||
self.path = _hid.find_paired_node(receiver.path, number, 1) if receiver else None
|
||||
self.path = self.low_level.find_paired_node(receiver.path, number, 1) if receiver else None
|
||||
if not self.handle:
|
||||
try:
|
||||
self.handle = base.open_path(self.path) if self.path else None
|
||||
self.handle = self.low_level.open_path(self.path) if self.path else None
|
||||
except Exception: # maybe the device wasn't set up
|
||||
try:
|
||||
time.sleep(1)
|
||||
self.handle = base.open_path(self.path) if self.path else None
|
||||
self.handle = self.low_level.open_path(self.path) if self.path else None
|
||||
except Exception: # give up
|
||||
self.handle = None # should this give up completely?
|
||||
|
||||
@@ -143,7 +184,11 @@ class Device:
|
||||
descriptors.get_btid(self.product_id) if self.bluetooth else descriptors.get_usbid(self.product_id)
|
||||
)
|
||||
if self.number is None: # for direct-connected devices get 'number' from descriptor protocol else use 0xFF
|
||||
self.number = 0x00 if self.descriptor and self.descriptor.protocol and self.descriptor.protocol < 2.0 else 0xFF
|
||||
if self.descriptor and self.descriptor.protocol and self.descriptor.protocol < 2.0:
|
||||
number = 0x00
|
||||
else:
|
||||
number = 0xFF
|
||||
self.number = number
|
||||
self.ping() # determine whether a direct-connected device is online
|
||||
|
||||
if self.descriptor:
|
||||
@@ -162,10 +207,10 @@ class Device:
|
||||
|
||||
Device.instances.append(self)
|
||||
|
||||
def find(self, id): # find a device by serial number or unit ID
|
||||
assert id, "need serial number or unit ID to find a device"
|
||||
def find(self, id): # find a device by serial number or unit ID or name or codename
|
||||
assert id, "need id to find a device"
|
||||
for device in Device.instances:
|
||||
if device.online and (device.unitId == id or device.serial == id):
|
||||
if device.online and (device.unitId == id or device.serial == id or device.name == id or device.codename == id):
|
||||
return device
|
||||
|
||||
@property
|
||||
@@ -177,8 +222,6 @@ class Device:
|
||||
@property
|
||||
def codename(self):
|
||||
if not self._codename:
|
||||
if not self.online: # be very defensive
|
||||
self.ping()
|
||||
if self.online and self.protocol >= 2.0:
|
||||
self._codename = _hidpp20.get_friendly_name(self)
|
||||
if not self._codename:
|
||||
@@ -188,20 +231,15 @@ class Device:
|
||||
if codename:
|
||||
self._codename = codename
|
||||
elif self.protocol < 2.0:
|
||||
self._codename = "? (%s)" % (self.wpid or hex(self.product_id)[2:].upper())
|
||||
return self._codename or "?? (%s)" % (self.wpid or hex(self.product_id)[2:].upper())
|
||||
self._codename = "? (%s)" % (self.wpid or self.product_id)
|
||||
return self._codename or f"?? ({self.wpid or self.product_id})"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
if not self._name:
|
||||
if not self.online: # be very defensive
|
||||
try:
|
||||
self.ping()
|
||||
except exceptions.NoSuchDevice:
|
||||
pass
|
||||
if self.online and self.protocol >= 2.0:
|
||||
self._name = _hidpp20.get_name(self)
|
||||
return self._name or self._codename or ("Unknown device %s" % (self.wpid or hex(self.product_id)[2:].upper()))
|
||||
return self._name or self._codename or f"Unknown device {self.wpid or self.product_id}"
|
||||
|
||||
def get_ids(self):
|
||||
ids = _hidpp20.get_ids(self)
|
||||
@@ -235,7 +273,7 @@ class Device:
|
||||
return self._kind or "?"
|
||||
|
||||
@property
|
||||
def firmware(self):
|
||||
def firmware(self) -> tuple[common.FirmwareInfo]:
|
||||
if self._firmware is None and self.online:
|
||||
if self.protocol >= 2.0:
|
||||
self._firmware = _hidpp20.get_firmware(self)
|
||||
@@ -265,9 +303,9 @@ class Device:
|
||||
@property
|
||||
def led_effects(self):
|
||||
if not self._led_effects and self.online and self.protocol >= 2.0:
|
||||
if hidpp20_constants.FEATURE.COLOR_LED_EFFECTS in self.features:
|
||||
if SupportedFeature.COLOR_LED_EFFECTS in self.features:
|
||||
self._led_effects = hidpp20.LEDEffectsInfo(self)
|
||||
elif hidpp20_constants.FEATURE.RGB_EFFECTS in self.features:
|
||||
elif SupportedFeature.RGB_EFFECTS in self.features:
|
||||
self._led_effects = hidpp20.RGBEffectsInfo(self)
|
||||
return self._led_effects
|
||||
|
||||
@@ -308,9 +346,15 @@ class Device:
|
||||
self._profiles = _hidpp20.get_profiles(self)
|
||||
return self._profiles
|
||||
|
||||
def set_configuration(self, configuration, no_reply=False):
|
||||
def force_buttons(self):
|
||||
if self._force_buttons is None:
|
||||
if self.online and self.protocol >= 2.0:
|
||||
self._force_buttons = _hidpp20.get_force_buttons(self) or ()
|
||||
return self._force_buttons
|
||||
|
||||
def set_configuration(self, configuration_, no_reply=False):
|
||||
if self.online and self.protocol >= 2.0:
|
||||
_hidpp20.config_change(self, configuration, no_reply=no_reply)
|
||||
_hidpp20.config_change(self, configuration_, no_reply=no_reply)
|
||||
|
||||
def reset(self, no_reply=False):
|
||||
self.set_configuration(0, no_reply)
|
||||
@@ -320,7 +364,7 @@ class Device:
|
||||
if not self._persister:
|
||||
with self._persister_lock:
|
||||
if not self._persister:
|
||||
self._persister = _configuration.persister(self)
|
||||
self._persister = configuration.persister(self)
|
||||
return self._persister
|
||||
|
||||
@property
|
||||
@@ -343,7 +387,7 @@ class Device:
|
||||
if not self._feature_settings_checked:
|
||||
with self._settings_lock:
|
||||
if not self._feature_settings_checked:
|
||||
self._feature_settings_checked = _check_feature_settings(self, self._settings)
|
||||
self._feature_settings_checked = settings_templates.check_feature_settings(self, self._settings)
|
||||
return self._settings
|
||||
|
||||
def battery(self): # None or level, next, status, voltage
|
||||
@@ -356,11 +400,11 @@ class Device:
|
||||
try:
|
||||
feature, battery = result
|
||||
if self.persister and battery_feature is None:
|
||||
self.persister["_battery"] = feature
|
||||
self.persister["_battery"] = feature.value
|
||||
return battery
|
||||
except Exception:
|
||||
if self.persister and battery_feature is None:
|
||||
self.persister["_battery"] = result
|
||||
if self.persister and battery_feature is None and result is not None and result != 0:
|
||||
self.persister["_battery"] = result.value
|
||||
|
||||
def set_battery_info(self, info):
|
||||
"""Update battery information for device, calling changed callback if necessary"""
|
||||
@@ -374,11 +418,11 @@ class Device:
|
||||
if old_info is None:
|
||||
old_info = Battery(None, None, None, None)
|
||||
|
||||
alert, reason = ALERT.NONE, None
|
||||
alert, reason = Alert.NONE, None
|
||||
if not info.ok():
|
||||
logger.warning("%s: battery %d%%, ALERT %s", self, info.level, info.status)
|
||||
if old_info.status != info.status:
|
||||
alert = ALERT.NOTIFICATION | ALERT.ATTENTION
|
||||
alert = Alert.NOTIFICATION | Alert.ATTENTION
|
||||
reason = info.to_str()
|
||||
|
||||
if changed or reason:
|
||||
@@ -392,9 +436,11 @@ class Device:
|
||||
battery = self.battery()
|
||||
self.set_battery_info(battery if battery is not None else Battery(None, None, None, None))
|
||||
|
||||
def changed(self, active=None, alert=ALERT.NONE, reason=None, push=False):
|
||||
def changed(self, active=None, alert=Alert.NONE, reason=None, push=False):
|
||||
"""The status of the device had changed, so invoke the status callback.
|
||||
Also push notifications and settings to the device when necessary."""
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("device %d changing: active=%s %s present=%s", self.number, active, self._active, self.present)
|
||||
if active is not None:
|
||||
self.online = active
|
||||
was_active, self._active = self._active, active
|
||||
@@ -405,7 +451,7 @@ class Device:
|
||||
was_active is None
|
||||
or not was_active
|
||||
or push
|
||||
and (not self.features or hidpp20_constants.FEATURE.WIRELESS_DEVICE_STATUS not in self.features)
|
||||
and (not self.features or SupportedFeature.WIRELESS_DEVICE_STATUS not in self.features)
|
||||
):
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s pushing device settings %s", self, self.settings)
|
||||
@@ -417,7 +463,7 @@ class Device:
|
||||
self.set_configuration(0x11) # signal end of configuration
|
||||
self.read_battery() # battery information may have changed so try to read it now
|
||||
elif was_active and self.receiver: # need to set configuration pending flag in receiver
|
||||
hidpp10.Hidpp10().set_configuration_pending_flags(self.receiver, 0xFF)
|
||||
hidpp10.set_configuration_pending_flags(self.receiver, 0xFF)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("device %d changed: active=%s %s", self.number, self._active, self.battery_info)
|
||||
if self.status_callback is not None:
|
||||
@@ -430,11 +476,7 @@ class Device:
|
||||
return False
|
||||
|
||||
if enable:
|
||||
set_flag_bits = (
|
||||
hidpp10_constants.NOTIFICATION_FLAG.battery_status
|
||||
| hidpp10_constants.NOTIFICATION_FLAG.ui
|
||||
| hidpp10_constants.NOTIFICATION_FLAG.configuration_complete
|
||||
)
|
||||
set_flag_bits = NotificationFlag.BATTERY_STATUS | NotificationFlag.UI | NotificationFlag.CONFIGURATION_COMPLETE
|
||||
else:
|
||||
set_flag_bits = 0
|
||||
ok = _hidpp10.set_notification_flags(self, set_flag_bits)
|
||||
@@ -443,8 +485,12 @@ class Device:
|
||||
|
||||
flag_bits = _hidpp10.get_notification_flags(self)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
flag_names = None if flag_bits is None else tuple(hidpp10_constants.NOTIFICATION_FLAG.flag_names(flag_bits))
|
||||
logger.info("%s: device notifications %s %s", self, "enabled" if enable else "disabled", flag_names)
|
||||
if flag_bits is None:
|
||||
flag_names = None
|
||||
else:
|
||||
flag_names = hidpp10_constants.NotificationFlag.flag_names(flag_bits)
|
||||
is_enabled = "enabled" if enable else "disabled"
|
||||
logger.info(f"{self}: device notifications {is_enabled} {flag_names}")
|
||||
return flag_bits if ok else None
|
||||
|
||||
def add_notification_handler(self, id: str, fn):
|
||||
@@ -481,8 +527,8 @@ class Device:
|
||||
long = self.hidpp_long is True or (
|
||||
self.hidpp_long is None and (self.bluetooth or self._protocol is not None and self._protocol >= 2.0)
|
||||
)
|
||||
return base.request(
|
||||
self.handle or self.receiver.handle,
|
||||
return self.low_level.request(
|
||||
self.handle or (self.receiver.handle if self.receiver else None),
|
||||
self.number,
|
||||
request_id,
|
||||
*params,
|
||||
@@ -496,14 +542,21 @@ class Device:
|
||||
return hidpp20.feature_request(self, feature, function, *params, no_reply=no_reply)
|
||||
|
||||
def ping(self):
|
||||
"""Checks if the device is online, returns True of False"""
|
||||
"""Checks if the device is online and present, returns True of False.
|
||||
Some devices are integral with their receiver but may not be present even if the receiver responds to ping."""
|
||||
long = self.hidpp_long is True or (
|
||||
self.hidpp_long is None and (self.bluetooth or self._protocol is not None and self._protocol >= 2.0)
|
||||
)
|
||||
protocol = base.ping(self.handle or self.receiver.handle, self.number, long_message=long)
|
||||
self.online = protocol is not None
|
||||
handle = self.handle or self.receiver.handle
|
||||
try:
|
||||
protocol = self.low_level.ping(handle, self.number, long_message=long)
|
||||
except exceptions.NoReceiver: # if ping fails, device is offline
|
||||
protocol = None
|
||||
self.online = protocol is not None and self.present
|
||||
if protocol:
|
||||
self._protocol = protocol
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("pinged %s: online %s protocol %s present %s", self.number, self.online, protocol, self.present)
|
||||
return self.online
|
||||
|
||||
def notify_devices(self): # no need to notify, as there are none
|
||||
@@ -516,7 +569,7 @@ class Device:
|
||||
if hasattr(self, "cleanups"):
|
||||
for cleanup in self.cleanups:
|
||||
cleanup(self)
|
||||
return handle and base.close(handle)
|
||||
return handle and self.low_level.close(handle)
|
||||
|
||||
def __index__(self):
|
||||
return self.number
|
||||
|
||||
@@ -14,48 +14,48 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
import ctypes as _ctypes
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import math
|
||||
import numbers
|
||||
import os as _os
|
||||
import os.path as _path
|
||||
import platform as _platform
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys as _sys
|
||||
import time as _time
|
||||
import sys
|
||||
import time
|
||||
import typing
|
||||
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import Tuple
|
||||
|
||||
import dbus
|
||||
import gi
|
||||
import psutil
|
||||
import yaml
|
||||
|
||||
from keysyms import keysymdef
|
||||
|
||||
# There is no evdev on macOS or Windows. Diversion will not work without
|
||||
# it but other Solaar functionality is available.
|
||||
if _platform.system() in ("Darwin", "Windows"):
|
||||
if platform.system() in ("Darwin", "Windows"):
|
||||
evdev = None
|
||||
else:
|
||||
import evdev
|
||||
|
||||
from math import sqrt as _sqrt
|
||||
from struct import unpack as _unpack
|
||||
|
||||
from yaml import add_representer as _yaml_add_representer
|
||||
from yaml import dump_all as _yaml_dump_all
|
||||
from yaml import safe_load_all as _yaml_safe_load_all
|
||||
|
||||
from .common import NamedInt
|
||||
from .hidpp20 import FEATURE as _F
|
||||
from .special_keys import CONTROL as _CONTROL
|
||||
from .hidpp20 import SupportedFeature
|
||||
from .special_keys import CONTROL
|
||||
|
||||
gi.require_version("Gdk", "3.0") # isort:skip
|
||||
from gi.repository import Gdk, GLib # NOQA: E402 # isort:skip
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .base import HIDPPNotification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#
|
||||
@@ -88,7 +88,7 @@ logger = logging.getLogger(__name__)
|
||||
# Xtest extension to X11 - provides input simulation, partly works under Wayland
|
||||
# Wayland - provides input simulation
|
||||
|
||||
XK_KEYS: Dict[str, int] = keysymdef.keysymdef
|
||||
XK_KEYS: Dict[str, int] = keysymdef.key_symbols
|
||||
|
||||
# Event codes - can't use Xlib.X codes because Xlib might not be available
|
||||
_KEY_RELEASE = 0
|
||||
@@ -103,7 +103,7 @@ gkeymap = Gdk.Keymap.get_for_display(gdisplay) if gdisplay else None
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("GDK Keymap %sset up", "" if gkeymap else "not ")
|
||||
|
||||
wayland = _os.getenv("WAYLAND_DISPLAY") # is this Wayland?
|
||||
wayland = os.getenv("WAYLAND_DISPLAY") # is this Wayland?
|
||||
if wayland:
|
||||
logger.warning(
|
||||
"rules cannot access modifier keys in Wayland, "
|
||||
@@ -120,9 +120,17 @@ except Exception:
|
||||
# Globals
|
||||
xtest_available = True # Xtest might be available
|
||||
xdisplay = None
|
||||
|
||||
|
||||
Xkbdisplay = None # xkb might be available
|
||||
X11Lib = None
|
||||
|
||||
modifier_keycodes = []
|
||||
XkbUseCoreKbd = 0x100
|
||||
NET_ACTIVE_WINDOW = None
|
||||
NET_WM_PID = None
|
||||
WM_CLASS = None
|
||||
|
||||
|
||||
udevice = None
|
||||
|
||||
@@ -138,26 +146,26 @@ thumb_wheel_displacement = 0
|
||||
_dbus_interface = None
|
||||
|
||||
|
||||
class XkbDisplay(_ctypes.Structure):
|
||||
class XkbDisplay(ctypes.Structure):
|
||||
"""opaque struct"""
|
||||
|
||||
|
||||
class XkbStateRec(_ctypes.Structure):
|
||||
class XkbStateRec(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("group", _ctypes.c_ubyte),
|
||||
("locked_group", _ctypes.c_ubyte),
|
||||
("base_group", _ctypes.c_ushort),
|
||||
("latched_group", _ctypes.c_ushort),
|
||||
("mods", _ctypes.c_ubyte),
|
||||
("base_mods", _ctypes.c_ubyte),
|
||||
("latched_mods", _ctypes.c_ubyte),
|
||||
("locked_mods", _ctypes.c_ubyte),
|
||||
("compat_state", _ctypes.c_ubyte),
|
||||
("grab_mods", _ctypes.c_ubyte),
|
||||
("compat_grab_mods", _ctypes.c_ubyte),
|
||||
("lookup_mods", _ctypes.c_ubyte),
|
||||
("compat_lookup_mods", _ctypes.c_ubyte),
|
||||
("ptr_buttons", _ctypes.c_ushort),
|
||||
("group", ctypes.c_ubyte),
|
||||
("locked_group", ctypes.c_ubyte),
|
||||
("base_group", ctypes.c_ushort),
|
||||
("latched_group", ctypes.c_ushort),
|
||||
("mods", ctypes.c_ubyte),
|
||||
("base_mods", ctypes.c_ubyte),
|
||||
("latched_mods", ctypes.c_ubyte),
|
||||
("locked_mods", ctypes.c_ubyte),
|
||||
("compat_state", ctypes.c_ubyte),
|
||||
("grab_mods", ctypes.c_ubyte),
|
||||
("compat_grab_mods", ctypes.c_ubyte),
|
||||
("lookup_mods", ctypes.c_ubyte),
|
||||
("compat_lookup_mods", ctypes.c_ubyte),
|
||||
("ptr_buttons", ctypes.c_ushort),
|
||||
] # something strange is happening here but it is not being used
|
||||
|
||||
|
||||
@@ -177,7 +185,7 @@ def x11_setup():
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("X11 library loaded and display set up")
|
||||
except Exception:
|
||||
logger.warning("X11 not available - some rule capabilities inoperable", exc_info=_sys.exc_info())
|
||||
logger.warning("X11 not available - some rule capabilities inoperable", exc_info=sys.exc_info())
|
||||
_x11 = False
|
||||
xtest_available = False
|
||||
return _x11
|
||||
@@ -188,11 +196,16 @@ def gnome_dbus_interface_setup():
|
||||
if _dbus_interface is not None:
|
||||
return _dbus_interface
|
||||
try:
|
||||
import dbus
|
||||
|
||||
bus = dbus.SessionBus()
|
||||
remote_object = bus.get_object("org.gnome.Shell", "/io/github/pwr_solaar/solaar")
|
||||
_dbus_interface = dbus.Interface(remote_object, "io.github.pwr_solaar.solaar")
|
||||
except dbus.exceptions.DBusException:
|
||||
logger.warning("Solaar Gnome extension not installed - some rule capabilities inoperable", exc_info=_sys.exc_info())
|
||||
logger.warning(
|
||||
"Solaar Gnome extension not installed - some rule capabilities inoperable",
|
||||
exc_info=sys.exc_info(),
|
||||
)
|
||||
_dbus_interface = False
|
||||
return _dbus_interface
|
||||
|
||||
@@ -202,14 +215,14 @@ def xkb_setup():
|
||||
if Xkbdisplay is not None:
|
||||
return Xkbdisplay
|
||||
try: # set up to get keyboard state using ctypes interface to libx11
|
||||
X11Lib = _ctypes.cdll.LoadLibrary("libX11.so")
|
||||
X11Lib.XOpenDisplay.restype = _ctypes.POINTER(XkbDisplay)
|
||||
X11Lib.XkbGetState.argtypes = [_ctypes.POINTER(XkbDisplay), _ctypes.c_uint, _ctypes.POINTER(XkbStateRec)]
|
||||
X11Lib = ctypes.cdll.LoadLibrary("libX11.so")
|
||||
X11Lib.XOpenDisplay.restype = ctypes.POINTER(XkbDisplay)
|
||||
X11Lib.XkbGetState.argtypes = [ctypes.POINTER(XkbDisplay), ctypes.c_uint, ctypes.POINTER(XkbStateRec)]
|
||||
Xkbdisplay = X11Lib.XOpenDisplay(None)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("XKB display set up")
|
||||
except Exception:
|
||||
logger.warning("XKB display not available - rules cannot access keyboard group", exc_info=_sys.exc_info())
|
||||
logger.warning("XKB display not available - rules cannot access keyboard group", exc_info=sys.exc_info())
|
||||
Xkbdisplay = False
|
||||
return Xkbdisplay
|
||||
|
||||
@@ -226,6 +239,8 @@ if evdev:
|
||||
"scroll_right": (7, evdev.ecodes.ecodes["BTN_7"]),
|
||||
"button8": (8, evdev.ecodes.ecodes["BTN_8"]),
|
||||
"button9": (9, evdev.ecodes.ecodes["BTN_9"]),
|
||||
"back": (10, evdev.ecodes.ecodes["BTN_SIDE"]),
|
||||
"forward": (11, evdev.ecodes.ecodes["BTN_EXTRA"]),
|
||||
}
|
||||
|
||||
# uinput capability for keyboard keys, mouse buttons, and scrolling
|
||||
@@ -233,7 +248,10 @@ if evdev:
|
||||
for _, evcode in buttons.values():
|
||||
if evcode:
|
||||
key_events.append(evcode)
|
||||
devicecap = {evdev.ecodes.EV_KEY: key_events, evdev.ecodes.EV_REL: [evdev.ecodes.REL_WHEEL, evdev.ecodes.REL_HWHEEL]}
|
||||
devicecap = {
|
||||
evdev.ecodes.EV_KEY: key_events,
|
||||
evdev.ecodes.EV_REL: [evdev.ecodes.REL_WHEEL, evdev.ecodes.REL_HWHEEL],
|
||||
}
|
||||
else:
|
||||
# Just mock these since they won't be useful without evdev anyway
|
||||
buttons = {}
|
||||
@@ -261,7 +279,7 @@ if wayland: # Wayland can't use xtest so may as well set up uinput now
|
||||
def kbdgroup():
|
||||
if xkb_setup():
|
||||
state = XkbStateRec()
|
||||
X11Lib.XkbGetState(Xkbdisplay, XkbUseCoreKbd, _ctypes.pointer(state))
|
||||
X11Lib.XkbGetState(Xkbdisplay, XkbUseCoreKbd, ctypes.pointer(state))
|
||||
return state.group
|
||||
else:
|
||||
return None
|
||||
@@ -281,16 +299,16 @@ def signed(bytes_: bytes) -> int:
|
||||
|
||||
def xy_direction(_x, _y):
|
||||
# normalize x and y
|
||||
m = _sqrt((_x * _x) + (_y * _y))
|
||||
m = math.sqrt((_x * _x) + (_y * _y))
|
||||
if m == 0:
|
||||
return "noop"
|
||||
x = round(_x / m)
|
||||
y = round(_y / m)
|
||||
if x < 0 and y < 0:
|
||||
return "Mouse Up-left"
|
||||
elif x > 0 and y < 0:
|
||||
elif x > 0 > y:
|
||||
return "Mouse Up-right"
|
||||
elif x < 0 and y > 0:
|
||||
elif x < 0 < y:
|
||||
return "Mouse Down-left"
|
||||
elif x > 0 and y > 0:
|
||||
return "Mouse Down-right"
|
||||
@@ -348,7 +366,7 @@ def simulate_uinput(what, code, arg):
|
||||
def simulate_key(code, event): # X11 keycode but Solaar event code
|
||||
if not wayland and simulate_xtest(code, event):
|
||||
return True
|
||||
if simulate_uinput(evdev.ecodes.EV_KEY, code - 8, event):
|
||||
if evdev and simulate_uinput(evdev.ecodes.EV_KEY, code - 8, event):
|
||||
return True
|
||||
logger.warning("no way to simulate key input")
|
||||
|
||||
@@ -418,7 +436,7 @@ def simulate_scroll(dx, dy):
|
||||
|
||||
def thumb_wheel_up(f, r, d, a):
|
||||
global thumb_wheel_displacement
|
||||
if f != _F.THUMB_WHEEL or r != 0:
|
||||
if f != SupportedFeature.THUMB_WHEEL or r != 0:
|
||||
return False
|
||||
if a is None:
|
||||
return signed(d[0:2]) < 0 and signed(d[0:2])
|
||||
@@ -431,7 +449,7 @@ def thumb_wheel_up(f, r, d, a):
|
||||
|
||||
def thumb_wheel_down(f, r, d, a):
|
||||
global thumb_wheel_displacement
|
||||
if f != _F.THUMB_WHEEL or r != 0:
|
||||
if f != SupportedFeature.THUMB_WHEEL or r != 0:
|
||||
return False
|
||||
if a is None:
|
||||
return signed(d[0:2]) > 0 and signed(d[0:2])
|
||||
@@ -444,9 +462,9 @@ def thumb_wheel_down(f, r, d, a):
|
||||
|
||||
def charging(f, r, d, _a):
|
||||
if (
|
||||
(f == _F.BATTERY_STATUS and r == 0 and 1 <= d[2] <= 4)
|
||||
or (f == _F.BATTERY_VOLTAGE and r == 0 and d[2] & (1 << 7))
|
||||
or (f == _F.UNIFIED_BATTERY and r == 0 and 1 <= d[2] <= 3)
|
||||
(f == SupportedFeature.BATTERY_STATUS and r == 0 and 1 <= d[2] <= 4)
|
||||
or (f == SupportedFeature.BATTERY_VOLTAGE and r == 0 and d[2] & (1 << 7))
|
||||
or (f == SupportedFeature.UNIFIED_BATTERY and r == 0 and 1 <= d[2] <= 3)
|
||||
):
|
||||
return 1
|
||||
else:
|
||||
@@ -454,20 +472,32 @@ def charging(f, r, d, _a):
|
||||
|
||||
|
||||
TESTS = {
|
||||
"crown_right": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[1] < 128 and d[1], False],
|
||||
"crown_left": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[1] >= 128 and 256 - d[1], False],
|
||||
"crown_right_ratchet": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[2] < 128 and d[2], False],
|
||||
"crown_left_ratchet": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[2] >= 128 and 256 - d[2], False],
|
||||
"crown_tap": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[5] == 0x01 and d[5], False],
|
||||
"crown_start_press": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[6] == 0x01 and d[6], False],
|
||||
"crown_end_press": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[6] == 0x05 and d[6], False],
|
||||
"crown_pressed": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[6] >= 0x01 and d[6] <= 0x04 and d[6], False],
|
||||
"crown_right": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[1] < 128 and d[1], False],
|
||||
"crown_left": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[1] >= 128 and 256 - d[1], False],
|
||||
"crown_right_ratchet": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[2] < 128 and d[2], False],
|
||||
"crown_left_ratchet": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[2] >= 128 and 256 - d[2], False],
|
||||
"crown_tap": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[5] == 0x01 and d[5], False],
|
||||
"crown_start_press": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[6] == 0x01 and d[6], False],
|
||||
"crown_end_press": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[6] == 0x05 and d[6], False],
|
||||
"crown_pressed": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and 0x01 <= d[6] <= 0x04 and d[6], False],
|
||||
"thumb_wheel_up": [thumb_wheel_up, True],
|
||||
"thumb_wheel_down": [thumb_wheel_down, True],
|
||||
"lowres_wheel_up": [lambda f, r, d, a: f == _F.LOWRES_WHEEL and r == 0 and signed(d[0:1]) > 0 and signed(d[0:1]), False],
|
||||
"lowres_wheel_down": [lambda f, r, d, a: f == _F.LOWRES_WHEEL and r == 0 and signed(d[0:1]) < 0 and signed(d[0:1]), False],
|
||||
"hires_wheel_up": [lambda f, r, d, a: f == _F.HIRES_WHEEL and r == 0 and signed(d[1:3]) > 0 and signed(d[1:3]), False],
|
||||
"hires_wheel_down": [lambda f, r, d, a: f == _F.HIRES_WHEEL and r == 0 and signed(d[1:3]) < 0 and signed(d[1:3]), False],
|
||||
"lowres_wheel_up": [
|
||||
lambda f, r, d, a: f == SupportedFeature.LOWRES_WHEEL and r == 0 and signed(d[0:1]) > 0 and signed(d[0:1]),
|
||||
False,
|
||||
],
|
||||
"lowres_wheel_down": [
|
||||
lambda f, r, d, a: f == SupportedFeature.LOWRES_WHEEL and r == 0 and signed(d[0:1]) < 0 and signed(d[0:1]),
|
||||
False,
|
||||
],
|
||||
"hires_wheel_up": [
|
||||
lambda f, r, d, a: f == SupportedFeature.HIRES_WHEEL and r == 0 and signed(d[1:3]) > 0 and signed(d[1:3]),
|
||||
False,
|
||||
],
|
||||
"hires_wheel_down": [
|
||||
lambda f, r, d, a: f == SupportedFeature.HIRES_WHEEL and r == 0 and signed(d[1:3]) < 0 and signed(d[1:3]),
|
||||
False,
|
||||
],
|
||||
"charging": [charging, False],
|
||||
"False": [lambda f, r, d, a: False, False],
|
||||
"True": [lambda f, r, d, a: True, False],
|
||||
@@ -481,7 +511,7 @@ MOUSE_GESTURE_TESTS = {
|
||||
"mouse-noop": [],
|
||||
}
|
||||
|
||||
COMPONENTS = {}
|
||||
# COMPONENTS = {}
|
||||
|
||||
|
||||
class RuleComponent:
|
||||
@@ -496,28 +526,32 @@ class RuleComponent:
|
||||
return Condition()
|
||||
|
||||
|
||||
def _evaluate(components, feature, notification: HIDPPNotification, device, result) -> Any:
|
||||
res = True
|
||||
for component in components:
|
||||
res = component.evaluate(feature, notification, device, result)
|
||||
if not isinstance(component, Action) and res is None:
|
||||
return None
|
||||
if isinstance(component, Condition) and not res:
|
||||
return res
|
||||
return res
|
||||
|
||||
|
||||
class Rule(RuleComponent):
|
||||
def __init__(self, args, source=None, warn=True):
|
||||
self.components = [self.compile(a) for a in args]
|
||||
self.source = source
|
||||
|
||||
def __str__(self):
|
||||
source = "(" + self.source + ")" if self.source else ""
|
||||
source = f"({self.source})" if self.source else ""
|
||||
return f"Rule{source}[{', '.join([c.__str__() for c in self.components])}]"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate rule: %s", self)
|
||||
result = True
|
||||
for component in self.components:
|
||||
result = component.evaluate(feature, notification, device, result)
|
||||
if not isinstance(component, Action) and result is None:
|
||||
return None
|
||||
if isinstance(component, Condition) and not result:
|
||||
return result
|
||||
return result
|
||||
return _evaluate(self.components, feature, notification, device, True)
|
||||
|
||||
def once(self, feature, notification, device, last_result):
|
||||
def once(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
self.evaluate(feature, notification, device, last_result)
|
||||
return False
|
||||
|
||||
@@ -532,7 +566,7 @@ class Condition(RuleComponent):
|
||||
def __str__(self):
|
||||
return "CONDITION"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
return False
|
||||
@@ -546,9 +580,9 @@ class Not(Condition):
|
||||
self.component = self.compile(op)
|
||||
|
||||
def __str__(self):
|
||||
return "Not: " + str(self.component)
|
||||
return f"Not: {str(self.component)}"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
result = self.component.evaluate(feature, notification, device, last_result)
|
||||
@@ -565,7 +599,7 @@ class Or(Condition):
|
||||
def __str__(self):
|
||||
return "Or: [" + ", ".join(str(c) for c in self.components) + "]"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
result = False
|
||||
@@ -588,17 +622,10 @@ class And(Condition):
|
||||
def __str__(self):
|
||||
return "And: [" + ", ".join(str(c) for c in self.components) + "]"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
result = True
|
||||
for component in self.components:
|
||||
result = component.evaluate(feature, notification, device, last_result)
|
||||
if not isinstance(component, Action) and result is None:
|
||||
return None
|
||||
if isinstance(component, Condition) and not result:
|
||||
return result
|
||||
return result
|
||||
return _evaluate(self.components, feature, notification, device, last_result)
|
||||
|
||||
def data(self):
|
||||
return {"And": [c.data() for c in self.components]}
|
||||
@@ -656,7 +683,8 @@ class Process(Condition):
|
||||
if (not wayland and not x11_setup()) or (wayland and not gnome_dbus_interface_setup()):
|
||||
if warn:
|
||||
logger.warning(
|
||||
"rules can only access active process in X11 or in Wayland under GNOME with Solaar Gnome extension - %s",
|
||||
"rules can only access active process in X11 or in Wayland under GNOME with Solaar Gnome "
|
||||
"extension - %s",
|
||||
self,
|
||||
)
|
||||
if not isinstance(process, str):
|
||||
@@ -665,9 +693,9 @@ class Process(Condition):
|
||||
self.process = str(process)
|
||||
|
||||
def __str__(self):
|
||||
return "Process: " + str(self.process)
|
||||
return f"Process: {str(self.process)}"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
if not isinstance(self.process, str):
|
||||
@@ -696,9 +724,9 @@ class MouseProcess(Condition):
|
||||
self.process = str(process)
|
||||
|
||||
def __str__(self):
|
||||
return "MouseProcess: " + str(self.process)
|
||||
return f"MouseProcess: {str(self.process)}"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
if not isinstance(self.process, str):
|
||||
@@ -712,17 +740,18 @@ class MouseProcess(Condition):
|
||||
|
||||
|
||||
class Feature(Condition):
|
||||
def __init__(self, feature, warn=True):
|
||||
if not (isinstance(feature, str) and feature in _F):
|
||||
def __init__(self, feature: str, warn: bool = True):
|
||||
try:
|
||||
self.feature = SupportedFeature[feature.replace(" ", "_")]
|
||||
except KeyError:
|
||||
self.feature = None
|
||||
if warn:
|
||||
logger.warning("rule Feature argument not name of a feature: %s", feature)
|
||||
self.feature = None
|
||||
self.feature = _F[feature]
|
||||
|
||||
def __str__(self):
|
||||
return "Feature: " + str(self.feature)
|
||||
return f"Feature: {str(self.feature)}"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
return feature == self.feature
|
||||
@@ -741,9 +770,9 @@ class Report(Condition):
|
||||
self.report = report
|
||||
|
||||
def __str__(self):
|
||||
return "Report: " + str(self.report)
|
||||
return f"Report: {str(self.report)}"
|
||||
|
||||
def evaluate(self, report, notification, device, last_result):
|
||||
def evaluate(self, report, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
return (notification.address >> 4) == self.report
|
||||
@@ -765,7 +794,7 @@ class Setting(Condition):
|
||||
def __str__(self):
|
||||
return "Setting: " + " ".join([str(a) for a in self.args])
|
||||
|
||||
def evaluate(self, report, notification, device, last_result):
|
||||
def evaluate(self, report, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
if len(self.args) < 3:
|
||||
@@ -814,9 +843,9 @@ class Modifiers(Condition):
|
||||
logger.warning("unknown rule Modifier value: %s", k)
|
||||
|
||||
def __str__(self):
|
||||
return "Modifiers: " + str(self.desired)
|
||||
return f"Modifiers: {str(self.desired)}"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
if gkeymap:
|
||||
@@ -856,8 +885,11 @@ class Key(Condition):
|
||||
elif len(args) >= 2:
|
||||
key, action = args[:2]
|
||||
|
||||
if isinstance(key, str) and key in _CONTROL:
|
||||
self.key = _CONTROL[key]
|
||||
if isinstance(key, str) and key in CONTROL:
|
||||
self.key = CONTROL[key]
|
||||
elif isinstance(key, str) and key.startswith("unknown:"):
|
||||
logger.info(f"rule Key key name currently unknown: {key}")
|
||||
self.key = CONTROL[int(key[-4:], 16)]
|
||||
else:
|
||||
if warn:
|
||||
logger.warning(f"rule Key key name not name of a Logitech key: {key}")
|
||||
@@ -873,7 +905,7 @@ class Key(Condition):
|
||||
def __str__(self):
|
||||
return f"Key: {str(self.key) if self.key else 'None'} ({self.action})"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
return bool(self.key and self.key == (key_down if self.action == self.DOWN else key_up))
|
||||
@@ -895,8 +927,8 @@ class KeyIsDown(Condition):
|
||||
elif isinstance(args, str):
|
||||
key = args
|
||||
|
||||
if isinstance(key, str) and key in _CONTROL:
|
||||
self.key = _CONTROL[key]
|
||||
if isinstance(key, str) and key in CONTROL:
|
||||
self.key = CONTROL[key]
|
||||
else:
|
||||
if warn:
|
||||
logger.warning(f"rule Key key name not name of a Logitech key: {key}")
|
||||
@@ -905,7 +937,7 @@ class KeyIsDown(Condition):
|
||||
def __str__(self):
|
||||
return f"KeyIsDown: {str(self.key) if self.key else 'None'}"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
return key_is_down(self.key)
|
||||
@@ -957,9 +989,9 @@ class Test(Condition):
|
||||
logger.warning("rule Test argument not valid %s", test)
|
||||
|
||||
def __str__(self):
|
||||
return "Test: " + str(self.test)
|
||||
return f"Test: {str(self.test)}"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
return self.function(feature, notification.address, notification.data, self.parameter)
|
||||
@@ -985,9 +1017,9 @@ class TestBytes(Condition):
|
||||
logger.warning("rule TestBytes argument not valid %s", test)
|
||||
|
||||
def __str__(self):
|
||||
return "TestBytes: " + str(self.test)
|
||||
return f"TestBytes: {str(self.test)}"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
return self.function(feature, notification.address, notification.data)
|
||||
@@ -1012,7 +1044,7 @@ class MouseGesture(Condition):
|
||||
if isinstance(movements, str):
|
||||
movements = [movements]
|
||||
for x in movements:
|
||||
if x not in self.MOVEMENTS and x not in _CONTROL:
|
||||
if x not in self.MOVEMENTS and x not in CONTROL:
|
||||
if warn:
|
||||
logger.warning("rule Mouse Gesture argument not direction or name of a Logitech key: %s", x)
|
||||
self.movements = movements
|
||||
@@ -1020,17 +1052,17 @@ class MouseGesture(Condition):
|
||||
def __str__(self):
|
||||
return "MouseGesture: " + " ".join(self.movements)
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
if feature == _F.MOUSE_GESTURE:
|
||||
if feature == SupportedFeature.MOUSE_GESTURE:
|
||||
d = notification.data
|
||||
data = _unpack("!" + (int(len(d) / 2) * "h"), d)
|
||||
data = struct.unpack("!" + (int(len(d) / 2) * "h"), d)
|
||||
data_offset = 1
|
||||
movement_offset = 0
|
||||
if self.movements and self.movements[0] not in self.MOVEMENTS: # matching against initiating key
|
||||
movement_offset = 1
|
||||
if self.movements[0] != str(_CONTROL[data[0]]):
|
||||
if self.movements[0] != str(CONTROL[data[0]]):
|
||||
return False
|
||||
for m in self.movements[movement_offset:]:
|
||||
if data_offset >= len(data):
|
||||
@@ -1041,7 +1073,7 @@ class MouseGesture(Condition):
|
||||
return False
|
||||
data_offset += 3
|
||||
elif data[data_offset] == 1:
|
||||
if m != str(_CONTROL[data[data_offset + 1]]):
|
||||
if m != str(CONTROL[data[data_offset + 1]]):
|
||||
return False
|
||||
data_offset += 2
|
||||
return data_offset == len(data)
|
||||
@@ -1060,9 +1092,9 @@ class Active(Condition):
|
||||
self.devID = devID
|
||||
|
||||
def __str__(self):
|
||||
return "Active: " + str(self.devID)
|
||||
return f"Active: {str(self.devID)}"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
dev = device.find(self.devID)
|
||||
@@ -1081,12 +1113,17 @@ class Device(Condition):
|
||||
self.devID = devID
|
||||
|
||||
def __str__(self):
|
||||
return "Device: " + str(self.devID)
|
||||
return f"Device: {str(self.devID)}"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
return device.unitId == self.devID or device.serial == self.devID
|
||||
return (
|
||||
device.unitId == self.devID
|
||||
or device.serial == self.devID
|
||||
or device.codename == self.devID
|
||||
or device.name == self.devID
|
||||
)
|
||||
|
||||
def data(self):
|
||||
return {"Device": self.devID}
|
||||
@@ -1101,9 +1138,9 @@ class Host(Condition):
|
||||
self.host = host
|
||||
|
||||
def __str__(self):
|
||||
return "Host: " + str(self.host)
|
||||
return f"Host: {str(self.host)}"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluate condition: %s", self)
|
||||
hostname = socket.getfqdn()
|
||||
@@ -1117,7 +1154,7 @@ class Action(RuleComponent):
|
||||
def __init__(self, *args):
|
||||
pass
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
return None
|
||||
|
||||
|
||||
@@ -1204,16 +1241,22 @@ class KeyPress(Action):
|
||||
simulate_key(keycode, _KEY_RELEASE)
|
||||
self.mods(level, modifiers, _KEY_RELEASE)
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if gkeymap:
|
||||
current = gkeymap.get_modifier_state()
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("KeyPress action: %s %s, group %s, modifiers %s", self.key_names, self.action, kbdgroup(), current)
|
||||
logger.info(
|
||||
"KeyPress action: %s %s, group %s, modifiers %s",
|
||||
self.key_names,
|
||||
self.action,
|
||||
kbdgroup(),
|
||||
current,
|
||||
)
|
||||
if self.action != RELEASE:
|
||||
self.keyDown(self.key_symbols, current)
|
||||
if self.action != DEPRESS:
|
||||
self.keyUp(reversed(self.key_symbols), current)
|
||||
_time.sleep(0.01)
|
||||
time.sleep(0.01)
|
||||
else:
|
||||
logger.warning("no keymap so cannot determine which keycode to send")
|
||||
return None
|
||||
@@ -1224,10 +1267,10 @@ class KeyPress(Action):
|
||||
|
||||
# KeyDown is dangerous as the key can auto-repeat and make your system unusable
|
||||
# class KeyDown(KeyPress):
|
||||
# def evaluate(self, feature, notification, device, last_result):
|
||||
# def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
# super().keyDown(self.keys, current_key_modifiers)
|
||||
# class KeyUp(KeyPress):
|
||||
# def evaluate(self, feature, notification, device, last_result):
|
||||
# def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
# super().keyUp(self.keys, current_key_modifiers)
|
||||
|
||||
|
||||
@@ -1244,7 +1287,7 @@ class MouseScroll(Action):
|
||||
def __str__(self):
|
||||
return "MouseScroll: " + " ".join([str(a) for a in self.amounts])
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
amounts = self.amounts
|
||||
if isinstance(last_result, numbers.Number):
|
||||
amounts = [math.floor(last_result * a) for a in self.amounts]
|
||||
@@ -1252,7 +1295,7 @@ class MouseScroll(Action):
|
||||
logger.info("MouseScroll action: %s %s %s", self.amounts, last_result, amounts)
|
||||
dx, dy = amounts
|
||||
simulate_scroll(dx, dy)
|
||||
_time.sleep(0.01)
|
||||
time.sleep(0.01)
|
||||
return None
|
||||
|
||||
def data(self):
|
||||
@@ -1277,18 +1320,21 @@ class MouseClick(Action):
|
||||
if count in [CLICK, DEPRESS, RELEASE]:
|
||||
self.count = count
|
||||
elif warn:
|
||||
logger.warning("rule MouseClick action: argument %s should be an integer or CLICK, PRESS, or RELEASE", count)
|
||||
logger.warning(
|
||||
"rule MouseClick action: argument %s should be an integer or click, depress, or release",
|
||||
count,
|
||||
)
|
||||
self.count = 1
|
||||
|
||||
def __str__(self):
|
||||
return f"MouseClick: {self.button} ({int(self.count)})"
|
||||
return f"MouseClick: {self.button} ({str(self.count)})"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info(f"MouseClick action: {int(self.count)} {self.button}")
|
||||
logger.info(f"MouseClick action: {str(self.count)} {self.button}")
|
||||
if self.button and self.count:
|
||||
click(buttons[self.button], self.count)
|
||||
_time.sleep(0.01)
|
||||
time.sleep(0.01)
|
||||
return None
|
||||
|
||||
def data(self):
|
||||
@@ -1307,7 +1353,7 @@ class Set(Action):
|
||||
def __str__(self):
|
||||
return "Set: " + " ".join([str(a) for a in self.args])
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if len(self.args) < 3:
|
||||
return None
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
@@ -1322,7 +1368,12 @@ class Set(Action):
|
||||
return None
|
||||
args = setting.acceptable(self.args[2:], setting.read())
|
||||
if args is None:
|
||||
logger.warning("Set Action: invalid args %s for setting %s of %s", self.args[2:], self.args[1], self.args[0])
|
||||
logger.warning(
|
||||
"Set Action: invalid args %s for setting %s of %s",
|
||||
self.args[2:],
|
||||
self.args[1],
|
||||
self.args[0],
|
||||
)
|
||||
return None
|
||||
if len(args) > 1:
|
||||
setting.write_key_value(args[0], args[1])
|
||||
@@ -1350,7 +1401,7 @@ class Execute(Action):
|
||||
def __str__(self):
|
||||
return "Execute: " + " ".join([a for a in self.args])
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("Execute action: %s", self.args)
|
||||
subprocess.Popen(self.args)
|
||||
@@ -1379,9 +1430,9 @@ class Later(Action):
|
||||
self.components = self.rule.components
|
||||
|
||||
def __str__(self):
|
||||
return "Later: [" + str(self.delay) + ", " + ", ".join(str(c) for c in self.components) + "]"
|
||||
return f"Later: [{str(self.delay)}, " + ", ".join(str(c) for c in self.components) + "]"
|
||||
|
||||
def evaluate(self, feature, notification, device, last_result):
|
||||
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
|
||||
if self.delay and self.rule:
|
||||
if self.delay >= 1:
|
||||
GLib.timeout_add_seconds(int(self.delay), Rule.once, self.rule, feature, notification, device, last_result)
|
||||
@@ -1422,93 +1473,93 @@ COMPONENTS = {
|
||||
"Later": Later,
|
||||
}
|
||||
|
||||
built_in_rules = Rule([])
|
||||
if True:
|
||||
built_in_rules = Rule(
|
||||
[
|
||||
{
|
||||
"Rule": [ # Implement problematic keys for Craft and MX Master
|
||||
{"Rule": [{"Key": ["Brightness Down", "pressed"]}, {"KeyPress": "XF86_MonBrightnessDown"}]},
|
||||
{"Rule": [{"Key": ["Brightness Up", "pressed"]}, {"KeyPress": "XF86_MonBrightnessUp"}]},
|
||||
]
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
built_in_rules = Rule(
|
||||
[
|
||||
{
|
||||
"Rule": [ # Implement problematic keys for Craft and MX Master
|
||||
{"Rule": [{"Key": ["Brightness Down", "pressed"]}, {"KeyPress": "XF86_MonBrightnessDown"}]},
|
||||
{"Rule": [{"Key": ["Brightness Up", "pressed"]}, {"KeyPress": "XF86_MonBrightnessUp"}]},
|
||||
]
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def key_is_down(key):
|
||||
if key == _CONTROL.MR:
|
||||
def key_is_down(key: NamedInt) -> bool:
|
||||
"""Checks if given key is pressed or not."""
|
||||
if key == CONTROL.MR:
|
||||
return mr_key_down
|
||||
elif _CONTROL.M1 <= key <= _CONTROL.M8:
|
||||
return bool(m_keys_down & (0x01 << (key - _CONTROL.M1)))
|
||||
elif _CONTROL.G1 <= key <= _CONTROL.G32:
|
||||
return bool(g_keys_down & (0x01 << (key - _CONTROL.G1)))
|
||||
else:
|
||||
return key in keys_down
|
||||
elif CONTROL.M1 <= key <= CONTROL.M8:
|
||||
return bool(m_keys_down & (0x01 << (key - CONTROL.M1)))
|
||||
elif CONTROL.G1 <= key <= CONTROL.G32:
|
||||
return bool(g_keys_down & (0x01 << (key - CONTROL.G1)))
|
||||
return key in keys_down
|
||||
|
||||
|
||||
def evaluate_rules(feature, notification, device):
|
||||
def evaluate_rules(feature, notification: HIDPPNotification, device):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("evaluating rules on %s", notification)
|
||||
logger.debug("evaluating rules on %s %s", feature, notification)
|
||||
rules.evaluate(feature, notification, device, True)
|
||||
|
||||
|
||||
# process a notification
|
||||
def process_notification(device, notification, feature):
|
||||
def process_notification(device, notification: HIDPPNotification, feature) -> None:
|
||||
"""Processes HID++ notifications."""
|
||||
global keys_down, g_keys_down, m_keys_down, mr_key_down, key_down, key_up, thumb_wheel_displacement
|
||||
key_down, key_up = None, None
|
||||
# need to keep track of keys that are down to find a new key down
|
||||
if feature == _F.REPROG_CONTROLS_V4 and notification.address == 0x00:
|
||||
new_keys_down = _unpack("!4H", notification.data[:8])
|
||||
for key in new_keys_down:
|
||||
if key and key not in keys_down:
|
||||
key_down = key
|
||||
for key in keys_down:
|
||||
if key and key not in new_keys_down:
|
||||
key_up = key
|
||||
keys_down = new_keys_down
|
||||
# and also G keys down
|
||||
elif feature == _F.GKEY and notification.address == 0x00:
|
||||
new_g_keys_down = _unpack("<I", notification.data[:4])[0]
|
||||
for i in range(32):
|
||||
if new_g_keys_down & (0x01 << i) and not g_keys_down & (0x01 << i):
|
||||
key_down = _CONTROL["G" + str(i + 1)]
|
||||
if g_keys_down & (0x01 << i) and not new_g_keys_down & (0x01 << i):
|
||||
key_up = _CONTROL["G" + str(i + 1)]
|
||||
g_keys_down = new_g_keys_down
|
||||
# and also M keys down
|
||||
elif feature == _F.MKEYS and notification.address == 0x00:
|
||||
new_m_keys_down = _unpack("!1B", notification.data[:1])[0]
|
||||
for i in range(1, 9):
|
||||
if new_m_keys_down & (0x01 << (i - 1)) and not m_keys_down & (0x01 << (i - 1)):
|
||||
key_down = _CONTROL["M" + str(i)]
|
||||
if m_keys_down & (0x01 << (i - 1)) and not new_m_keys_down & (0x01 << (i - 1)):
|
||||
key_up = _CONTROL["M" + str(i)]
|
||||
m_keys_down = new_m_keys_down
|
||||
# and also MR key
|
||||
elif feature == _F.MR and notification.address == 0x00:
|
||||
new_mr_key_down = _unpack("!1B", notification.data[:1])[0]
|
||||
if not mr_key_down and new_mr_key_down:
|
||||
key_down = _CONTROL["MR"]
|
||||
if mr_key_down and not new_mr_key_down:
|
||||
key_up = _CONTROL["MR"]
|
||||
mr_key_down = new_mr_key_down
|
||||
# keep track of thumb wheel movment
|
||||
elif feature == _F.THUMB_WHEEL and notification.address == 0x00:
|
||||
if notification.data[4] <= 0x01: # when wheel starts, zero out last movement
|
||||
thumb_wheel_displacement = 0
|
||||
thumb_wheel_displacement += signed(notification.data[0:2])
|
||||
if notification.address == 0x00:
|
||||
if feature == SupportedFeature.REPROG_CONTROLS_V4:
|
||||
new_keys_down = struct.unpack("!4H", notification.data[:8])
|
||||
for key in new_keys_down:
|
||||
if key and key not in keys_down:
|
||||
key_down = key
|
||||
for key in keys_down:
|
||||
if key and key not in new_keys_down:
|
||||
key_up = key
|
||||
keys_down = new_keys_down
|
||||
# and also G keys down
|
||||
elif feature == SupportedFeature.GKEY:
|
||||
new_g_keys_down = struct.unpack("<I", notification.data[:4])[0]
|
||||
for i in range(32):
|
||||
if new_g_keys_down & (0x01 << i) and not g_keys_down & (0x01 << i):
|
||||
key_down = CONTROL["G" + str(i + 1)]
|
||||
if g_keys_down & (0x01 << i) and not new_g_keys_down & (0x01 << i):
|
||||
key_up = CONTROL["G" + str(i + 1)]
|
||||
g_keys_down = new_g_keys_down
|
||||
# and also M keys down
|
||||
elif feature == SupportedFeature.MKEYS:
|
||||
new_m_keys_down = struct.unpack("!1B", notification.data[:1])[0]
|
||||
for i in range(1, 9):
|
||||
if new_m_keys_down & (0x01 << (i - 1)) and not m_keys_down & (0x01 << (i - 1)):
|
||||
key_down = CONTROL["M" + str(i)]
|
||||
if m_keys_down & (0x01 << (i - 1)) and not new_m_keys_down & (0x01 << (i - 1)):
|
||||
key_up = CONTROL["M" + str(i)]
|
||||
m_keys_down = new_m_keys_down
|
||||
# and also MR key
|
||||
elif feature == SupportedFeature.MR:
|
||||
new_mr_key_down = struct.unpack("!1B", notification.data[:1])[0]
|
||||
if not mr_key_down and new_mr_key_down:
|
||||
key_down = CONTROL["MR"]
|
||||
if mr_key_down and not new_mr_key_down:
|
||||
key_up = CONTROL["MR"]
|
||||
mr_key_down = new_mr_key_down
|
||||
# keep track of thumb wheel movement
|
||||
elif feature == SupportedFeature.THUMB_WHEEL:
|
||||
if notification.data[4] <= 0x01: # when wheel starts, zero out last movement
|
||||
thumb_wheel_displacement = 0
|
||||
thumb_wheel_displacement += signed(notification.data[0:2])
|
||||
|
||||
GLib.idle_add(evaluate_rules, feature, notification, device)
|
||||
|
||||
|
||||
_XDG_CONFIG_HOME = _os.environ.get("XDG_CONFIG_HOME") or _path.expanduser(_path.join("~", ".config"))
|
||||
_file_path = _path.join(_XDG_CONFIG_HOME, "solaar", "rules.yaml")
|
||||
_XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser(os.path.join("~", ".config"))
|
||||
_file_path = os.path.join(_XDG_CONFIG_HOME, "solaar", "rules.yaml")
|
||||
|
||||
rules = built_in_rules
|
||||
|
||||
|
||||
def _save_config_rule_file(file_name=_file_path):
|
||||
def _save_config_rule_file(file_name: str = _file_path):
|
||||
# This is a trick to show str/float/int lists in-line (inspired by https://stackoverflow.com/a/14001707)
|
||||
class inline_list(list):
|
||||
pass
|
||||
@@ -1516,7 +1567,7 @@ def _save_config_rule_file(file_name=_file_path):
|
||||
def blockseq_rep(dumper, data):
|
||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
|
||||
|
||||
_yaml_add_representer(inline_list, blockseq_rep)
|
||||
yaml.add_representer(inline_list, blockseq_rep)
|
||||
|
||||
def convert(elem):
|
||||
if isinstance(elem, list):
|
||||
@@ -1542,17 +1593,17 @@ def _save_config_rule_file(file_name=_file_path):
|
||||
}
|
||||
# Save only user-defined rules
|
||||
rules_to_save = sum((r.data()["Rule"] for r in rules.components if r.source == file_name), [])
|
||||
if True: # save even if there are no rules to save
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("saving %d rule(s) to %s", len(rules_to_save), file_name)
|
||||
try:
|
||||
with open(file_name, "w") as f:
|
||||
if rules_to_save:
|
||||
f.write("%YAML 1.3\n") # Write version manually
|
||||
_yaml_dump_all(convert([r["Rule"] for r in rules_to_save]), f, **dump_settings)
|
||||
except Exception as e:
|
||||
logger.error("failed to save to %s\n%s", file_name, e)
|
||||
return False
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("saving %d rule(s) to %s", len(rules_to_save), file_name)
|
||||
try:
|
||||
with open(file_name, "w") as f:
|
||||
if rules_to_save:
|
||||
f.write("%YAML 1.3\n") # Write version manually
|
||||
dump_data = [r["Rule"] for r in rules_to_save]
|
||||
yaml.dump_all(convert(dump_data), f, **dump_settings)
|
||||
except Exception as e:
|
||||
logger.error("failed to save to %s\n%s", file_name, e)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -1560,7 +1611,7 @@ def load_config_rule_file():
|
||||
"""Loads user configured rules."""
|
||||
global rules
|
||||
|
||||
if _path.isfile(_file_path):
|
||||
if os.path.isfile(_file_path):
|
||||
rules = _load_rule_config(_file_path)
|
||||
|
||||
|
||||
@@ -1569,7 +1620,7 @@ def _load_rule_config(file_path: str) -> Rule:
|
||||
try:
|
||||
with open(file_path) as config_file:
|
||||
loaded_rules = []
|
||||
for loaded_rule in _yaml_safe_load_all(config_file):
|
||||
for loaded_rule in yaml.safe_load_all(config_file):
|
||||
rule = Rule(loaded_rule, source=file_path)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("load rule: %s", rule)
|
||||
|
||||
@@ -15,14 +15,12 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
from .common import KwException as _KwException
|
||||
from .common import KwException
|
||||
|
||||
#
|
||||
# Exceptions that may be raised by this API.
|
||||
#
|
||||
"""Exceptions that may be raised by this API."""
|
||||
|
||||
|
||||
class NoReceiver(_KwException):
|
||||
class NoReceiver(KwException):
|
||||
"""Raised when trying to talk through a previously open handle, when the
|
||||
receiver is no longer available. Should only happen if the receiver is
|
||||
physically disconnected from the machine, or its kernel driver module is
|
||||
@@ -31,25 +29,25 @@ class NoReceiver(_KwException):
|
||||
pass
|
||||
|
||||
|
||||
class NoSuchDevice(_KwException):
|
||||
class NoSuchDevice(KwException):
|
||||
"""Raised when trying to reach a device number not paired to the receiver."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DeviceUnreachable(_KwException):
|
||||
class DeviceUnreachable(KwException):
|
||||
"""Raised when a request is made to an unreachable (turned off) device."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class FeatureNotSupported(_KwException):
|
||||
class FeatureNotSupported(KwException):
|
||||
"""Raised when trying to request a feature not supported by the device."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class FeatureCallError(_KwException):
|
||||
class FeatureCallError(KwException):
|
||||
"""Raised if the device replied to a feature call with an error."""
|
||||
|
||||
pass
|
||||
|
||||
@@ -13,40 +13,75 @@
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from .common import Battery as _Battery
|
||||
from .common import FirmwareInfo as _FirmwareInfo
|
||||
from .common import bytes2int as _bytes2int
|
||||
from .common import int2bytes as _int2bytes
|
||||
from .common import strhex as _strhex
|
||||
from .hidpp10_constants import REGISTERS
|
||||
from .hidpp20_constants import FIRMWARE_KIND
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import Protocol
|
||||
|
||||
from . import common
|
||||
from .common import Battery
|
||||
from .common import BatteryLevelApproximation
|
||||
from .common import BatteryStatus
|
||||
from .common import FirmwareKind
|
||||
from .hidpp10_constants import NotificationFlag
|
||||
from .hidpp10_constants import Registers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#
|
||||
# functions
|
||||
#
|
||||
|
||||
class Device(Protocol):
|
||||
def request(self, request_id, *params):
|
||||
...
|
||||
|
||||
@property
|
||||
def kind(self) -> Any:
|
||||
...
|
||||
|
||||
@property
|
||||
def online(self) -> bool:
|
||||
...
|
||||
|
||||
@property
|
||||
def protocol(self) -> Any:
|
||||
...
|
||||
|
||||
@property
|
||||
def registers(self) -> list:
|
||||
...
|
||||
|
||||
|
||||
def read_register(device, register_number, *params):
|
||||
assert device is not None, f"tried to read register {register_number:02X} from invalid device {device}"
|
||||
def read_register(device: Device, register: Registers | int, *params) -> Any:
|
||||
assert device is not None, f"tried to read register {register:02X} from invalid device {device}"
|
||||
# support long registers by adding a 2 in front of the register number
|
||||
request_id = 0x8100 | (int(register_number) & 0x2FF)
|
||||
request_id = 0x8100 | (int(register) & 0x2FF)
|
||||
return device.request(request_id, *params)
|
||||
|
||||
|
||||
def write_register(device, register_number, *value):
|
||||
assert device is not None, f"tried to write register {register_number:02X} to invalid device {device}"
|
||||
def write_register(device: Device, register: Registers | int, *value) -> Any:
|
||||
assert device is not None, f"tried to write register {register:02X} to invalid device {device}"
|
||||
# support long registers by adding a 2 in front of the register number
|
||||
request_id = 0x8000 | (int(register_number) & 0x2FF)
|
||||
request_id = 0x8000 | (int(register) & 0x2FF)
|
||||
return device.request(request_id, *value)
|
||||
|
||||
|
||||
def get_configuration_pending_flags(receiver):
|
||||
assert not receiver.isDevice
|
||||
result = read_register(receiver, Registers.DEVICES_CONFIGURATION)
|
||||
if result is not None:
|
||||
return ord(result[:1])
|
||||
|
||||
|
||||
def set_configuration_pending_flags(receiver, devices):
|
||||
assert not receiver.isDevice
|
||||
result = write_register(receiver, Registers.DEVICES_CONFIGURATION, devices)
|
||||
return result is not None
|
||||
|
||||
|
||||
class Hidpp10:
|
||||
def get_battery(self, device):
|
||||
def get_battery(self, device: Device):
|
||||
assert device is not None
|
||||
assert device.kind is not None
|
||||
if not device.online:
|
||||
@@ -56,7 +91,7 @@ class Hidpp10:
|
||||
# let's just assume HID++ 2.0 devices do not provide the battery info in a register
|
||||
return
|
||||
|
||||
for r in (REGISTERS.battery_status, REGISTERS.battery_charge):
|
||||
for r in (Registers.BATTERY_STATUS, Registers.BATTERY_CHARGE):
|
||||
if r in device.registers:
|
||||
reply = read_register(device, r)
|
||||
if reply:
|
||||
@@ -64,74 +99,74 @@ class Hidpp10:
|
||||
return
|
||||
|
||||
# the descriptor does not tell us which register this device has, try them both
|
||||
reply = read_register(device, REGISTERS.battery_charge)
|
||||
reply = read_register(device, Registers.BATTERY_CHARGE)
|
||||
if reply:
|
||||
# remember this for the next time
|
||||
device.registers.append(REGISTERS.battery_charge)
|
||||
return parse_battery_status(REGISTERS.battery_charge, reply)
|
||||
device.registers.append(Registers.BATTERY_CHARGE)
|
||||
return parse_battery_status(Registers.BATTERY_CHARGE, reply)
|
||||
|
||||
reply = read_register(device, REGISTERS.battery_status)
|
||||
reply = read_register(device, Registers.BATTERY_STATUS)
|
||||
if reply:
|
||||
# remember this for the next time
|
||||
device.registers.append(REGISTERS.battery_status)
|
||||
return parse_battery_status(REGISTERS.battery_status, reply)
|
||||
device.registers.append(Registers.BATTERY_STATUS)
|
||||
return parse_battery_status(Registers.BATTERY_STATUS, reply)
|
||||
|
||||
def get_firmware(self, device):
|
||||
def get_firmware(self, device: Device) -> tuple[common.FirmwareInfo] | None:
|
||||
assert device is not None
|
||||
|
||||
firmware = [None, None, None]
|
||||
|
||||
reply = read_register(device, REGISTERS.firmware, 0x01)
|
||||
reply = read_register(device, Registers.FIRMWARE, 0x01)
|
||||
if not reply:
|
||||
# won't be able to read any of it now...
|
||||
return
|
||||
|
||||
fw_version = _strhex(reply[1:3])
|
||||
fw_version = common.strhex(reply[1:3])
|
||||
fw_version = f"{fw_version[0:2]}.{fw_version[2:4]}"
|
||||
reply = read_register(device, REGISTERS.firmware, 0x02)
|
||||
reply = read_register(device, Registers.FIRMWARE, 0x02)
|
||||
if reply:
|
||||
fw_version += ".B" + _strhex(reply[1:3])
|
||||
fw = _FirmwareInfo(FIRMWARE_KIND.Firmware, "", fw_version, None)
|
||||
fw_version += ".B" + common.strhex(reply[1:3])
|
||||
fw = common.FirmwareInfo(FirmwareKind.Firmware, "", fw_version, None)
|
||||
firmware[0] = fw
|
||||
|
||||
reply = read_register(device, REGISTERS.firmware, 0x04)
|
||||
reply = read_register(device, Registers.FIRMWARE, 0x04)
|
||||
if reply:
|
||||
bl_version = _strhex(reply[1:3])
|
||||
bl_version = common.strhex(reply[1:3])
|
||||
bl_version = f"{bl_version[0:2]}.{bl_version[2:4]}"
|
||||
bl = _FirmwareInfo(FIRMWARE_KIND.Bootloader, "", bl_version, None)
|
||||
bl = common.FirmwareInfo(FirmwareKind.Bootloader, "", bl_version, None)
|
||||
firmware[1] = bl
|
||||
|
||||
reply = read_register(device, REGISTERS.firmware, 0x03)
|
||||
reply = read_register(device, Registers.FIRMWARE, 0x03)
|
||||
if reply:
|
||||
o_version = _strhex(reply[1:3])
|
||||
o_version = common.strhex(reply[1:3])
|
||||
o_version = f"{o_version[0:2]}.{o_version[2:4]}"
|
||||
o = _FirmwareInfo(FIRMWARE_KIND.Other, "", o_version, None)
|
||||
o = common.FirmwareInfo(FirmwareKind.Other, "", o_version, None)
|
||||
firmware[2] = o
|
||||
|
||||
if any(firmware):
|
||||
return tuple(f for f in firmware if f)
|
||||
|
||||
def set_3leds(self, device, battery_level=None, charging=None, warning=None):
|
||||
def set_3leds(self, device: Device, battery_level=None, charging=None, warning=None):
|
||||
assert device is not None
|
||||
assert device.kind is not None
|
||||
if not device.online:
|
||||
return
|
||||
|
||||
if REGISTERS.three_leds not in device.registers:
|
||||
if Registers.THREE_LEDS not in device.registers:
|
||||
return
|
||||
|
||||
if battery_level is not None:
|
||||
if battery_level < _Battery.APPROX.critical:
|
||||
if battery_level < BatteryLevelApproximation.CRITICAL:
|
||||
# 1 orange, and force blink
|
||||
v1, v2 = 0x22, 0x00
|
||||
warning = True
|
||||
elif battery_level < _Battery.APPROX.low:
|
||||
elif battery_level < BatteryLevelApproximation.LOW:
|
||||
# 1 orange
|
||||
v1, v2 = 0x22, 0x00
|
||||
elif battery_level < _Battery.APPROX.good:
|
||||
elif battery_level < BatteryLevelApproximation.GOOD:
|
||||
# 1 green
|
||||
v1, v2 = 0x20, 0x00
|
||||
elif battery_level < _Battery.APPROX.full:
|
||||
elif battery_level < BatteryLevelApproximation.FULL:
|
||||
# 2 greens
|
||||
v1, v2 = 0x20, 0x02
|
||||
else:
|
||||
@@ -151,12 +186,14 @@ class Hidpp10:
|
||||
# turn off all leds
|
||||
v1, v2 = 0x11, 0x11
|
||||
|
||||
write_register(device, REGISTERS.three_leds, v1, v2)
|
||||
write_register(device, Registers.THREE_LEDS, v1, v2)
|
||||
|
||||
def get_notification_flags(self, device):
|
||||
return self._get_register(device, REGISTERS.notifications)
|
||||
def get_notification_flags(self, device: Device):
|
||||
flags = self._get_register(device, Registers.NOTIFICATIONS)
|
||||
if flags is not None:
|
||||
return NotificationFlag(flags)
|
||||
|
||||
def set_notification_flags(self, device, *flag_bits):
|
||||
def set_notification_flags(self, device: Device, *flag_bits: NotificationFlag):
|
||||
assert device is not None
|
||||
|
||||
# Avoid a call if the device is not online,
|
||||
@@ -166,15 +203,15 @@ class Hidpp10:
|
||||
if device.protocol and device.protocol >= 2.0:
|
||||
return
|
||||
|
||||
flag_bits = sum(int(b) for b in flag_bits)
|
||||
flag_bits = sum(int(b.value) for b in flag_bits)
|
||||
assert flag_bits & 0x00FFFFFF == flag_bits
|
||||
result = write_register(device, REGISTERS.notifications, _int2bytes(flag_bits, 3))
|
||||
result = write_register(device, Registers.NOTIFICATIONS, common.int2bytes(flag_bits, 3))
|
||||
return result is not None
|
||||
|
||||
def get_device_features(self, device):
|
||||
return self._get_register(device, REGISTERS.mouse_button_flags)
|
||||
def get_device_features(self, device: Device):
|
||||
return self._get_register(device, Registers.MOUSE_BUTTON_FLAGS)
|
||||
|
||||
def _get_register(self, device, register):
|
||||
def _get_register(self, device: Device, register: Registers | int):
|
||||
assert device is not None
|
||||
|
||||
# Avoid a call if the device is not online,
|
||||
@@ -187,64 +224,64 @@ class Hidpp10:
|
||||
flags = read_register(device, register)
|
||||
if flags is not None:
|
||||
assert len(flags) == 3
|
||||
return _bytes2int(flags)
|
||||
|
||||
def get_configuration_pending_flags(self, receiver):
|
||||
assert not receiver.isDevice
|
||||
result = read_register(receiver, REGISTERS.devices_configuration)
|
||||
if result is not None:
|
||||
return ord(result[:1])
|
||||
|
||||
def set_configuration_pending_flags(self, receiver, devices):
|
||||
assert not receiver.isDevice
|
||||
result = write_register(receiver, REGISTERS.devices_configuration, devices)
|
||||
return result is not None
|
||||
return common.bytes2int(flags)
|
||||
|
||||
|
||||
def parse_battery_status(register, reply):
|
||||
if register == REGISTERS.battery_charge:
|
||||
def parse_battery_status(register: Registers | int, reply) -> Battery | None:
|
||||
def status_byte_to_charge(status_byte_: int) -> BatteryLevelApproximation:
|
||||
if status_byte_ == 7:
|
||||
charge_ = BatteryLevelApproximation.FULL
|
||||
elif status_byte_ == 5:
|
||||
charge_ = BatteryLevelApproximation.GOOD
|
||||
elif status_byte_ == 3:
|
||||
charge_ = BatteryLevelApproximation.LOW
|
||||
elif status_byte_ == 1:
|
||||
charge_ = BatteryLevelApproximation.CRITICAL
|
||||
else:
|
||||
# pure 'charging' notifications may come without a status
|
||||
charge_ = BatteryLevelApproximation.EMPTY
|
||||
return charge_
|
||||
|
||||
def status_byte_to_battery_status(status_byte_: int) -> BatteryStatus:
|
||||
if status_byte_ == 0x30:
|
||||
status_text_ = BatteryStatus.DISCHARGING
|
||||
elif status_byte_ == 0x50:
|
||||
status_text_ = BatteryStatus.RECHARGING
|
||||
elif status_byte_ == 0x90:
|
||||
status_text_ = BatteryStatus.FULL
|
||||
else:
|
||||
status_text_ = None
|
||||
return status_text_
|
||||
|
||||
def charging_byte_to_status_text(charging_byte_: int) -> BatteryStatus:
|
||||
if charging_byte_ == 0x00:
|
||||
status_text_ = BatteryStatus.DISCHARGING
|
||||
elif charging_byte_ & 0x21 == 0x21:
|
||||
status_text_ = BatteryStatus.RECHARGING
|
||||
elif charging_byte_ & 0x22 == 0x22:
|
||||
status_text_ = BatteryStatus.FULL
|
||||
else:
|
||||
logger.warning("could not parse 0x07 battery status: %02X (level %02X)", charging_byte_, status_byte)
|
||||
status_text_ = None
|
||||
return status_text_
|
||||
|
||||
if register == Registers.BATTERY_CHARGE:
|
||||
charge = ord(reply[:1])
|
||||
status_byte = ord(reply[2:3]) & 0xF0
|
||||
status_text = (
|
||||
_Battery.STATUS.discharging
|
||||
if status_byte == 0x30
|
||||
else _Battery.STATUS.recharging
|
||||
if status_byte == 0x50
|
||||
else _Battery.STATUS.full
|
||||
if status_byte == 0x90
|
||||
else None
|
||||
)
|
||||
return _Battery(charge, None, status_text, None)
|
||||
|
||||
if register == REGISTERS.battery_status:
|
||||
battery_status = status_byte_to_battery_status(status_byte)
|
||||
return Battery(charge, None, battery_status, None)
|
||||
|
||||
if register == Registers.BATTERY_STATUS:
|
||||
status_byte = ord(reply[:1])
|
||||
charge = (
|
||||
_Battery.APPROX.full
|
||||
if status_byte == 7 # full
|
||||
else _Battery.APPROX.good
|
||||
if status_byte == 5 # good
|
||||
else _Battery.APPROX.low
|
||||
if status_byte == 3 # low
|
||||
else _Battery.APPROX.critical
|
||||
if status_byte == 1 # critical
|
||||
# pure 'charging' notifications may come without a status
|
||||
else _Battery.APPROX.empty
|
||||
)
|
||||
|
||||
charging_byte = ord(reply[1:2])
|
||||
if charging_byte == 0x00:
|
||||
status_text = _Battery.STATUS.discharging
|
||||
elif charging_byte & 0x21 == 0x21:
|
||||
status_text = _Battery.STATUS.recharging
|
||||
elif charging_byte & 0x22 == 0x22:
|
||||
status_text = _Battery.STATUS.full
|
||||
else:
|
||||
logger.warning("could not parse 0x07 battery status: %02X (level %02X)", charging_byte, status_byte)
|
||||
status_text = None
|
||||
|
||||
status_text = charging_byte_to_status_text(charging_byte)
|
||||
charge = status_byte_to_charge(status_byte)
|
||||
|
||||
if charging_byte & 0x03 and status_byte == 0:
|
||||
# some 'charging' notifications may come with no battery level information
|
||||
charge = None
|
||||
|
||||
# Return None for next charge level and voltage as these are not in HID++ 1.0 spec
|
||||
return _Battery(charge, None, status_text, None)
|
||||
return Battery(charge, None, status_text, None)
|
||||
|
||||
@@ -14,13 +14,19 @@
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Flag
|
||||
from enum import IntEnum
|
||||
from typing import List
|
||||
|
||||
from .common import NamedInts
|
||||
|
||||
#
|
||||
# Constants - most of them as defined by the official Logitech HID++ 1.0
|
||||
# documentation, some of them guessed.
|
||||
#
|
||||
"""HID constants for HID++ 1.0.
|
||||
|
||||
Most of them as defined by the official Logitech HID++ 1.0
|
||||
documentation, some of them guessed.
|
||||
"""
|
||||
|
||||
DEVICE_KIND = NamedInts(
|
||||
unknown=0x00,
|
||||
@@ -39,129 +45,207 @@ DEVICE_KIND = NamedInts(
|
||||
receiver=0x0F, # for compatibility with HID++ 2.0
|
||||
)
|
||||
|
||||
POWER_SWITCH_LOCATION = NamedInts(
|
||||
base=0x01,
|
||||
top_case=0x02,
|
||||
edge_of_top_right_corner=0x03,
|
||||
top_left_corner=0x05,
|
||||
bottom_left_corner=0x06,
|
||||
top_right_corner=0x07,
|
||||
bottom_right_corner=0x08,
|
||||
top_edge=0x09,
|
||||
right_edge=0x0A,
|
||||
left_edge=0x0B,
|
||||
bottom_edge=0x0C,
|
||||
)
|
||||
|
||||
# Some flags are used both by devices and receivers. The Logitech documentation
|
||||
# mentions that the first and last (third) byte are used for devices while the
|
||||
# second is used for the receiver. In practise, the second byte is also used for
|
||||
# some device-specific notifications (keyboard illumination level). Do not
|
||||
# simply set all notification bits if the software does not support it. For
|
||||
# example, enabling keyboard_sleep_raw makes the Sleep key a no-operation unless
|
||||
# the software is updated to handle that event.
|
||||
# Observations:
|
||||
# - wireless and software present were seen on receivers, reserved_r1b4 as well
|
||||
# - the rest work only on devices as far as we can tell right now
|
||||
# In the future would be useful to have separate enums for receiver and device notification flags,
|
||||
# but right now we don't know enough.
|
||||
# additional flags taken from https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing
|
||||
NOTIFICATION_FLAG = NamedInts(
|
||||
numpad_numerical_keys=0x800000,
|
||||
f_lock_status=0x400000,
|
||||
roller_H=0x200000,
|
||||
battery_status=0x100000, # send battery charge notifications (0x07 or 0x0D)
|
||||
mouse_extra_buttons=0x080000,
|
||||
roller_V=0x040000,
|
||||
power_keys=0x020000, # system control keys such as Sleep
|
||||
keyboard_multimedia_raw=0x010000, # consumer controls such as Mute and Calculator
|
||||
multi_touch=0x001000, # notify on multi-touch changes
|
||||
software_present=0x000800, # software is controlling part of device behaviour
|
||||
link_quality=0x000400, # notify on link quality changes
|
||||
ui=0x000200, # notify on UI changes
|
||||
wireless=0x000100, # notify when the device wireless goes on/off-line
|
||||
configuration_complete=0x000004,
|
||||
voip_telephony=0x000002,
|
||||
threed_gesture=0x000001,
|
||||
)
|
||||
class PowerSwitchLocation(IntEnum):
|
||||
UNKNOWN = 0x00
|
||||
BASE = 0x01
|
||||
TOP_CASE = 0x02
|
||||
EDGE_OF_TOP_RIGHT_CORNER = 0x03
|
||||
TOP_LEFT_CORNER = 0x05
|
||||
BOTTOM_LEFT_CORNER = 0x06
|
||||
TOP_RIGHT_CORNER = 0x07
|
||||
BOTTOM_RIGHT_CORNER = 0x08
|
||||
TOP_EDGE = 0x09
|
||||
RIGHT_EDGE = 0x0A
|
||||
LEFT_EDGE = 0x0B
|
||||
BOTTOM_EDGE = 0x0C
|
||||
|
||||
ERROR = NamedInts(
|
||||
invalid_SubID__command=0x01,
|
||||
invalid_address=0x02,
|
||||
invalid_value=0x03,
|
||||
connection_request_failed=0x04,
|
||||
too_many_devices=0x05,
|
||||
already_exists=0x06,
|
||||
busy=0x07,
|
||||
unknown_device=0x08,
|
||||
resource_error=0x09,
|
||||
request_unavailable=0x0A,
|
||||
unsupported_parameter_value=0x0B,
|
||||
wrong_pin_code=0x0C,
|
||||
)
|
||||
@classmethod
|
||||
def location(cls, loc: int) -> PowerSwitchLocation:
|
||||
try:
|
||||
return cls(loc)
|
||||
except ValueError:
|
||||
return cls.UNKNOWN
|
||||
|
||||
|
||||
class NotificationFlag(Flag):
|
||||
"""Some flags are used both by devices and receivers.
|
||||
|
||||
The Logitech documentation mentions that the first and last (third)
|
||||
byte are used for devices while the second is used for the receiver.
|
||||
In practise, the second byte is also used for some device-specific
|
||||
notifications (keyboard illumination level). Do not simply set all
|
||||
notification bits if the software does not support it. For example,
|
||||
enabling keyboard_sleep_raw makes the Sleep key a no-operation
|
||||
unless the software is updated to handle that event.
|
||||
|
||||
Observations:
|
||||
- wireless and software present seen on receivers,
|
||||
reserved_r1b4 as well
|
||||
- the rest work only on devices as far as we can tell right now
|
||||
In the future would be useful to have separate enums for receiver
|
||||
and device notification flags, but right now we don't know enough.
|
||||
Additional flags taken from https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def flag_names(cls, flags) -> List[str]:
|
||||
"""Extract the names of the flags from the integer."""
|
||||
return flags.name.replace("_", " ").lower().split("|")
|
||||
|
||||
NUMPAD_NUMERICAL_KEYS = 0x800000
|
||||
F_LOCK_STATUS = 0x400000
|
||||
ROLLER_H = 0x200000
|
||||
BATTERY_STATUS = 0x100000 # send battery charge notifications (0x07 or 0x0D)
|
||||
MOUSE_EXTRA_BUTTONS = 0x080000
|
||||
ROLLER_V = 0x040000
|
||||
POWER_KEYS = 0x020000 # system control keys such as Sleep
|
||||
KEYBOARD_MULTIMEDIA_RAW = 0x010000 # consumer controls such as Mute and Calculator
|
||||
MULTI_TOUCH = 0x001000 # notify on multi-touch changes
|
||||
SOFTWARE_PRESENT = 0x000800 # software is controlling part of device behaviour
|
||||
LINK_QUALITY = 0x000400 # notify on link quality changes
|
||||
UI = 0x000200 # notify on UI changes
|
||||
WIRELESS = 0x000100 # notify when the device wireless goes on/off-line
|
||||
CONFIGURATION_COMPLETE = 0x000004
|
||||
VOIP_TELEPHONY = 0x000002
|
||||
THREED_GESTURE = 0x000001
|
||||
|
||||
|
||||
def flags_to_str(flags, fallback: str) -> str:
|
||||
flag_names = []
|
||||
if flags is not None and flags is not False:
|
||||
if flags.value == 0:
|
||||
flag_names = (fallback,)
|
||||
else:
|
||||
flag_names = NotificationFlag.flag_names(flags)
|
||||
return f"\n{' ':15}".join(sorted(flag_names))
|
||||
|
||||
|
||||
class ErrorCode(IntEnum):
|
||||
INVALID_SUB_ID_COMMAND = 0x01
|
||||
INVALID_ADDRESS = 0x02
|
||||
INVALID_VALUE = 0x03
|
||||
CONNECTION_REQUEST_FAILED = 0x04
|
||||
TOO_MANY_DEVICES = 0x05
|
||||
ALREADY_EXISTS = 0x06
|
||||
BUSY = 0x07
|
||||
UNKNOWN_DEVICE = 0x08
|
||||
RESOURCE_ERROR = 0x09
|
||||
REQUEST_UNAVAILABLE = 0x0A
|
||||
UNSUPPORTED_PARAMETER_VALUE = 0x0B
|
||||
WRONG_PIN_CODE = 0x0C
|
||||
|
||||
|
||||
class PairingError(IntEnum):
|
||||
DEVICE_TIMEOUT = 0x01
|
||||
DEVICE_NOT_SUPPORTED = 0x02
|
||||
TOO_MANY_DEVICES = 0x03
|
||||
SEQUENCE_TIMEOUT = 0x06
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.name.lower().replace("_", " ")
|
||||
|
||||
|
||||
class BoltPairingError(IntEnum):
|
||||
DEVICE_TIMEOUT = 0x01
|
||||
FAILED = 0x02
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.name.lower().replace("_", " ")
|
||||
|
||||
|
||||
class Registers(IntEnum):
|
||||
"""Known HID registers.
|
||||
|
||||
Devices usually have a (small) sub-set of these. Some registers are only
|
||||
applicable to certain device kinds (e.g. smooth_scroll only applies to mice).
|
||||
"""
|
||||
|
||||
# Generally applicable
|
||||
NOTIFICATIONS = 0x00
|
||||
FIRMWARE = 0xF1
|
||||
|
||||
PAIRING_ERRORS = NamedInts(device_timeout=0x01, device_not_supported=0x02, too_many_devices=0x03, sequence_timeout=0x06)
|
||||
BOLT_PAIRING_ERRORS = NamedInts(device_timeout=0x01, failed=0x02)
|
||||
"""Known registers.
|
||||
Devices usually have a (small) sub-set of these. Some registers are only
|
||||
applicable to certain device kinds (e.g. smooth_scroll only applies to mice."""
|
||||
REGISTERS = NamedInts(
|
||||
# only apply to receivers
|
||||
receiver_connection=0x02,
|
||||
receiver_pairing=0xB2,
|
||||
devices_activity=0x2B3,
|
||||
receiver_info=0x2B5,
|
||||
bolt_device_discovery=0xC0,
|
||||
bolt_pairing=0x2C1,
|
||||
bolt_uniqueId=0x02FB,
|
||||
# only apply to devices
|
||||
mouse_button_flags=0x01,
|
||||
keyboard_hand_detection=0x01,
|
||||
devices_configuration=0x03,
|
||||
battery_status=0x07,
|
||||
keyboard_fn_swap=0x09,
|
||||
battery_charge=0x0D,
|
||||
keyboard_illumination=0x17,
|
||||
three_leds=0x51,
|
||||
mouse_dpi=0x63,
|
||||
# apply to both
|
||||
notifications=0x00,
|
||||
firmware=0xF1,
|
||||
# notifications
|
||||
passkey_request_notification=0x4D,
|
||||
passkey_pressed_notification=0x4E,
|
||||
device_discovery_notification=0x4F,
|
||||
discovery_status_notification=0x53,
|
||||
pairing_status_notification=0x54,
|
||||
)
|
||||
# Subregisters for receiver_info register
|
||||
INFO_SUBREGISTERS = NamedInts(
|
||||
serial_number=0x01, # not found on many receivers
|
||||
fw_version=0x02,
|
||||
receiver_information=0x03,
|
||||
pairing_information=0x20, # 0x2N, by connected device
|
||||
extended_pairing_information=0x30, # 0x3N, by connected device
|
||||
device_name=0x40, # 0x4N, by connected device
|
||||
bolt_pairing_information=0x50, # 0x5N, by connected device
|
||||
bolt_device_name=0x60, # 0x6N01, by connected device,
|
||||
)
|
||||
RECEIVER_CONNECTION = 0x02
|
||||
RECEIVER_PAIRING = 0xB2
|
||||
DEVICES_ACTIVITY = 0x2B3
|
||||
RECEIVER_INFO = 0x2B5
|
||||
BOLT_DEVICE_DISCOVERY = 0xC0
|
||||
BOLT_PAIRING = 0x2C1
|
||||
BOLT_UNIQUE_ID = 0x02FB
|
||||
|
||||
# Flags taken from https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing
|
||||
DEVICE_FEATURES = NamedInts(
|
||||
reserved1=0x010000,
|
||||
special_buttons=0x020000,
|
||||
enhanced_key_usage=0x040000,
|
||||
fast_fw_rev=0x080000,
|
||||
reserved2=0x100000,
|
||||
reserved3=0x200000,
|
||||
scroll_accel=0x400000,
|
||||
buttons_control_resolution=0x800000,
|
||||
inhibit_lock_key_sound=0x000001,
|
||||
reserved4=0x000002,
|
||||
mx_air_3d_engine=0x000004,
|
||||
host_control_leds=0x000008,
|
||||
reserved5=0x000010,
|
||||
reserved6=0x000020,
|
||||
reserved7=0x000040,
|
||||
reserved8=0x000080,
|
||||
)
|
||||
# only apply to devices
|
||||
MOUSE_BUTTON_FLAGS = 0x01
|
||||
KEYBOARD_HAND_DETECTION = 0x01
|
||||
DEVICES_CONFIGURATION = 0x03
|
||||
BATTERY_STATUS = 0x07
|
||||
KEYBOARD_FN_SWAP = 0x09
|
||||
BATTERY_CHARGE = 0x0D
|
||||
KEYBOARD_ILLUMINATION = 0x17
|
||||
THREE_LEDS = 0x51
|
||||
MOUSE_DPI = 0x63
|
||||
|
||||
# notifications
|
||||
PASSKEY_REQUEST_NOTIFICATION = 0x4D
|
||||
PASSKEY_PRESSED_NOTIFICATION = 0x4E
|
||||
DEVICE_DISCOVERY_NOTIFICATION = 0x4F
|
||||
DISCOVERY_STATUS_NOTIFICATION = 0x53
|
||||
PAIRING_STATUS_NOTIFICATION = 0x54
|
||||
|
||||
|
||||
# Subregisters for receiver_info register
|
||||
class InfoSubRegisters(IntEnum):
|
||||
SERIAL_NUMBER = 0x01 # not found on many receivers
|
||||
FW_VERSION = 0x02
|
||||
RECEIVER_INFORMATION = 0x03
|
||||
PAIRING_INFORMATION = 0x20 # 0x2N, by connected device
|
||||
EXTENDED_PAIRING_INFORMATION = 0x30 # 0x3N, by connected device
|
||||
DEVICE_NAME = 0x40 # 0x4N, by connected device
|
||||
BOLT_PAIRING_INFORMATION = 0x50 # 0x5N, by connected device
|
||||
BOLT_DEVICE_NAME = 0x60 # 0x6N01, by connected device
|
||||
|
||||
|
||||
class DeviceFeature(Flag):
|
||||
"""Features for devices.
|
||||
|
||||
Flags taken from
|
||||
https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def flag_names(cls, flag_bits: int) -> List[str]:
|
||||
"""Extract the names of the flags from the integer."""
|
||||
indexed = {item.value: item.name for item in cls}
|
||||
|
||||
flag_names = []
|
||||
unknown_bits = flag_bits
|
||||
for k in indexed:
|
||||
# Ensure that the key (flag value) is a power of 2 (a single bit flag)
|
||||
assert bin(k).count("1") == 1
|
||||
if k & flag_bits == k:
|
||||
unknown_bits &= ~k
|
||||
flag_names.append(indexed[k].replace("_", " ").lower())
|
||||
|
||||
# Yield any remaining unknown bits
|
||||
if unknown_bits != 0:
|
||||
flag_names.append(f"unknown:{unknown_bits:06X}")
|
||||
return flag_names
|
||||
|
||||
RESERVED1 = 0x010000
|
||||
SPECIAL_BUTTONS = 0x020000
|
||||
ENHANCED_KEY_USAGE = 0x040000
|
||||
FAST_FW_REV = 0x080000
|
||||
RESERVED2 = 0x100000
|
||||
RESERVED3 = 0x200000
|
||||
SCROLL_ACCEL = 0x400000
|
||||
BUTTONS_CONTROL_RESOLUTION = 0x800000
|
||||
INHIBIT_LOCK_KEY_SOUND = 0x000001
|
||||
RESERVED4 = 0x000002
|
||||
MX_AIR_3D_ENGINE = 0x000004
|
||||
HOST_CONTROL_LEDS = 0x000008
|
||||
RESERVED5 = 0x000010
|
||||
RESERVED6 = 0x000020
|
||||
RESERVED7 = 0x000040
|
||||
RESERVED8 = 0x000080
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
from enum import IntEnum
|
||||
from enum import IntFlag
|
||||
|
||||
from .common import NamedInts
|
||||
|
||||
@@ -25,226 +27,274 @@ from .common import NamedInts
|
||||
A particular device might not support all these features, and may support other
|
||||
unknown features as well.
|
||||
"""
|
||||
FEATURE = NamedInts(
|
||||
ROOT=0x0000,
|
||||
FEATURE_SET=0x0001,
|
||||
FEATURE_INFO=0x0002,
|
||||
# Common
|
||||
DEVICE_FW_VERSION=0x0003,
|
||||
DEVICE_UNIT_ID=0x0004,
|
||||
DEVICE_NAME=0x0005,
|
||||
DEVICE_GROUPS=0x0006,
|
||||
DEVICE_FRIENDLY_NAME=0x0007,
|
||||
KEEP_ALIVE=0x0008,
|
||||
CONFIG_CHANGE=0x0020,
|
||||
CRYPTO_ID=0x0021,
|
||||
TARGET_SOFTWARE=0x0030,
|
||||
WIRELESS_SIGNAL_STRENGTH=0x0080,
|
||||
DFUCONTROL_LEGACY=0x00C0,
|
||||
DFUCONTROL_UNSIGNED=0x00C1,
|
||||
DFUCONTROL_SIGNED=0x00C2,
|
||||
DFUCONTROL=0x00C3,
|
||||
DFU=0x00D0,
|
||||
BATTERY_STATUS=0x1000,
|
||||
BATTERY_VOLTAGE=0x1001,
|
||||
UNIFIED_BATTERY=0x1004,
|
||||
CHARGING_CONTROL=0x1010,
|
||||
LED_CONTROL=0x1300,
|
||||
FORCE_PAIRING=0x1500,
|
||||
GENERIC_TEST=0x1800,
|
||||
DEVICE_RESET=0x1802,
|
||||
OOBSTATE=0x1805,
|
||||
CONFIG_DEVICE_PROPS=0x1806,
|
||||
CHANGE_HOST=0x1814,
|
||||
HOSTS_INFO=0x1815,
|
||||
BACKLIGHT=0x1981,
|
||||
BACKLIGHT2=0x1982,
|
||||
BACKLIGHT3=0x1983,
|
||||
ILLUMINATION=0x1990,
|
||||
PRESENTER_CONTROL=0x1A00,
|
||||
SENSOR_3D=0x1A01,
|
||||
REPROG_CONTROLS=0x1B00,
|
||||
REPROG_CONTROLS_V2=0x1B01,
|
||||
REPROG_CONTROLS_V2_2=0x1B02, # LogiOptions 2.10.73 features.xml
|
||||
REPROG_CONTROLS_V3=0x1B03,
|
||||
REPROG_CONTROLS_V4=0x1B04,
|
||||
REPORT_HID_USAGE=0x1BC0,
|
||||
PERSISTENT_REMAPPABLE_ACTION=0x1C00,
|
||||
WIRELESS_DEVICE_STATUS=0x1D4B,
|
||||
REMAINING_PAIRING=0x1DF0,
|
||||
FIRMWARE_PROPERTIES=0x1F1F,
|
||||
ADC_MEASUREMENT=0x1F20,
|
||||
# Mouse
|
||||
LEFT_RIGHT_SWAP=0x2001,
|
||||
SWAP_BUTTON_CANCEL=0x2005,
|
||||
POINTER_AXIS_ORIENTATION=0x2006,
|
||||
VERTICAL_SCROLLING=0x2100,
|
||||
SMART_SHIFT=0x2110,
|
||||
SMART_SHIFT_ENHANCED=0x2111,
|
||||
HI_RES_SCROLLING=0x2120,
|
||||
HIRES_WHEEL=0x2121,
|
||||
LOWRES_WHEEL=0x2130,
|
||||
THUMB_WHEEL=0x2150,
|
||||
MOUSE_POINTER=0x2200,
|
||||
ADJUSTABLE_DPI=0x2201,
|
||||
EXTENDED_ADJUSTABLE_DPI=0x2202,
|
||||
POINTER_SPEED=0x2205,
|
||||
ANGLE_SNAPPING=0x2230,
|
||||
SURFACE_TUNING=0x2240,
|
||||
XY_STATS=0x2250,
|
||||
WHEEL_STATS=0x2251,
|
||||
HYBRID_TRACKING=0x2400,
|
||||
# Keyboard
|
||||
FN_INVERSION=0x40A0,
|
||||
NEW_FN_INVERSION=0x40A2,
|
||||
K375S_FN_INVERSION=0x40A3,
|
||||
ENCRYPTION=0x4100,
|
||||
LOCK_KEY_STATE=0x4220,
|
||||
SOLAR_DASHBOARD=0x4301,
|
||||
KEYBOARD_LAYOUT=0x4520,
|
||||
KEYBOARD_DISABLE_KEYS=0x4521,
|
||||
KEYBOARD_DISABLE_BY_USAGE=0x4522,
|
||||
DUALPLATFORM=0x4530,
|
||||
MULTIPLATFORM=0x4531,
|
||||
KEYBOARD_LAYOUT_2=0x4540,
|
||||
CROWN=0x4600,
|
||||
# Touchpad
|
||||
TOUCHPAD_FW_ITEMS=0x6010,
|
||||
TOUCHPAD_SW_ITEMS=0x6011,
|
||||
TOUCHPAD_WIN8_FW_ITEMS=0x6012,
|
||||
TAP_ENABLE=0x6020,
|
||||
TAP_ENABLE_EXTENDED=0x6021,
|
||||
CURSOR_BALLISTIC=0x6030,
|
||||
TOUCHPAD_RESOLUTION=0x6040,
|
||||
TOUCHPAD_RAW_XY=0x6100,
|
||||
TOUCHMOUSE_RAW_POINTS=0x6110,
|
||||
TOUCHMOUSE_6120=0x6120,
|
||||
GESTURE=0x6500,
|
||||
GESTURE_2=0x6501,
|
||||
# Gaming Devices
|
||||
GKEY=0x8010,
|
||||
MKEYS=0x8020,
|
||||
MR=0x8030,
|
||||
BRIGHTNESS_CONTROL=0x8040,
|
||||
REPORT_RATE=0x8060,
|
||||
EXTENDED_ADJUSTABLE_REPORT_RATE=0x8061,
|
||||
COLOR_LED_EFFECTS=0x8070,
|
||||
RGB_EFFECTS=0x8071,
|
||||
PER_KEY_LIGHTING=0x8080,
|
||||
PER_KEY_LIGHTING_V2=0x8081,
|
||||
MODE_STATUS=0x8090,
|
||||
ONBOARD_PROFILES=0x8100,
|
||||
MOUSE_BUTTON_SPY=0x8110,
|
||||
LATENCY_MONITORING=0x8111,
|
||||
GAMING_ATTACHMENTS=0x8120,
|
||||
FORCE_FEEDBACK=0x8123,
|
||||
# Headsets
|
||||
SIDETONE=0x8300,
|
||||
EQUALIZER=0x8310,
|
||||
HEADSET_OUT=0x8320,
|
||||
# Fake features for Solaar internal use
|
||||
MOUSE_GESTURE=0xFE00,
|
||||
)
|
||||
FEATURE._fallback = lambda x: f"unknown:{x:04X}"
|
||||
|
||||
FEATURE_FLAG = NamedInts(internal=0x20, hidden=0x40, obsolete=0x80)
|
||||
|
||||
class SupportedFeature(IntEnum):
|
||||
ROOT = 0x0000
|
||||
FEATURE_SET = 0x0001
|
||||
FEATURE_INFO = 0x0002
|
||||
# Common
|
||||
DEVICE_FW_VERSION = 0x0003
|
||||
DEVICE_UNIT_ID = 0x0004
|
||||
DEVICE_NAME = 0x0005
|
||||
DEVICE_GROUPS = 0x0006
|
||||
DEVICE_FRIENDLY_NAME = 0x0007
|
||||
KEEP_ALIVE = 0x0008
|
||||
CONFIG_CHANGE = 0x0020
|
||||
CRYPTO_ID = 0x0021
|
||||
TARGET_SOFTWARE = 0x0030
|
||||
WIRELESS_SIGNAL_STRENGTH = 0x0080
|
||||
DFUCONTROL_LEGACY = 0x00C0
|
||||
DFUCONTROL_UNSIGNED = 0x00C1
|
||||
DFUCONTROL_SIGNED = 0x00C2
|
||||
DFUCONTROL = 0x00C3
|
||||
DFU = 0x00D0
|
||||
BATTERY_STATUS = 0x1000
|
||||
BATTERY_VOLTAGE = 0x1001
|
||||
UNIFIED_BATTERY = 0x1004
|
||||
CHARGING_CONTROL = 0x1010
|
||||
LED_CONTROL = 0x1300
|
||||
FORCE_PAIRING = 0x1500
|
||||
GENERIC_TEST = 0x1800
|
||||
DEVICE_RESET = 0x1802
|
||||
OOBSTATE = 0x1805
|
||||
CONFIG_DEVICE_PROPS = 0x1806
|
||||
CHANGE_HOST = 0x1814
|
||||
HOSTS_INFO = 0x1815
|
||||
BACKLIGHT = 0x1981
|
||||
BACKLIGHT2 = 0x1982
|
||||
BACKLIGHT3 = 0x1983
|
||||
ILLUMINATION = 0x1990
|
||||
FORCE_SENSING_BUTTON = 0x19C0
|
||||
HAPTIC = 0x19B0
|
||||
PRESENTER_CONTROL = 0x1A00
|
||||
SENSOR_3D = 0x1A01
|
||||
REPROG_CONTROLS = 0x1B00
|
||||
REPROG_CONTROLS_V2 = 0x1B01
|
||||
REPROG_CONTROLS_V2_2 = 0x1B02 # LogiOptions 2.10.73 features.xml
|
||||
REPROG_CONTROLS_V3 = 0x1B03
|
||||
REPROG_CONTROLS_V4 = 0x1B04
|
||||
REPORT_HID_USAGE = 0x1BC0
|
||||
PERSISTENT_REMAPPABLE_ACTION = 0x1C00
|
||||
WIRELESS_DEVICE_STATUS = 0x1D4B
|
||||
REMAINING_PAIRING = 0x1DF0
|
||||
FIRMWARE_PROPERTIES = 0x1F1F
|
||||
ADC_MEASUREMENT = 0x1F20
|
||||
# Mouse
|
||||
LEFT_RIGHT_SWAP = 0x2001
|
||||
SWAP_BUTTON_CANCEL = 0x2005
|
||||
POINTER_AXIS_ORIENTATION = 0x2006
|
||||
VERTICAL_SCROLLING = 0x2100
|
||||
SMART_SHIFT = 0x2110
|
||||
SMART_SHIFT_ENHANCED = 0x2111
|
||||
HI_RES_SCROLLING = 0x2120
|
||||
HIRES_WHEEL = 0x2121
|
||||
LOWRES_WHEEL = 0x2130
|
||||
THUMB_WHEEL = 0x2150
|
||||
MOUSE_POINTER = 0x2200
|
||||
ADJUSTABLE_DPI = 0x2201
|
||||
EXTENDED_ADJUSTABLE_DPI = 0x2202
|
||||
POINTER_SPEED = 0x2205
|
||||
ANGLE_SNAPPING = 0x2230
|
||||
SURFACE_TUNING = 0x2240
|
||||
XY_STATS = 0x2250
|
||||
WHEEL_STATS = 0x2251
|
||||
HYBRID_TRACKING = 0x2400
|
||||
# Keyboard
|
||||
FN_INVERSION = 0x40A0
|
||||
NEW_FN_INVERSION = 0x40A2
|
||||
K375S_FN_INVERSION = 0x40A3
|
||||
ENCRYPTION = 0x4100
|
||||
LOCK_KEY_STATE = 0x4220
|
||||
SOLAR_DASHBOARD = 0x4301
|
||||
KEYBOARD_LAYOUT = 0x4520
|
||||
KEYBOARD_DISABLE_KEYS = 0x4521
|
||||
KEYBOARD_DISABLE_BY_USAGE = 0x4522
|
||||
DUALPLATFORM = 0x4530
|
||||
MULTIPLATFORM = 0x4531
|
||||
KEYBOARD_LAYOUT_2 = 0x4540
|
||||
CROWN = 0x4600
|
||||
# Touchpad
|
||||
TOUCHPAD_FW_ITEMS = 0x6010
|
||||
TOUCHPAD_SW_ITEMS = 0x6011
|
||||
TOUCHPAD_WIN8_FW_ITEMS = 0x6012
|
||||
TAP_ENABLE = 0x6020
|
||||
TAP_ENABLE_EXTENDED = 0x6021
|
||||
CURSOR_BALLISTIC = 0x6030
|
||||
TOUCHPAD_RESOLUTION = 0x6040
|
||||
TOUCHPAD_RAW_XY = 0x6100
|
||||
TOUCHMOUSE_RAW_POINTS = 0x6110
|
||||
TOUCHMOUSE_6120 = 0x6120
|
||||
GESTURE = 0x6500
|
||||
GESTURE_2 = 0x6501
|
||||
# Gaming Devices
|
||||
GKEY = 0x8010
|
||||
MKEYS = 0x8020
|
||||
MR = 0x8030
|
||||
BRIGHTNESS_CONTROL = 0x8040
|
||||
REPORT_RATE = 0x8060
|
||||
EXTENDED_ADJUSTABLE_REPORT_RATE = 0x8061
|
||||
COLOR_LED_EFFECTS = 0x8070
|
||||
RGB_EFFECTS = 0x8071
|
||||
PER_KEY_LIGHTING = 0x8080
|
||||
PER_KEY_LIGHTING_V2 = 0x8081
|
||||
MODE_STATUS = 0x8090
|
||||
ONBOARD_PROFILES = 0x8100
|
||||
MOUSE_BUTTON_SPY = 0x8110
|
||||
LATENCY_MONITORING = 0x8111
|
||||
GAMING_ATTACHMENTS = 0x8120
|
||||
FORCE_FEEDBACK = 0x8123
|
||||
# Headsets
|
||||
SIDETONE = 0x8300
|
||||
EQUALIZER = 0x8310
|
||||
HEADSET_OUT = 0x8320
|
||||
# Fake features for Solaar internal use
|
||||
MOUSE_GESTURE = 0xFE00
|
||||
|
||||
def __str__(self):
|
||||
return self.name.replace("_", " ")
|
||||
|
||||
|
||||
class FeatureFlag(IntFlag):
|
||||
"""Single bit flags."""
|
||||
|
||||
INTERNAL = 0x20
|
||||
HIDDEN = 0x40
|
||||
OBSOLETE = 0x80
|
||||
|
||||
|
||||
DEVICE_KIND = NamedInts(
|
||||
keyboard=0x00, remote_control=0x01, numpad=0x02, mouse=0x03, touchpad=0x04, trackball=0x05, presenter=0x06, receiver=0x07
|
||||
keyboard=0x00,
|
||||
remote_control=0x01,
|
||||
numpad=0x02,
|
||||
mouse=0x03,
|
||||
touchpad=0x04,
|
||||
trackball=0x05,
|
||||
presenter=0x06,
|
||||
receiver=0x07,
|
||||
)
|
||||
|
||||
FIRMWARE_KIND = NamedInts(Firmware=0x00, Bootloader=0x01, Hardware=0x02, Other=0x03)
|
||||
|
||||
ONBOARD_MODES = NamedInts(MODE_NO_CHANGE=0x00, MODE_ONBOARD=0x01, MODE_HOST=0x02)
|
||||
class OnboardMode(IntEnum):
|
||||
MODE_NO_CHANGE = 0x00
|
||||
MODE_ONBOARD = 0x01
|
||||
MODE_HOST = 0x02
|
||||
|
||||
CHARGE_STATUS = NamedInts(charging=0x00, full=0x01, not_charging=0x02, error=0x07)
|
||||
|
||||
CHARGE_LEVEL = NamedInts(average=50, full=90, critical=5)
|
||||
class ChargeLevel(IntEnum):
|
||||
AVERAGE = 50
|
||||
FULL = 90
|
||||
CRITICAL = 5
|
||||
|
||||
CHARGE_TYPE = NamedInts(standard=0x00, fast=0x01, slow=0x02)
|
||||
|
||||
ERROR = NamedInts(
|
||||
unknown=0x01,
|
||||
invalid_argument=0x02,
|
||||
out_of_range=0x03,
|
||||
hardware_error=0x04,
|
||||
logitech_internal=0x05,
|
||||
invalid_feature_index=0x06,
|
||||
invalid_function=0x07,
|
||||
busy=0x08,
|
||||
unsupported=0x09,
|
||||
class ChargeType(IntEnum):
|
||||
STANDARD = 0x00
|
||||
FAST = 0x01
|
||||
SLOW = 0x02
|
||||
|
||||
|
||||
class ErrorCode(IntEnum):
|
||||
UNKNOWN = 0x01
|
||||
INVALID_ARGUMENT = 0x02
|
||||
OUT_OF_RANGE = 0x03
|
||||
HARDWARE_ERROR = 0x04
|
||||
LOGITECH_ERROR = 0x05
|
||||
INVALID_FEATURE_INDEX = 0x06
|
||||
INVALID_FUNCTION = 0x07
|
||||
BUSY = 0x08
|
||||
UNSUPPORTED = 0x09
|
||||
|
||||
|
||||
class GestureId(IntEnum):
|
||||
"""Gesture IDs for feature GESTURE_2."""
|
||||
|
||||
TAP_1_FINGER = 1 # task Left_Click
|
||||
TAP_2_FINGER = 2 # task Right_Click
|
||||
TAP_3_FINGER = 3
|
||||
CLICK_1_FINGER = 4 # task Left_Click
|
||||
CLICK_2_FINGER = 5 # task Right_Click
|
||||
CLICK_3_FINGER = 6
|
||||
DOUBLE_TAP_1_FINGER = 10
|
||||
DOUBLE_TAP_2_FINGER = 11
|
||||
DOUBLE_TAP_3_FINGER = 12
|
||||
TRACK_1_FINGER = 20 # action MovePointer
|
||||
TRACKING_ACCELERATION = 21
|
||||
TAP_DRAG_1_FINGER = 30 # action Drag
|
||||
TAP_DRAG_2_FINGER = 31 # action SecondaryDrag
|
||||
DRAG_3_FINGER = 32
|
||||
TAP_GESTURES = 33 # group all tap gestures under a single UI setting
|
||||
FN_CLICK_GESTURE_SUPPRESSION = 34 # suppresses Tap and Edge gestures, toggled by Fn+Click
|
||||
SCROLL_1_FINGER = 40 # action ScrollOrPageXY / ScrollHorizontal
|
||||
SCROLL_2_FINGER = 41 # action ScrollOrPageXY / ScrollHorizontal
|
||||
SCROLL_2_FINGER_HORIZONTAL = 42 # action ScrollHorizontal
|
||||
SCROLL_2_FINGER_VERTICAL = 43 # action WheelScrolling
|
||||
SCROLL_2_FINGER_STATELESS = 44
|
||||
NATURAL_SCROLLING = 45 # affects native HID wheel reporting by gestures, not when diverted
|
||||
THUMBWHEEL = (46,) # action WheelScrolling
|
||||
V_SCROLL_INTERTIA = 48
|
||||
V_SCROLL_BALLISTICS = 49
|
||||
SWIPE_2_FINGER_HORIZONTAL = 50 # action PageScreen
|
||||
SWIPE_3_FINGER_HORIZONTAL = 51 # action PageScreen
|
||||
SWIPE_4_FINGER_HORIZONTAL = 52 # action PageScreen
|
||||
SWIPE_3_FINGER_VERTICAL = 53
|
||||
SWIPE_4_FINGER_VERTICAL = 54
|
||||
LEFT_EDGE_SWIPE_1_FINGER = 60
|
||||
RIGHT_EDGE_SWIPE_1_FINGER = 61
|
||||
BOTTOM_EDGE_SWIPE_1_FINGER = 62
|
||||
TOP_EDGE_SWIPE_1_FINGER = 63
|
||||
LEFT_EDGE_SWIPE_1_FINGER_2 = 64 # task HorzScrollNoRepeatSet
|
||||
RIGHT_EDGE_SWIPE_1_FINGER_2 = 65
|
||||
BOTTOM_EDGE_SWIPE_1_FINGER_2 = 66
|
||||
TOP_EDGE_SWIPE_1_FINGER_2 = 67
|
||||
LEFT_EDGE_SWIPE_2_FINGER = 70
|
||||
RIGHT_EDGE_SWIPE_2_FINGER = 71
|
||||
BottomEdgeSwipe2Finger = 72
|
||||
BOTTOM_EDGE_SWIPE_2_FINGER = 72
|
||||
TOP_EDGE_SWIPE_2_FINGER = 73
|
||||
ZOOM_2_FINGER = 80 # action Zoom
|
||||
ZOOM_2_FINGER_PINCH = 81 # ZoomBtnInSet
|
||||
ZOOM_2_FINGER_SPREAD = 82 # ZoomBtnOutSet
|
||||
ZOOM_3_FINGER = 83
|
||||
ZOOM_2_FINGER_STATELESS = 84
|
||||
TWO_FINGERS_PRESENT = 85
|
||||
ROTATE_2_FINGER = 87
|
||||
FINGER_1 = 90
|
||||
FINGER_2 = 91
|
||||
FINGER_3 = 92
|
||||
FINGER_4 = 93
|
||||
FINGER_5 = 94
|
||||
FINGER_6 = 95
|
||||
FINGER_7 = 96
|
||||
FINGER_8 = 97
|
||||
FINGER_9 = 98
|
||||
FINGER_10 = 99
|
||||
DEVICE_SPECIFIC_RAW_DATA = 100
|
||||
|
||||
|
||||
class ParamId(IntEnum):
|
||||
"""Param Ids for feature GESTURE_2"""
|
||||
|
||||
EXTRA_CAPABILITIES = 1 # not suitable for use
|
||||
PIXEL_ZONE = 2 # 4 2-byte integers, left, bottom, width, height; pixels
|
||||
RATIO_ZONE = 3 # 4 bytes, left, bottom, width, height; unit 1/240 pad size
|
||||
SCALE_FACTOR = 4 # 2-byte integer, with 256 as normal scale
|
||||
|
||||
|
||||
HapticWaveForms = NamedInts(
|
||||
SHARP_STATE_CHANGE=0x00,
|
||||
DAMP_STATE_CHANGE=0x01,
|
||||
SHARP_COLLISION=0x02,
|
||||
DAMP_COLLISION=0x03,
|
||||
SUBTLE_COLLISION=0x04,
|
||||
HAPPY_ALERT=0x05,
|
||||
ANGRY_ALERT=0x06,
|
||||
COMPLETED=0x07,
|
||||
SQUARE=0x08,
|
||||
WAVE=0x09,
|
||||
FIREWORK=0x0A,
|
||||
MAD=0x0B,
|
||||
KNOCK=0x0C,
|
||||
JINGLE=0x0D,
|
||||
RINGING=0xE,
|
||||
WHISPER_COLLISION=0x1B,
|
||||
)
|
||||
|
||||
# Gesture Ids for feature GESTURE_2
|
||||
GESTURE = NamedInts(
|
||||
Tap1Finger=1, # task Left_Click
|
||||
Tap2Finger=2, # task Right_Click
|
||||
Tap3Finger=3,
|
||||
Click1Finger=4, # task Left_Click
|
||||
Click2Finger=5, # task Right_Click
|
||||
Click3Finger=6,
|
||||
DoubleTap1Finger=10,
|
||||
DoubleTap2Finger=11,
|
||||
DoubleTap3Finger=12,
|
||||
Track1Finger=20, # action MovePointer
|
||||
TrackingAcceleration=21,
|
||||
TapDrag1Finger=30, # action Drag
|
||||
TapDrag2Finger=31, # action SecondaryDrag
|
||||
Drag3Finger=32,
|
||||
TapGestures=33, # group all tap gestures under a single UI setting
|
||||
FnClickGestureSuppression=34, # suppresses Tap and Edge gestures, toggled by Fn+Click
|
||||
Scroll1Finger=40, # action ScrollOrPageXY / ScrollHorizontal
|
||||
Scroll2Finger=41, # action ScrollOrPageXY / ScrollHorizontal
|
||||
Scroll2FingerHoriz=42, # action ScrollHorizontal
|
||||
Scroll2FingerVert=43, # action WheelScrolling
|
||||
Scroll2FingerStateless=44,
|
||||
NaturalScrolling=45, # affects native HID wheel reporting by gestures, not when diverted
|
||||
Thumbwheel=46, # action WheelScrolling
|
||||
VScrollInertia=48,
|
||||
VScrollBallistics=49,
|
||||
Swipe2FingerHoriz=50, # action PageScreen
|
||||
Swipe3FingerHoriz=51, # action PageScreen
|
||||
Swipe4FingerHoriz=52, # action PageScreen
|
||||
Swipe3FingerVert=53,
|
||||
Swipe4FingerVert=54,
|
||||
LeftEdgeSwipe1Finger=60,
|
||||
RightEdgeSwipe1Finger=61,
|
||||
BottomEdgeSwipe1Finger=62,
|
||||
TopEdgeSwipe1Finger=63,
|
||||
LeftEdgeSwipe1Finger2=64, # task HorzScrollNoRepeatSet
|
||||
RightEdgeSwipe1Finger2=65, # task 122 ??
|
||||
BottomEdgeSwipe1Finger2=66, #
|
||||
TopEdgeSwipe1Finger2=67, # task 121 ??
|
||||
LeftEdgeSwipe2Finger=70,
|
||||
RightEdgeSwipe2Finger=71,
|
||||
BottomEdgeSwipe2Finger=72,
|
||||
TopEdgeSwipe2Finger=73,
|
||||
Zoom2Finger=80, # action Zoom
|
||||
Zoom2FingerPinch=81, # ZoomBtnInSet
|
||||
Zoom2FingerSpread=82, # ZoomBtnOutSet
|
||||
Zoom3Finger=83,
|
||||
Zoom2FingerStateless=84, # action Zoom
|
||||
TwoFingersPresent=85,
|
||||
Rotate2Finger=87,
|
||||
Finger1=90,
|
||||
Finger2=91,
|
||||
Finger3=92,
|
||||
Finger4=93,
|
||||
Finger5=94,
|
||||
Finger6=95,
|
||||
Finger7=96,
|
||||
Finger8=97,
|
||||
Finger9=98,
|
||||
Finger10=99,
|
||||
DeviceSpecificRawData=100,
|
||||
)
|
||||
GESTURE._fallback = lambda x: f"unknown:{x:04X}"
|
||||
|
||||
# Param Ids for feature GESTURE_2
|
||||
PARAM = NamedInts(
|
||||
ExtraCapabilities=1, # not suitable for use
|
||||
PixelZone=2, # 4 2-byte integers, left, bottom, width, height; pixels
|
||||
RatioZone=3, # 4 bytes, left, bottom, width, height; unit 1/240 pad size
|
||||
ScaleFactor=4, # 2-byte integer, with 256 as normal scale
|
||||
)
|
||||
PARAM._fallback = lambda x: f"unknown:{x:04X}"
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
|
||||
# Translation support for the Logitech receivers library
|
||||
|
||||
import gettext as _gettext
|
||||
import gettext
|
||||
|
||||
_ = _gettext.gettext
|
||||
ngettext = _gettext.ngettext
|
||||
_ = gettext.gettext
|
||||
ngettext = gettext.ngettext
|
||||
|
||||
# A few common strings, not always accessible as such in the code.
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ class EventsListener(threading.Thread):
|
||||
path_name = receiver.path.split("/")[2]
|
||||
except IndexError:
|
||||
path_name = receiver.path
|
||||
super().__init__(name=self.__class__.__name__ + ":" + path_name)
|
||||
super().__init__(name=f"{self.__class__.__name__}:{path_name}")
|
||||
self.daemon = True
|
||||
self._active = False
|
||||
self.receiver = receiver
|
||||
|
||||
@@ -15,153 +15,106 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
# Handles incoming events from the receiver/devices, updating the object as appropriate.
|
||||
"""Handles incoming events from the receiver/devices, updating the
|
||||
object as appropriate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading as _threading
|
||||
|
||||
from struct import unpack as _unpack
|
||||
import struct
|
||||
import threading
|
||||
import typing
|
||||
|
||||
from solaar.i18n import _
|
||||
|
||||
from . import diversion as _diversion
|
||||
from . import base
|
||||
from . import common
|
||||
from . import diversion
|
||||
from . import hidpp10
|
||||
from . import hidpp10_constants as _hidpp10_constants
|
||||
from . import hidpp10_constants
|
||||
from . import hidpp20
|
||||
from . import hidpp20_constants as _hidpp20_constants
|
||||
from . import settings_templates as _st
|
||||
from .base import DJ_MESSAGE_ID as _DJ_MESSAGE_ID
|
||||
from .common import ALERT as _ALERT
|
||||
from .common import Battery as _Battery
|
||||
from .common import strhex as _strhex
|
||||
from . import settings_templates
|
||||
from .common import Alert
|
||||
from .common import BatteryStatus
|
||||
from .common import Notification
|
||||
from .hidpp10_constants import Registers
|
||||
from .hidpp20_constants import SupportedFeature
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .base import HIDPPNotification
|
||||
from .device import Device
|
||||
from .receiver import Receiver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NotificationHandler = typing.Callable[["Receiver", "HIDPPNotification"], bool]
|
||||
|
||||
_hidpp10 = hidpp10.Hidpp10()
|
||||
_hidpp20 = hidpp20.Hidpp20()
|
||||
_R = _hidpp10_constants.REGISTERS
|
||||
_F = _hidpp20_constants.FEATURE
|
||||
|
||||
notification_lock = threading.Lock()
|
||||
|
||||
|
||||
notification_lock = _threading.Lock()
|
||||
|
||||
|
||||
def process(device, notification):
|
||||
def process(device: Device | Receiver, notification: HIDPPNotification):
|
||||
"""Handle incoming events (notification) from device or receiver."""
|
||||
assert device
|
||||
assert notification
|
||||
|
||||
if not device.isDevice:
|
||||
return _process_receiver_notification(device, notification)
|
||||
return _process_device_notification(device, notification)
|
||||
return process_receiver_notification(device, notification)
|
||||
return process_device_notification(device, notification)
|
||||
|
||||
|
||||
def _process_receiver_notification(receiver, n):
|
||||
# supposedly only 0x4x notifications arrive for the receiver
|
||||
assert n.sub_id & 0x40 == 0x40
|
||||
def process_receiver_notification(receiver: Receiver, notification: HIDPPNotification) -> bool | None:
|
||||
"""Process event messages from receivers."""
|
||||
event_handler_mapping: dict[int, NotificationHandler] = {
|
||||
Notification.PAIRING_LOCK: handle_pairing_lock,
|
||||
Registers.DEVICE_DISCOVERY_NOTIFICATION: handle_device_discovery,
|
||||
Registers.DISCOVERY_STATUS_NOTIFICATION: handle_discovery_status,
|
||||
Registers.PAIRING_STATUS_NOTIFICATION: handle_pairing_status,
|
||||
Registers.PASSKEY_PRESSED_NOTIFICATION: handle_passkey_pressed,
|
||||
Registers.PASSKEY_REQUEST_NOTIFICATION: handle_passkey_request,
|
||||
}
|
||||
|
||||
if n.sub_id == 0x4A: # pairing lock notification
|
||||
receiver.pairing.lock_open = bool(n.address & 0x01)
|
||||
reason = _("pairing lock is open") if receiver.pairing.lock_open else _("pairing lock is closed")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: %s", receiver, reason)
|
||||
receiver.pairing.error = None
|
||||
if receiver.pairing.lock_open:
|
||||
receiver.pairing.new_device = None
|
||||
pair_error = ord(n.data[:1])
|
||||
if pair_error:
|
||||
receiver.pairing.error = error_string = _hidpp10_constants.PAIRING_ERRORS[pair_error]
|
||||
receiver.pairing.new_device = None
|
||||
logger.warning("pairing error %d: %s", pair_error, error_string)
|
||||
receiver.changed(reason=reason)
|
||||
return True
|
||||
try:
|
||||
handler_func = event_handler_mapping[notification.sub_id]
|
||||
return handler_func(receiver, notification)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
elif n.sub_id == _R.discovery_status_notification: # Bolt pairing
|
||||
with notification_lock:
|
||||
receiver.pairing.discovering = n.address == 0x00
|
||||
reason = _("discovery lock is open") if receiver.pairing.discovering else _("discovery lock is closed")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: %s", receiver, reason)
|
||||
receiver.pairing.error = None
|
||||
if receiver.pairing.discovering:
|
||||
receiver.pairing.counter = receiver.pairing.device_address = None
|
||||
receiver.pairing.device_authentication = receiver.pairing.device_name = None
|
||||
receiver.pairing.device_passkey = None
|
||||
discover_error = ord(n.data[:1])
|
||||
if discover_error:
|
||||
receiver.pairing.error = discover_string = _hidpp10_constants.BOLT_PAIRING_ERRORS[discover_error]
|
||||
logger.warning("bolt discovering error %d: %s", discover_error, discover_string)
|
||||
receiver.changed(reason=reason)
|
||||
return True
|
||||
assert notification.sub_id in [
|
||||
Notification.CONNECT_DISCONNECT,
|
||||
Notification.DJ_PAIRING,
|
||||
Notification.CONNECTED,
|
||||
Notification.RAW_INPUT,
|
||||
Notification.POWER,
|
||||
]
|
||||
|
||||
elif n.sub_id == _R.device_discovery_notification: # Bolt pairing
|
||||
with notification_lock:
|
||||
counter = n.address + n.data[0] * 256 # notification counter
|
||||
if receiver.pairing.counter is None:
|
||||
receiver.pairing.counter = counter
|
||||
else:
|
||||
if not receiver.pairing.counter == counter:
|
||||
return None
|
||||
if n.data[1] == 0:
|
||||
receiver.pairing.device_kind = n.data[3]
|
||||
receiver.pairing.device_address = n.data[6:12]
|
||||
receiver.pairing.device_authentication = n.data[14]
|
||||
elif n.data[1] == 1:
|
||||
receiver.pairing.device_name = n.data[3 : 3 + n.data[2]].decode("utf-8")
|
||||
return True
|
||||
|
||||
elif n.sub_id == _R.pairing_status_notification: # Bolt pairing
|
||||
with notification_lock:
|
||||
receiver.pairing.device_passkey = None
|
||||
receiver.pairing.lock_open = n.address == 0x00
|
||||
reason = _("pairing lock is open") if receiver.pairing.lock_open else _("pairing lock is closed")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: %s", receiver, reason)
|
||||
receiver.pairing.error = None
|
||||
if not receiver.pairing.lock_open:
|
||||
receiver.pairing.counter = (
|
||||
receiver.pairing.device_address
|
||||
) = receiver.pairing.device_authentication = receiver.pairing.device_name = None
|
||||
pair_error = n.data[0]
|
||||
if receiver.pairing.lock_open:
|
||||
receiver.pairing.new_device = None
|
||||
elif n.address == 0x02 and not pair_error:
|
||||
receiver.pairing.new_device = receiver.register_new_device(n.data[7])
|
||||
if pair_error:
|
||||
receiver.pairing.error = error_string = _hidpp10_constants.BOLT_PAIRING_ERRORS[pair_error]
|
||||
receiver.pairing.new_device = None
|
||||
logger.warning("pairing error %d: %s", pair_error, error_string)
|
||||
receiver.changed(reason=reason)
|
||||
return True
|
||||
|
||||
elif n.sub_id == _R.passkey_request_notification: # Bolt pairing
|
||||
with notification_lock:
|
||||
receiver.pairing.device_passkey = n.data[0:6].decode("utf-8")
|
||||
return True
|
||||
|
||||
elif n.sub_id == _R.passkey_pressed_notification: # Bolt pairing
|
||||
return True
|
||||
|
||||
logger.warning("%s: unhandled notification %s", receiver, n)
|
||||
logger.warning(f"{receiver}: unhandled notification {notification}")
|
||||
|
||||
|
||||
def _process_device_notification(device, n):
|
||||
def process_device_notification(device: Device, notification: HIDPPNotification):
|
||||
"""Process event messages from devices."""
|
||||
|
||||
# incoming packets with SubId >= 0x80 are supposedly replies from HID++ 1.0 requests, should never get here
|
||||
assert n.sub_id & 0x80 == 0
|
||||
assert notification.sub_id & 0x80 == 0
|
||||
|
||||
if n.sub_id == 00: # no-op feature notification, dispose of it quickly
|
||||
if notification.sub_id == Notification.NO_OPERATION:
|
||||
# dispose it
|
||||
return False
|
||||
|
||||
# Allow the device object to handle the notification using custom per-device state.
|
||||
handling_ret = device.handle_notification(n)
|
||||
handling_ret = device.handle_notification(notification)
|
||||
if handling_ret is not None:
|
||||
return handling_ret
|
||||
|
||||
# 0x40 to 0x7F appear to be HID++ 1.0 or DJ notifications
|
||||
if n.sub_id >= 0x40:
|
||||
if n.report_id == _DJ_MESSAGE_ID:
|
||||
return _process_dj_notification(device, n)
|
||||
if notification.sub_id >= 0x40:
|
||||
if notification.report_id == base.DJ_MESSAGE_ID:
|
||||
return _process_dj_notification(device, notification)
|
||||
else:
|
||||
return _process_hidpp10_notification(device, n)
|
||||
return _process_hidpp10_notification(device, notification)
|
||||
|
||||
# These notifications are from the device itself, so it must be active
|
||||
device.online = True
|
||||
@@ -170,85 +123,82 @@ def _process_device_notification(device, n):
|
||||
|
||||
# some custom battery events for HID++ 1.0 devices
|
||||
if device.protocol < 2.0:
|
||||
return _process_hidpp10_custom_notification(device, n)
|
||||
return _process_hidpp10_custom_notification(device, notification)
|
||||
|
||||
# assuming 0x00 to 0x3F are feature (HID++ 2.0) notifications
|
||||
if not device.features:
|
||||
logger.warning("%s: feature notification but features not set up: %02X %s", device, n.sub_id, n)
|
||||
return False
|
||||
try:
|
||||
feature = device.features.get_feature(n.sub_id)
|
||||
except IndexError:
|
||||
logger.warning("%s: notification from invalid feature index %02X: %s", device, n.sub_id, n)
|
||||
logger.warning("%s: feature notification but features not set up: %02X %s", device, notification.sub_id, notification)
|
||||
return False
|
||||
|
||||
return _process_feature_notification(device, n, feature)
|
||||
return _process_feature_notification(device, notification)
|
||||
|
||||
|
||||
def _process_dj_notification(device, n):
|
||||
def _process_dj_notification(device: Device, notification: HIDPPNotification):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s (%s) DJ %s", device, device.protocol, n)
|
||||
logger.debug("%s (%s) DJ %s", device, device.protocol, notification)
|
||||
|
||||
if n.sub_id == 0x40:
|
||||
if notification.sub_id == Notification.CONNECT_DISCONNECT:
|
||||
# do all DJ paired notifications also show up as HID++ 1.0 notifications?
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: ignoring DJ unpaired: %s", device, n)
|
||||
logger.info("%s: ignoring DJ unpaired: %s", device, notification)
|
||||
return True
|
||||
|
||||
if n.sub_id == 0x41:
|
||||
if notification.sub_id == Notification.DJ_PAIRING:
|
||||
# do all DJ paired notifications also show up as HID++ 1.0 notifications?
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: ignoring DJ paired: %s", device, n)
|
||||
logger.info("%s: ignoring DJ paired: %s", device, notification)
|
||||
return True
|
||||
|
||||
if n.sub_id == 0x42:
|
||||
connected = not n.address & 0x01
|
||||
if notification.sub_id == Notification.CONNECTED:
|
||||
connected = not notification.address & 0x01
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: DJ connection: %s %s", device, connected, n)
|
||||
device.changed(active=connected, alert=_ALERT.NONE, reason=_("connected") if connected else _("disconnected"))
|
||||
logger.info("%s: DJ connection: %s %s", device, connected, notification)
|
||||
device.changed(active=connected, alert=Alert.NONE, reason=_("connected") if connected else _("disconnected"))
|
||||
return True
|
||||
|
||||
logger.warning("%s: unrecognized DJ %s", device, n)
|
||||
logger.warning("%s: unrecognized DJ %s", device, notification)
|
||||
|
||||
|
||||
def _process_hidpp10_custom_notification(device, n):
|
||||
def _process_hidpp10_custom_notification(device: Device, notification: HIDPPNotification):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s (%s) custom notification %s", device, device.protocol, n)
|
||||
logger.debug("%s (%s) custom notification %s", device, device.protocol, notification)
|
||||
|
||||
if n.sub_id in (_R.battery_status, _R.battery_charge):
|
||||
assert n.data[-1:] == b"\x00"
|
||||
data = chr(n.address).encode() + n.data
|
||||
device.set_battery_info(hidpp10.parse_battery_status(n.sub_id, data))
|
||||
if notification.sub_id in (Registers.BATTERY_STATUS, Registers.BATTERY_CHARGE):
|
||||
assert notification.data[-1:] == b"\x00"
|
||||
data = chr(notification.address).encode() + notification.data
|
||||
device.set_battery_info(hidpp10.parse_battery_status(notification.sub_id, data))
|
||||
return True
|
||||
|
||||
logger.warning("%s: unrecognized %s", device, n)
|
||||
logger.warning("%s: unrecognized %s", device, notification)
|
||||
|
||||
|
||||
def _process_hidpp10_notification(device, n):
|
||||
if n.sub_id == 0x40: # device unpairing
|
||||
if n.address == 0x02:
|
||||
def _process_hidpp10_notification(device: Device, notification: HIDPPNotification):
|
||||
if notification.sub_id == Notification.CONNECT_DISCONNECT: # device unpairing
|
||||
if notification.address == 0x02:
|
||||
# device un-paired
|
||||
device.wpid = None
|
||||
if device.number in device.receiver:
|
||||
del device.receiver[device.number]
|
||||
device.changed(active=False, alert=_ALERT.ALL, reason=_("unpaired"))
|
||||
device.changed(active=False, alert=Alert.ALL, reason=_("unpaired"))
|
||||
## device.status = None
|
||||
else:
|
||||
logger.warning("%s: disconnection with unknown type %02X: %s", device, n.address, n)
|
||||
logger.warning("%s: disconnection with unknown type %02X: %s", device, notification.address, notification)
|
||||
return True
|
||||
|
||||
if n.sub_id == 0x41: # device connection (and disconnection)
|
||||
flags = ord(n.data[:1]) & 0xF0
|
||||
if n.address == 0x02: # very old 27 MHz protocol
|
||||
wpid = "00" + _strhex(n.data[2:3])
|
||||
if notification.sub_id == Notification.DJ_PAIRING: # device connection (and disconnection)
|
||||
flags = ord(notification.data[:1]) & 0xF0
|
||||
if notification.address == 0x02: # very old 27 MHz protocol
|
||||
wpid = "00" + common.strhex(notification.data[2:3])
|
||||
link_established = True
|
||||
link_encrypted = bool(flags & 0x80)
|
||||
elif n.address > 0x00: # all other protocols are supposed to be almost the same
|
||||
wpid = _strhex(n.data[2:3] + n.data[1:2])
|
||||
elif notification.address > 0x00: # all other protocols are supposed to be almost the same
|
||||
wpid = common.strhex(notification.data[2:3] + notification.data[1:2])
|
||||
link_established = not (flags & 0x40)
|
||||
link_encrypted = bool(flags & 0x20) or n.address == 0x10 # Bolt protocol always encrypted
|
||||
link_encrypted = bool(flags & 0x20) or notification.address == 0x10 # Bolt protocol always encrypted
|
||||
else:
|
||||
logger.warning("%s: connection notification with unknown protocol %02X: %s", device.number, n.address, n)
|
||||
logger.warning(
|
||||
"%s: connection notification with unknown protocol %02X: %s", device.number, notification.address, notification
|
||||
)
|
||||
return True
|
||||
if wpid != device.wpid:
|
||||
logger.warning("%s wpid mismatch, got %s", device, wpid)
|
||||
@@ -256,7 +206,7 @@ def _process_hidpp10_notification(device, n):
|
||||
logger.debug(
|
||||
"%s: protocol %s connection notification: software=%s, encrypted=%s, link=%s, payload=%s",
|
||||
device,
|
||||
n.address,
|
||||
notification.address,
|
||||
bool(flags & 0x10),
|
||||
link_encrypted,
|
||||
link_established,
|
||||
@@ -264,193 +214,300 @@ def _process_hidpp10_notification(device, n):
|
||||
)
|
||||
device.link_encrypted = link_encrypted
|
||||
if not link_established and device.receiver:
|
||||
_hidpp10.set_configuration_pending_flags(device.receiver, 0xFF)
|
||||
hidpp10.set_configuration_pending_flags(device.receiver, 0xFF)
|
||||
device.changed(active=link_established)
|
||||
return True
|
||||
|
||||
if n.sub_id == 0x49:
|
||||
if notification.sub_id == Notification.RAW_INPUT:
|
||||
# raw input event? just ignore it
|
||||
# if n.address == 0x01, no idea what it is, but they keep on coming
|
||||
# if n.address == 0x03, appears to be an actual input event, because they only come when input happents
|
||||
# if notification.address == 0x01, no idea what it is, but they keep on coming
|
||||
# if notification.address == 0x03, appears to be an actual input event, because they only come when input happents
|
||||
return True
|
||||
|
||||
if n.sub_id == 0x4B: # power notification
|
||||
if n.address == 0x01:
|
||||
if notification.sub_id == Notification.POWER:
|
||||
if notification.address == 0x01:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: device powered on", device)
|
||||
reason = device.status_string() or _("powered on")
|
||||
device.changed(active=True, alert=_ALERT.NOTIFICATION, reason=reason)
|
||||
device.changed(active=True, alert=Alert.NOTIFICATION, reason=reason)
|
||||
else:
|
||||
logger.warning("%s: unknown %s", device, n)
|
||||
logger.warning("%s: unknown %s", device, notification)
|
||||
return True
|
||||
|
||||
logger.warning("%s: unrecognized %s", device, n)
|
||||
logger.warning("%s: unrecognized %s", device, notification)
|
||||
|
||||
|
||||
def _process_feature_notification(device, n, feature):
|
||||
def _process_feature_notification(device: Device, notification: HIDPPNotification):
|
||||
old_present, device.present = device.present, True # the device is generating a feature notification so it must be present
|
||||
try:
|
||||
feature = device.features.get_feature(notification.sub_id)
|
||||
except IndexError:
|
||||
logger.warning("%s: notification from invalid feature index %02X: %s", device, notification.sub_id, notification)
|
||||
return False
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: notification for feature %s, report %s, data %s", device, feature, n.address >> 4, _strhex(n.data))
|
||||
logger.debug(
|
||||
"%s: notification for feature %s, report %s, data %s",
|
||||
device,
|
||||
feature,
|
||||
notification.address >> 4,
|
||||
common.strhex(notification.data),
|
||||
)
|
||||
|
||||
if feature == _F.BATTERY_STATUS:
|
||||
if n.address == 0x00:
|
||||
device.set_battery_info(hidpp20.decipher_battery_status(n.data)[1])
|
||||
elif n.address == 0x10:
|
||||
if feature == SupportedFeature.BATTERY_STATUS:
|
||||
if notification.address == 0x00:
|
||||
device.set_battery_info(hidpp20.decipher_battery_status(notification.data)[1])
|
||||
elif notification.address == 0x10:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: spurious BATTERY status %s", device, n)
|
||||
logger.info("%s: spurious BATTERY status %s", device, notification)
|
||||
else:
|
||||
logger.warning("%s: unknown BATTERY %s", device, n)
|
||||
logger.warning("%s: unknown BATTERY %s", device, notification)
|
||||
|
||||
elif feature == _F.BATTERY_VOLTAGE:
|
||||
if n.address == 0x00:
|
||||
device.set_battery_info(hidpp20.decipher_battery_voltage(n.data)[1])
|
||||
elif feature == SupportedFeature.BATTERY_VOLTAGE:
|
||||
if notification.address == 0x00:
|
||||
device.set_battery_info(hidpp20.decipher_battery_voltage(notification.data)[1])
|
||||
else:
|
||||
logger.warning("%s: unknown VOLTAGE %s", device, n)
|
||||
logger.warning("%s: unknown VOLTAGE %s", device, notification)
|
||||
|
||||
elif feature == _F.UNIFIED_BATTERY:
|
||||
if n.address == 0x00:
|
||||
device.set_battery_info(hidpp20.decipher_battery_unified(n.data)[1])
|
||||
elif feature == SupportedFeature.UNIFIED_BATTERY:
|
||||
if notification.address == 0x00:
|
||||
device.set_battery_info(hidpp20.decipher_battery_unified(notification.data)[1])
|
||||
else:
|
||||
logger.warning("%s: unknown UNIFIED BATTERY %s", device, n)
|
||||
logger.warning("%s: unknown UNIFIED BATTERY %s", device, notification)
|
||||
|
||||
elif feature == _F.ADC_MEASUREMENT:
|
||||
if n.address == 0x00:
|
||||
result = hidpp20.decipher_adc_measurement(n.data)
|
||||
if result:
|
||||
elif feature == SupportedFeature.ADC_MEASUREMENT:
|
||||
if notification.address == 0x00:
|
||||
result = hidpp20.decipher_adc_measurement(notification.data)
|
||||
if result: # if good data and the device was not present then a push is needed
|
||||
device.set_battery_info(result[1])
|
||||
else: # this feature is used to signal device becoming inactive
|
||||
device.changed(active=True, alert=Alert.NONE, reason=_("ADC measurement notification"), push=not old_present)
|
||||
else: # this feature is also used to signal device becoming inactive
|
||||
device.present = False # exception to device presence
|
||||
device.changed(active=False)
|
||||
else:
|
||||
logger.warning("%s: unknown ADC MEASUREMENT %s", device, n)
|
||||
logger.warning("%s: unknown ADC MEASUREMENT %s", device, notification)
|
||||
|
||||
elif feature == _F.SOLAR_DASHBOARD:
|
||||
if n.data[5:9] == b"GOOD":
|
||||
charge, lux, adc = _unpack("!BHH", n.data[:5])
|
||||
elif feature == SupportedFeature.SOLAR_DASHBOARD:
|
||||
if notification.data[5:9] == b"GOOD":
|
||||
charge, lux, adc = struct.unpack("!BHH", notification.data[:5])
|
||||
# guesstimate the battery voltage, emphasis on 'guess'
|
||||
# status_text = '%1.2fV' % (adc * 2.67793237653 / 0x0672)
|
||||
status_text = _Battery.STATUS.discharging
|
||||
if n.address == 0x00:
|
||||
device.set_battery_info(_Battery(charge, None, status_text, None))
|
||||
elif n.address == 0x10:
|
||||
status_text = BatteryStatus.DISCHARGING
|
||||
if notification.address == 0x00:
|
||||
device.set_battery_info(common.Battery(charge, None, status_text, None))
|
||||
elif notification.address == 0x10:
|
||||
if lux > 200:
|
||||
status_text = _Battery.STATUS.recharging
|
||||
device.set_battery_info(_Battery(charge, None, status_text, None, lux))
|
||||
elif n.address == 0x20:
|
||||
status_text = BatteryStatus.RECHARGING
|
||||
device.set_battery_info(common.Battery(charge, None, status_text, None, lux))
|
||||
elif notification.address == 0x20:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: Light Check button pressed", device)
|
||||
device.changed(alert=_ALERT.SHOW_WINDOW)
|
||||
device.changed(alert=Alert.SHOW_WINDOW)
|
||||
# first cancel any reporting
|
||||
# device.feature_request(_F.SOLAR_DASHBOARD)
|
||||
# device.feature_request(SupportedFeature.SOLAR_DASHBOARD)
|
||||
# trigger a new report chain
|
||||
reports_count = 15
|
||||
reports_period = 2 # seconds
|
||||
device.feature_request(_F.SOLAR_DASHBOARD, 0x00, reports_count, reports_period)
|
||||
device.feature_request(SupportedFeature.SOLAR_DASHBOARD, 0x00, reports_count, reports_period)
|
||||
else:
|
||||
logger.warning("%s: unknown SOLAR CHARGE %s", device, n)
|
||||
logger.warning("%s: unknown SOLAR CHARGE %s", device, notification)
|
||||
else:
|
||||
logger.warning("%s: SOLAR CHARGE not GOOD? %s", device, n)
|
||||
logger.warning("%s: SOLAR CHARGE not GOOD? %s", device, notification)
|
||||
|
||||
elif feature == _F.WIRELESS_DEVICE_STATUS:
|
||||
if n.address == 0x00:
|
||||
elif feature == SupportedFeature.WIRELESS_DEVICE_STATUS:
|
||||
if notification.address == 0x00:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("wireless status: %s", n)
|
||||
reason = "powered on" if n.data[2] == 1 else None
|
||||
if n.data[1] == 1: # device is asking for software reconfiguration so need to change status
|
||||
alert = _ALERT.NONE
|
||||
logger.debug("wireless status: %s", notification)
|
||||
reason = "powered on" if notification.data[2] == 1 else None
|
||||
if notification.data[1] == 1: # device is asking for software reconfiguration so need to change status
|
||||
alert = Alert.NONE
|
||||
device.changed(active=True, alert=alert, reason=reason, push=True)
|
||||
else:
|
||||
logger.warning("%s: unknown WIRELESS %s", device, n)
|
||||
logger.warning("%s: unknown WIRELESS %s", device, notification)
|
||||
|
||||
elif feature == _F.TOUCHMOUSE_RAW_POINTS:
|
||||
if n.address == 0x00:
|
||||
elif feature == SupportedFeature.TOUCHMOUSE_RAW_POINTS:
|
||||
if notification.address == 0x00:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: TOUCH MOUSE points %s", device, n)
|
||||
elif n.address == 0x10:
|
||||
touch = ord(n.data[:1])
|
||||
logger.info("%s: TOUCH MOUSE points %s", device, notification)
|
||||
elif notification.address == 0x10:
|
||||
touch = ord(notification.data[:1])
|
||||
button_down = bool(touch & 0x02)
|
||||
mouse_lifted = bool(touch & 0x01)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: TOUCH MOUSE status: button_down=%s mouse_lifted=%s", device, button_down, mouse_lifted)
|
||||
else:
|
||||
logger.warning("%s: unknown TOUCH MOUSE %s", device, n)
|
||||
logger.warning("%s: unknown TOUCH MOUSE %s", device, notification)
|
||||
|
||||
# TODO: what are REPROG_CONTROLS_V{2,3}?
|
||||
elif feature == _F.REPROG_CONTROLS:
|
||||
if n.address == 0x00:
|
||||
elif feature == SupportedFeature.REPROG_CONTROLS:
|
||||
if notification.address == 0x00:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: reprogrammable key: %s", device, n)
|
||||
logger.info("%s: reprogrammable key: %s", device, notification)
|
||||
else:
|
||||
logger.warning("%s: unknown REPROG_CONTROLS %s", device, n)
|
||||
logger.warning("%s: unknown REPROG_CONTROLS %s", device, notification)
|
||||
|
||||
elif feature == _F.BACKLIGHT2:
|
||||
if n.address == 0x00:
|
||||
level = _unpack("!B", n.data[1:2])[0]
|
||||
elif feature == SupportedFeature.BACKLIGHT2:
|
||||
if notification.address == 0x00:
|
||||
level = struct.unpack("!B", notification.data[1:2])[0]
|
||||
if device.setting_callback:
|
||||
device.setting_callback(device, _st.Backlight2Level, [level])
|
||||
device.setting_callback(device, settings_templates.Backlight2Level, [level])
|
||||
|
||||
elif feature == _F.REPROG_CONTROLS_V4:
|
||||
if n.address == 0x00:
|
||||
elif feature == SupportedFeature.REPROG_CONTROLS_V4:
|
||||
if notification.address == 0x00:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
cid1, cid2, cid3, cid4 = _unpack("!HHHH", n.data[:8])
|
||||
cid1, cid2, cid3, cid4 = struct.unpack("!HHHH", notification.data[:8])
|
||||
logger.debug("%s: diverted controls pressed: 0x%x, 0x%x, 0x%x, 0x%x", device, cid1, cid2, cid3, cid4)
|
||||
elif n.address == 0x10:
|
||||
elif notification.address == 0x10:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
dx, dy = _unpack("!hh", n.data[:4])
|
||||
dx, dy = struct.unpack("!hh", notification.data[:4])
|
||||
logger.debug("%s: rawXY dx=%i dy=%i", device, dx, dy)
|
||||
elif n.address == 0x20:
|
||||
elif notification.address == 0x20:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: received analyticsKeyEvents", device)
|
||||
elif logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: unknown REPROG_CONTROLS_V4 %s", device, n)
|
||||
logger.info("%s: unknown REPROG_CONTROLS_V4 %s", device, notification)
|
||||
|
||||
elif feature == _F.HIRES_WHEEL:
|
||||
if n.address == 0x00:
|
||||
elif feature == SupportedFeature.HIRES_WHEEL:
|
||||
if notification.address == 0x00:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
flags, delta_v = _unpack(">bh", n.data[:3])
|
||||
flags, delta_v = struct.unpack(">bh", notification.data[:3])
|
||||
high_res = (flags & 0x10) != 0
|
||||
periods = flags & 0x0F
|
||||
logger.info("%s: WHEEL: res: %d periods: %d delta V:%-3d", device, high_res, periods, delta_v)
|
||||
elif n.address == 0x10:
|
||||
ratchet = n.data[0]
|
||||
elif notification.address == 0x10:
|
||||
ratchet = notification.data[0]
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: WHEEL: ratchet: %d", device, ratchet)
|
||||
if ratchet < 2: # don't process messages with unusual ratchet values
|
||||
if device.setting_callback:
|
||||
device.setting_callback(device, _st.ScrollRatchet, [2 if ratchet else 1])
|
||||
device.setting_callback(device, settings_templates.ScrollRatchet, [2 if ratchet else 1])
|
||||
else:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: unknown WHEEL %s", device, n)
|
||||
logger.info("%s: unknown WHEEL %s", device, notification)
|
||||
|
||||
elif feature == _F.ONBOARD_PROFILES:
|
||||
if n.address > 0x10:
|
||||
elif feature == SupportedFeature.ONBOARD_PROFILES:
|
||||
if notification.address > 0x10:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: unknown ONBOARD PROFILES %s", device, n)
|
||||
logger.info("%s: unknown ONBOARD PROFILES %s", device, notification)
|
||||
else:
|
||||
if n.address == 0x00:
|
||||
profile_sector = _unpack("!H", n.data[:2])[0]
|
||||
if notification.address == 0x00:
|
||||
profile_sector = struct.unpack("!H", notification.data[:2])[0]
|
||||
if profile_sector:
|
||||
_st.profile_change(device, profile_sector)
|
||||
elif n.address == 0x10:
|
||||
resolution_index = _unpack("!B", n.data[:1])[0]
|
||||
profile_sector = _unpack("!H", device.feature_request(_F.ONBOARD_PROFILES, 0x40)[:2])[0]
|
||||
settings_templates.profile_change(device, profile_sector)
|
||||
elif notification.address == 0x10:
|
||||
resolution_index = struct.unpack("!B", notification.data[:1])[0]
|
||||
profile_sector = struct.unpack("!H", device.feature_request(SupportedFeature.ONBOARD_PROFILES, 0x40)[:2])[0]
|
||||
if device.setting_callback:
|
||||
for profile in device.profiles.profiles.values() if device.profiles else []:
|
||||
if profile.sector == profile_sector:
|
||||
device.setting_callback(device, _st.AdjustableDpi, [profile.resolutions[resolution_index]])
|
||||
device.setting_callback(
|
||||
device, settings_templates.AdjustableDpi, [profile.resolutions[resolution_index]]
|
||||
)
|
||||
break
|
||||
|
||||
elif feature == _F.BRIGHTNESS_CONTROL:
|
||||
if n.address > 0x10:
|
||||
elif feature == SupportedFeature.BRIGHTNESS_CONTROL:
|
||||
if notification.address > 0x10:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: unknown BRIGHTNESS CONTROL %s", device, n)
|
||||
logger.info("%s: unknown BRIGHTNESS CONTROL %s", device, notification)
|
||||
else:
|
||||
if n.address == 0x00:
|
||||
brightness = _unpack("!H", n.data[:2])[0]
|
||||
device.setting_callback(device, _st.BrightnessControl, [brightness])
|
||||
elif n.address == 0x10:
|
||||
brightness = n.data[0] & 0x01
|
||||
if notification.address == 0x00:
|
||||
brightness = struct.unpack("!H", notification.data[:2])[0]
|
||||
device.setting_callback(device, settings_templates.BrightnessControl, [brightness])
|
||||
elif notification.address == 0x10:
|
||||
brightness = notification.data[0] & 0x01
|
||||
if brightness:
|
||||
brightness = _unpack("!H", device.feature_request(_F.BRIGHTNESS_CONTROL, 0x10)[:2])[0]
|
||||
device.setting_callback(device, _st.BrightnessControl, [brightness])
|
||||
brightness = struct.unpack("!H", device.feature_request(SupportedFeature.BRIGHTNESS_CONTROL, 0x10)[:2])[0]
|
||||
device.setting_callback(device, settings_templates.BrightnessControl, [brightness])
|
||||
|
||||
_diversion.process_notification(device, n, feature)
|
||||
diversion.process_notification(device, notification, feature)
|
||||
return True
|
||||
|
||||
|
||||
def handle_pairing_lock(receiver: Receiver, notification: HIDPPNotification) -> bool:
|
||||
receiver.pairing.lock_open = bool(notification.address & 0x01)
|
||||
reason = _("pairing lock is open") if receiver.pairing.lock_open else _("pairing lock is closed")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: %s", receiver, reason)
|
||||
receiver.pairing.error = None
|
||||
if receiver.pairing.lock_open:
|
||||
receiver.pairing.new_device = None
|
||||
pair_error = ord(notification.data[:1])
|
||||
if pair_error:
|
||||
error_string = hidpp10_constants.PairingError(pair_error).label
|
||||
receiver.pairing.error = error_string
|
||||
receiver.pairing.new_device = None
|
||||
logger.warning("pairing error %d: %s", pair_error, error_string)
|
||||
receiver.changed(reason=reason)
|
||||
return True
|
||||
|
||||
|
||||
def handle_discovery_status(receiver: Receiver, notification: HIDPPNotification) -> bool:
|
||||
with notification_lock:
|
||||
receiver.pairing.discovering = notification.address == 0x00
|
||||
reason = _("discovery lock is open") if receiver.pairing.discovering else _("discovery lock is closed")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: %s", receiver, reason)
|
||||
receiver.pairing.error = None
|
||||
if receiver.pairing.discovering:
|
||||
receiver.pairing.counter = receiver.pairing.device_address = None
|
||||
receiver.pairing.device_authentication = receiver.pairing.device_name = None
|
||||
receiver.pairing.device_passkey = None
|
||||
discover_error = ord(notification.data[:1])
|
||||
if discover_error:
|
||||
receiver.pairing.error = discover_string = hidpp10_constants.BoltPairingError(discover_error).label
|
||||
logger.warning("bolt discovering error %d: %s", discover_error, discover_string)
|
||||
receiver.changed(reason=reason)
|
||||
return True
|
||||
|
||||
|
||||
def handle_device_discovery(receiver: Receiver, notification: HIDPPNotification) -> bool:
|
||||
with notification_lock:
|
||||
counter = notification.address + notification.data[0] * 256 # notification counter
|
||||
if receiver.pairing.counter is None:
|
||||
receiver.pairing.counter = counter
|
||||
else:
|
||||
if not receiver.pairing.counter == counter:
|
||||
return None
|
||||
if notification.data[1] == 0:
|
||||
receiver.pairing.device_kind = notification.data[3]
|
||||
receiver.pairing.device_address = notification.data[6:12]
|
||||
receiver.pairing.device_authentication = notification.data[14]
|
||||
elif notification.data[1] == 1:
|
||||
receiver.pairing.device_name = notification.data[3 : 3 + notification.data[2]].decode("utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def handle_pairing_status(receiver: Receiver, notification: HIDPPNotification) -> bool:
|
||||
with notification_lock:
|
||||
receiver.pairing.device_passkey = None
|
||||
receiver.pairing.lock_open = notification.address == 0x00
|
||||
reason = _("pairing lock is open") if receiver.pairing.lock_open else _("pairing lock is closed")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: %s", receiver, reason)
|
||||
receiver.pairing.error = None
|
||||
if not receiver.pairing.lock_open:
|
||||
receiver.pairing.counter = None
|
||||
receiver.pairing.device_address = None
|
||||
receiver.pairing.device_authentication = None
|
||||
receiver.pairing.device_name = None
|
||||
pair_error = notification.data[0]
|
||||
if receiver.pairing.lock_open:
|
||||
receiver.pairing.new_device = None
|
||||
elif notification.address == 0x02 and not pair_error:
|
||||
receiver.pairing.new_device = receiver.register_new_device(notification.data[7])
|
||||
if pair_error:
|
||||
receiver.pairing.error = error_string = hidpp10_constants.BoltPairingError(pair_error).label
|
||||
receiver.pairing.new_device = None
|
||||
logger.warning("pairing error %d: %s", pair_error, error_string)
|
||||
receiver.changed(reason=reason)
|
||||
return True
|
||||
|
||||
|
||||
def handle_passkey_request(receiver: Receiver, notification: HIDPPNotification) -> bool:
|
||||
with notification_lock:
|
||||
receiver.pairing.device_passkey = notification.data[0:6].decode("utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def handle_passkey_pressed(_receiver: Receiver, _hidpp_notification: HIDPPNotification) -> bool:
|
||||
return True
|
||||
|
||||
@@ -15,30 +15,56 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
import errno as _errno
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import time
|
||||
import typing
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
from typing import Optional
|
||||
|
||||
import hidapi as _hid
|
||||
from typing import Protocol
|
||||
|
||||
from solaar.i18n import _
|
||||
from solaar.i18n import ngettext
|
||||
|
||||
from . import base as _base
|
||||
from . import exceptions
|
||||
from . import hidpp10
|
||||
from . import hidpp10_constants
|
||||
from .common import ALERT
|
||||
from .common import Alert
|
||||
from .common import Notification
|
||||
from .device import Device
|
||||
from .hidpp10_constants import InfoSubRegisters
|
||||
from .hidpp10_constants import NotificationFlag
|
||||
from .hidpp10_constants import Registers
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from logitech_receiver import common
|
||||
|
||||
from .base import HIDPPNotification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_hidpp10 = hidpp10.Hidpp10()
|
||||
_R = hidpp10_constants.REGISTERS
|
||||
_IR = hidpp10_constants.INFO_SUBREGISTERS
|
||||
|
||||
|
||||
class LowLevelInterface(Protocol):
|
||||
def open_path(self, path):
|
||||
...
|
||||
|
||||
def find_paired_node_wpid(self, receiver_path: str, index: int):
|
||||
...
|
||||
|
||||
def ping(self, handle, number, long_message=False):
|
||||
...
|
||||
|
||||
def request(self, handle, devnumber, request_id, *params, **kwargs):
|
||||
...
|
||||
|
||||
def close(self, handle):
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -57,16 +83,75 @@ class Pairing:
|
||||
error: Optional[any] = None
|
||||
|
||||
|
||||
def extract_serial(response: bytes) -> str:
|
||||
"""Extracts serial number from receiver response."""
|
||||
return response.hex().upper()
|
||||
|
||||
|
||||
def extract_max_devices(response: bytes) -> int:
|
||||
"""Extracts maximum number of supported devices from response."""
|
||||
max_devices = response[6]
|
||||
return int(max_devices)
|
||||
|
||||
|
||||
def extract_remaining_pairings(response: bytes) -> int:
|
||||
ps = ord(response[2:3])
|
||||
remaining_pairings = ps - 5 if ps >= 5 else -1
|
||||
return int(remaining_pairings)
|
||||
|
||||
|
||||
def extract_codename(response: bytes) -> str:
|
||||
codename = response[2 : 2 + ord(response[1:2])]
|
||||
return codename.decode("ascii")
|
||||
|
||||
|
||||
def extract_power_switch_location(response: bytes) -> str:
|
||||
"""Extracts power switch location from response."""
|
||||
index = response[9] & 0x0F
|
||||
return hidpp10_constants.PowerSwitchLocation.location(index).name.lower()
|
||||
|
||||
|
||||
def extract_connection_count(response: bytes) -> int:
|
||||
"""Extract connection count from receiver response."""
|
||||
return ord(response[1:2])
|
||||
|
||||
|
||||
def extract_wpid(response: bytes) -> str:
|
||||
"""Extract wpid from receiver response."""
|
||||
return response.hex().upper()
|
||||
|
||||
|
||||
def extract_polling_rate(response: bytes) -> int:
|
||||
"""Returns polling rate in milliseconds."""
|
||||
return int(response[2])
|
||||
|
||||
|
||||
def extract_device_kind(response: int) -> str:
|
||||
return hidpp10_constants.DEVICE_KIND[response]
|
||||
|
||||
|
||||
class Receiver:
|
||||
"""A generic Receiver instance, mostly implementing the interface used on Unifying, Nano, and LightSpeed receivers"
|
||||
The paired devices are available through the sequence interface.
|
||||
"""
|
||||
|
||||
read_register: Callable = hidpp10.read_register
|
||||
write_register: Callable = hidpp10.write_register
|
||||
number = 0xFF
|
||||
kind = None
|
||||
|
||||
def __init__(self, receiver_kind, product_info, handle, path, product_id, setting_callback=None):
|
||||
def __init__(
|
||||
self,
|
||||
low_level: LowLevelInterface,
|
||||
receiver_kind,
|
||||
product_info,
|
||||
handle,
|
||||
path,
|
||||
product_id,
|
||||
setting_callback=None,
|
||||
):
|
||||
assert handle
|
||||
self.low_level = low_level
|
||||
self.isDevice = False # some devices act as receiver so we need a property to distinguish them
|
||||
self.handle = handle
|
||||
self.path = path
|
||||
@@ -85,15 +170,15 @@ class Receiver:
|
||||
self.notification_flags = None
|
||||
self.pairing = Pairing()
|
||||
self.initialize(product_info)
|
||||
_hidpp10.set_configuration_pending_flags(self, 0xFF)
|
||||
hidpp10.set_configuration_pending_flags(self, 0xFF)
|
||||
|
||||
def initialize(self, product_info: dict):
|
||||
# read the receiver information subregister, so we can find out max_devices
|
||||
serial_reply = self.read_register(_R.receiver_info, _IR.receiver_information)
|
||||
serial_reply = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.RECEIVER_INFORMATION)
|
||||
if serial_reply:
|
||||
self.serial = serial_reply[1:5].hex().upper()
|
||||
self.max_devices = serial_reply[6]
|
||||
if self.max_devices <= 0 or self.max_devices > 6:
|
||||
self.serial = extract_serial(serial_reply[1:5])
|
||||
self.max_devices = extract_max_devices(serial_reply)
|
||||
if not (1 <= self.max_devices <= 6):
|
||||
self.max_devices = product_info.get("max_devices", 1)
|
||||
else: # handle receivers that don't have a serial number specially (i.e., c534)
|
||||
self.serial = None
|
||||
@@ -105,18 +190,18 @@ class Receiver:
|
||||
if d:
|
||||
d.close()
|
||||
self._devices.clear()
|
||||
return handle and _base.close(handle)
|
||||
return handle and self.low_level.close(handle)
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
def changed(self, alert=ALERT.NOTIFICATION, reason=None):
|
||||
def changed(self, alert=Alert.NOTIFICATION, reason=None):
|
||||
"""The status of the device had changed, so invoke the status callback"""
|
||||
if self.status_callback is not None:
|
||||
self.status_callback(self, alert=alert, reason=reason)
|
||||
|
||||
@property
|
||||
def firmware(self):
|
||||
def firmware(self) -> tuple[common.FirmwareInfo]:
|
||||
if self._firmware is None and self.handle:
|
||||
self._firmware = _hidpp10.get_firmware(self)
|
||||
return self._firmware
|
||||
@@ -124,10 +209,9 @@ class Receiver:
|
||||
# how many pairings remain (None for unknown, -1 for unlimited)
|
||||
def remaining_pairings(self, cache=True):
|
||||
if self._remaining_pairings is None or not cache:
|
||||
ps = self.read_register(_R.receiver_connection)
|
||||
ps = self.read_register(Registers.RECEIVER_CONNECTION)
|
||||
if ps is not None:
|
||||
ps = ord(ps[2:3])
|
||||
self._remaining_pairings = ps - 5 if ps >= 5 else -1
|
||||
self._remaining_pairings = extract_remaining_pairings(ps)
|
||||
return self._remaining_pairings
|
||||
|
||||
def enable_connection_notifications(self, enable=True):
|
||||
@@ -137,7 +221,7 @@ class Receiver:
|
||||
return False
|
||||
|
||||
if enable:
|
||||
set_flag_bits = hidpp10_constants.NOTIFICATION_FLAG.wireless | hidpp10_constants.NOTIFICATION_FLAG.software_present
|
||||
set_flag_bits = NotificationFlag.WIRELESS | NotificationFlag.SOFTWARE_PRESENT
|
||||
else:
|
||||
set_flag_bits = 0
|
||||
ok = _hidpp10.set_notification_flags(self, set_flag_bits)
|
||||
@@ -146,30 +230,32 @@ class Receiver:
|
||||
return None
|
||||
|
||||
flag_bits = _hidpp10.get_notification_flags(self)
|
||||
flag_names = None if flag_bits is None else tuple(hidpp10_constants.NOTIFICATION_FLAG.flag_names(flag_bits))
|
||||
if flag_bits is None:
|
||||
flag_names = None
|
||||
else:
|
||||
flag_names = hidpp10_constants.NotificationFlag.flag_names(flag_bits)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: receiver notifications %s => %s", self, "enabled" if enable else "disabled", flag_names)
|
||||
return flag_bits
|
||||
|
||||
def device_codename(self, n):
|
||||
codename = self.read_register(_R.receiver_info, _IR.device_name + n - 1)
|
||||
codename = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.DEVICE_NAME + n - 1)
|
||||
if codename:
|
||||
codename = codename[2 : 2 + ord(codename[1:2])]
|
||||
return codename.decode("ascii")
|
||||
return extract_codename(codename)
|
||||
|
||||
def notify_devices(self):
|
||||
"""Scan all devices."""
|
||||
if self.handle:
|
||||
if not self.write_register(_R.receiver_connection, 0x02):
|
||||
if not self.write_register(Registers.RECEIVER_CONNECTION, 0x02):
|
||||
logger.warning("%s: failed to trigger device link notifications", self)
|
||||
|
||||
def notification_information(self, number, notification):
|
||||
def notification_information(self, number, notification: HIDPPNotification) -> tuple[bool, bool, typing.Any, str]:
|
||||
"""Extract information from unifying-style notification"""
|
||||
assert notification.address != 0x02
|
||||
online = not bool(notification.data[0] & 0x40)
|
||||
encrypted = bool(notification.data[0] & 0x20) or notification.address == 0x10
|
||||
kind = hidpp10_constants.DEVICE_KIND[notification.data[0] & 0x0F]
|
||||
wpid = (notification.data[2:3] + notification.data[1:2]).hex().upper()
|
||||
kind = extract_device_kind(notification.data[0] & 0x0F)
|
||||
wpid = extract_wpid(notification.data[2:3] + notification.data[1:2])
|
||||
return online, encrypted, wpid, kind
|
||||
|
||||
def device_pairing_information(self, n: int) -> dict:
|
||||
@@ -177,30 +263,31 @@ class Receiver:
|
||||
polling_rate = ""
|
||||
serial = None
|
||||
power_switch = "(unknown)"
|
||||
pair_info = self.read_register(_R.receiver_info, _IR.pairing_information + n - 1)
|
||||
pair_info = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.PAIRING_INFORMATION + n - 1)
|
||||
if pair_info: # a receiver that uses Unifying-style pairing registers
|
||||
wpid = pair_info[3:5].hex().upper()
|
||||
kind = hidpp10_constants.DEVICE_KIND[pair_info[7] & 0x0F]
|
||||
polling_rate = str(pair_info[2]) + "ms"
|
||||
wpid = extract_wpid(pair_info[3:5])
|
||||
kind = extract_device_kind(pair_info[7] & 0x0F)
|
||||
polling_rate_ms = extract_polling_rate(pair_info)
|
||||
polling_rate = f"{polling_rate_ms}ms"
|
||||
elif not self.receiver_kind == "unifying": # may be an old Nano receiver
|
||||
device_info = self.read_register(_R.receiver_info, 0x04) # undocumented
|
||||
device_info = self.read_register(Registers.RECEIVER_INFO, 0x04) # undocumented
|
||||
if device_info:
|
||||
logger.warning("using undocumented register for device wpid")
|
||||
wpid = device_info[3:5].hex().upper()
|
||||
kind = hidpp10_constants.DEVICE_KIND[0x00] # unknown kind
|
||||
wpid = extract_wpid(device_info[3:5])
|
||||
kind = extract_device_kind(0x00) # unknown kind
|
||||
else:
|
||||
raise exceptions.NoSuchDevice(number=n, receiver=self, error="read pairing information - non-unifying")
|
||||
else:
|
||||
raise exceptions.NoSuchDevice(number=n, receiver=self, error="read pairing information")
|
||||
pair_info = self.read_register(_R.receiver_info, _IR.extended_pairing_information + n - 1)
|
||||
pair_info = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.EXTENDED_PAIRING_INFORMATION + n - 1)
|
||||
if pair_info:
|
||||
power_switch = hidpp10_constants.POWER_SWITCH_LOCATION[pair_info[9] & 0x0F]
|
||||
serial = pair_info[1:5].hex().upper()
|
||||
power_switch = extract_power_switch_location(pair_info)
|
||||
serial = extract_serial(pair_info[1:5])
|
||||
else: # some Nano receivers?
|
||||
pair_info = self.read_register(0x2D5) # undocumented and questionable
|
||||
if pair_info:
|
||||
logger.warning("using undocumented register for device serial number")
|
||||
serial = pair_info[1:5].hex().upper()
|
||||
serial = extract_serial(pair_info[1:5])
|
||||
return {"wpid": wpid, "kind": kind, "polling": polling_rate, "serial": serial, "power_switch": power_switch}
|
||||
|
||||
def register_new_device(self, number, notification=None):
|
||||
@@ -208,7 +295,7 @@ class Receiver:
|
||||
raise IndexError(f"{self}: device number {int(number)} already registered")
|
||||
|
||||
assert notification is None or notification.devnumber == number
|
||||
assert notification is None or notification.sub_id == 0x41
|
||||
assert notification is None or notification.sub_id == Notification.DJ_PAIRING
|
||||
|
||||
try:
|
||||
time.sleep(0.05) # let receiver settle
|
||||
@@ -225,7 +312,7 @@ class Receiver:
|
||||
logger.warning("mismatch on device kind %s %s", info["kind"], nkind)
|
||||
else:
|
||||
online = True
|
||||
dev = Device(self, number, online, pairing_info=info, setting_callback=self.setting_callback)
|
||||
dev = Device(self.low_level, self, number, online, pairing_info=info, setting_callback=self.setting_callback)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: found new device %d (%s)", self, number, dev.wpid)
|
||||
self._devices[number] = dev
|
||||
@@ -239,25 +326,24 @@ class Receiver:
|
||||
def set_lock(self, lock_closed=True, device=0, timeout=0):
|
||||
if self.handle:
|
||||
action = 0x02 if lock_closed else 0x01
|
||||
reply = self.write_register(_R.receiver_pairing, action, device, timeout)
|
||||
reply = self.write_register(Registers.RECEIVER_PAIRING, action, device, timeout)
|
||||
if reply:
|
||||
return True
|
||||
logger.warning("%s: failed to %s the receiver lock", self, "close" if lock_closed else "open")
|
||||
|
||||
def count(self):
|
||||
count = self.read_register(_R.receiver_connection)
|
||||
return 0 if count is None else ord(count[1:2])
|
||||
count = self.read_register(Registers.RECEIVER_CONNECTION)
|
||||
if count is None:
|
||||
return 0
|
||||
return extract_connection_count(count)
|
||||
|
||||
def request(self, request_id, *params):
|
||||
if bool(self):
|
||||
return _base.request(self.handle, 0xFF, request_id, *params)
|
||||
return self.low_level.request(self.handle, 0xFF, request_id, *params)
|
||||
|
||||
def reset_pairing(self):
|
||||
self.pairing = Pairing()
|
||||
|
||||
read_register = hidpp10.read_register
|
||||
write_register = hidpp10.write_register
|
||||
|
||||
def __iter__(self):
|
||||
connected_devices = self.count()
|
||||
found_devices = 0
|
||||
@@ -325,7 +411,7 @@ class Receiver:
|
||||
|
||||
def _unpair_device_per_receiver(self, key):
|
||||
"""Receiver specific unpairing."""
|
||||
return self.write_register(_R.receiver_pairing, 0x03, key)
|
||||
return self.write_register(Registers.RECEIVER_PAIRING, 0x03, key)
|
||||
|
||||
def __len__(self):
|
||||
return len([d for d in self._devices.values() if d is not None])
|
||||
@@ -369,26 +455,26 @@ class Receiver:
|
||||
class BoltReceiver(Receiver):
|
||||
"""Bolt receivers use a different pairing prototol and have different pairing registers"""
|
||||
|
||||
def __init__(self, receiver_kind, product_info, handle, path, product_id, setting_callback=None):
|
||||
super().__init__(receiver_kind, product_info, handle, path, product_id, setting_callback)
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def initialize(self, product_info: dict):
|
||||
serial_reply = self.read_register(_R.bolt_uniqueId)
|
||||
self.serial = serial_reply.hex().upper()
|
||||
serial_reply = self.read_register(Registers.BOLT_UNIQUE_ID)
|
||||
self.serial = extract_serial(serial_reply)
|
||||
self.max_devices = product_info.get("max_devices", 1)
|
||||
|
||||
def device_codename(self, n):
|
||||
codename = self.read_register(_R.receiver_info, _IR.bolt_device_name + n, 0x01)
|
||||
codename = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.BOLT_DEVICE_NAME + n, 0x01)
|
||||
if codename:
|
||||
codename = codename[3 : 3 + min(14, ord(codename[2:3]))]
|
||||
return codename.decode("ascii")
|
||||
|
||||
def device_pairing_information(self, n: int) -> dict:
|
||||
pair_info = self.read_register(_R.receiver_info, _IR.bolt_pairing_information + n)
|
||||
pair_info = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.BOLT_PAIRING_INFORMATION + n)
|
||||
if pair_info:
|
||||
wpid = (pair_info[3:4] + pair_info[2:3]).hex().upper()
|
||||
kind = hidpp10_constants.DEVICE_KIND[pair_info[1] & 0x0F]
|
||||
serial = pair_info[4:8].hex().upper()
|
||||
wpid = extract_wpid(pair_info[3:4] + pair_info[2:3])
|
||||
kind = extract_device_kind(pair_info[1] & 0x0F)
|
||||
serial = extract_serial(pair_info[4:8])
|
||||
return {"wpid": wpid, "kind": kind, "polling": None, "serial": serial, "power_switch": "(unknown)"}
|
||||
else:
|
||||
raise exceptions.NoSuchDevice(number=n, receiver=self, error="can't read Bolt pairing register")
|
||||
@@ -397,7 +483,7 @@ class BoltReceiver(Receiver):
|
||||
"""Discover Logitech Bolt devices."""
|
||||
if self.handle:
|
||||
action = 0x02 if cancel else 0x01
|
||||
reply = self.write_register(_R.bolt_device_discovery, timeout, action)
|
||||
reply = self.write_register(Registers.BOLT_DEVICE_DISCOVERY, timeout, action)
|
||||
if reply:
|
||||
return True
|
||||
logger.warning("%s: failed to %s device discovery", self, "cancel" if cancel else "start")
|
||||
@@ -406,36 +492,36 @@ class BoltReceiver(Receiver):
|
||||
"""Pair a Bolt device."""
|
||||
if self.handle:
|
||||
action = 0x01 if pair is True else 0x03 if pair is False else 0x02
|
||||
reply = self.write_register(_R.bolt_pairing, action, slot, address, authentication, entropy)
|
||||
reply = self.write_register(Registers.BOLT_PAIRING, action, slot, address, authentication, entropy)
|
||||
if reply:
|
||||
return True
|
||||
logger.warning("%s: failed to %s device %s", self, "pair" if pair else "unpair", address)
|
||||
|
||||
def _unpair_device_per_receiver(self, key):
|
||||
"""Receiver specific unpairing."""
|
||||
return self.write_register(_R.bolt_pairing, 0x03, key)
|
||||
return self.write_register(Registers.BOLT_PAIRING, 0x03, key)
|
||||
|
||||
|
||||
class UnifyingReceiver(Receiver):
|
||||
def __init__(self, receiver_kind, product_info, handle, path, product_id, setting_callback=None):
|
||||
super().__init__(receiver_kind, product_info, handle, path, product_id, setting_callback)
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class NanoReceiver(Receiver):
|
||||
def __init__(self, receiver_kind, product_info, handle, path, product_id, setting_callback=None):
|
||||
super().__init__(receiver_kind, product_info, handle, path, product_id, setting_callback)
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class LightSpeedReceiver(Receiver):
|
||||
def __init__(self, receiver_kind, product_info, handle, path, product_id, setting_callback=None):
|
||||
super().__init__(receiver_kind, product_info, handle, path, product_id, setting_callback)
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class Ex100Receiver(Receiver):
|
||||
"""A very old style receiver, somewhat different from newer receivers"""
|
||||
|
||||
def __init__(self, receiver_kind, product_info, handle, path, product_id, setting_callback=None):
|
||||
super().__init__(receiver_kind, product_info, handle, path, product_id, setting_callback)
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def initialize(self, product_info: dict):
|
||||
self.serial = None
|
||||
@@ -446,20 +532,21 @@ class Ex100Receiver(Receiver):
|
||||
assert notification.address == 0x02
|
||||
online = True
|
||||
encrypted = bool(notification.data[0] & 0x80)
|
||||
kind = hidpp10_constants.DEVICE_KIND[_get_kind_from_index(self, number)]
|
||||
wpid = "00" + notification.data[2:3].hex().upper()
|
||||
kind = extract_device_kind(_get_kind_from_index(self, number))
|
||||
wpid = "00" + extract_wpid(notification.data[2:3])
|
||||
return online, encrypted, wpid, kind
|
||||
|
||||
def device_pairing_information(self, number: int) -> dict:
|
||||
wpid = _hid.find_paired_node_wpid(self.path, number) # extract WPID from udev path
|
||||
# extract WPID from udev path
|
||||
wpid = self.low_level.find_paired_node_wpid(self.path, number)
|
||||
if not wpid:
|
||||
logger.error("Unable to get wpid from udev for device %d of %s", number, self)
|
||||
raise exceptions.NoSuchDevice(number=number, receiver=self, error="Not present 27Mhz device")
|
||||
kind = hidpp10_constants.DEVICE_KIND[_get_kind_from_index(self, number)]
|
||||
kind = extract_device_kind(_get_kind_from_index(self, number))
|
||||
return {"wpid": wpid, "kind": kind, "polling": "", "serial": None, "power_switch": "(unknown)"}
|
||||
|
||||
|
||||
def _get_kind_from_index(receiver, index):
|
||||
def _get_kind_from_index(receiver, index: int) -> int:
|
||||
"""Get device kind from 27Mhz device index"""
|
||||
# From drivers/hid/hid-logitech-dj.c
|
||||
if index == 1: # mouse
|
||||
@@ -485,24 +572,33 @@ receiver_class_mapping = {
|
||||
}
|
||||
|
||||
|
||||
class ReceiverFactory:
|
||||
@staticmethod
|
||||
def create_receiver(device_info, setting_callback=None) -> Optional[Receiver]:
|
||||
"""Opens a Logitech Receiver found attached to the machine, by Linux device path."""
|
||||
def create_receiver(low_level: LowLevelInterface, device_info, setting_callback=None) -> Optional[Receiver]:
|
||||
"""Opens a Logitech Receiver found attached to the machine, by Linux device path."""
|
||||
|
||||
try:
|
||||
handle = _base.open_path(device_info.path)
|
||||
if handle:
|
||||
product_info = _base.product_information(device_info.product_id)
|
||||
if not product_info:
|
||||
logger.warning("Unknown receiver type: %s", device_info.product_id)
|
||||
product_info = {}
|
||||
kind = product_info.get("receiver_kind", "unknown")
|
||||
rclass = receiver_class_mapping.get(kind, Receiver)
|
||||
return rclass(kind, product_info, handle, device_info.path, device_info.product_id, setting_callback)
|
||||
except OSError as e:
|
||||
logger.exception("open %s", device_info)
|
||||
if e.errno == _errno.EACCES:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("open %s", device_info)
|
||||
try:
|
||||
handle = low_level.open_path(device_info.path)
|
||||
if handle:
|
||||
usb_id = device_info.product_id
|
||||
if isinstance(usb_id, str):
|
||||
usb_id = int(usb_id, 16)
|
||||
try:
|
||||
product_info = low_level.product_information(usb_id)
|
||||
except ValueError:
|
||||
product_info = {}
|
||||
kind = product_info.get("receiver_kind", "unknown")
|
||||
rclass = receiver_class_mapping.get(kind, Receiver)
|
||||
return rclass(
|
||||
low_level,
|
||||
kind,
|
||||
product_info,
|
||||
handle,
|
||||
device_info.path,
|
||||
device_info.product_id,
|
||||
setting_callback,
|
||||
)
|
||||
except OSError as e:
|
||||
logger.exception("open %s", device_info)
|
||||
if e.errno == errno.EACCES:
|
||||
raise e
|
||||
except Exception:
|
||||
logger.exception("open %s", device_info)
|
||||
|
||||
@@ -13,54 +13,39 @@
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import struct
|
||||
import time
|
||||
|
||||
from struct import unpack as _unpack
|
||||
from time import sleep as _sleep
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
from solaar.i18n import _
|
||||
|
||||
from . import hidpp20_constants as _hidpp20_constants
|
||||
from .common import NamedInt as _NamedInt
|
||||
from .common import NamedInts as _NamedInts
|
||||
from .common import bytes2int as _bytes2int
|
||||
from .common import int2bytes as _int2bytes
|
||||
from . import common
|
||||
from . import hidpp20_constants
|
||||
from . import settings_validator
|
||||
from .common import NamedInt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
SENSITIVITY_IGNORE = "ignore"
|
||||
KIND = _NamedInts(
|
||||
toggle=0x01,
|
||||
choice=0x02,
|
||||
range=0x04,
|
||||
map_choice=0x0A,
|
||||
multiple_toggle=0x10,
|
||||
packed_range=0x20,
|
||||
multiple_range=0x40,
|
||||
hetero=0x80,
|
||||
)
|
||||
|
||||
|
||||
def bool_or_toggle(current, new):
|
||||
if isinstance(new, bool):
|
||||
return new
|
||||
try:
|
||||
return bool(int(new))
|
||||
except (TypeError, ValueError):
|
||||
new = str(new).lower()
|
||||
if new in ("true", "yes", "on", "t", "y"):
|
||||
return True
|
||||
if new in ("false", "no", "off", "f", "n"):
|
||||
return False
|
||||
if new in ("~", "toggle"):
|
||||
return not current
|
||||
return None
|
||||
class Kind(IntEnum):
|
||||
NONE = 0
|
||||
TOGGLE = 0x01
|
||||
CHOICE = 0x02
|
||||
RANGE = 0x04
|
||||
MAP_CHOICE = 0x0A
|
||||
MULTIPLE_TOGGLE = 0x10
|
||||
PACKED_RANGE = 0x20
|
||||
MULTIPLE_RANGE = 0x40
|
||||
HETERO = 0x80
|
||||
MAP_RANGE = 0x102
|
||||
COLOR = 0x200
|
||||
|
||||
|
||||
class Setting:
|
||||
@@ -73,6 +58,7 @@ class Setting:
|
||||
rw_options = {}
|
||||
validator_class = None
|
||||
validator_options = {}
|
||||
display = True # display setting in UI
|
||||
|
||||
def __init__(self, device, rw, validator):
|
||||
self._device = device
|
||||
@@ -105,15 +91,15 @@ class Setting:
|
||||
assert hasattr(self, "_value")
|
||||
assert hasattr(self, "_device")
|
||||
|
||||
return self._validator.choices if self._validator and self._validator.kind & KIND.choice else None
|
||||
return self._validator.choices if self._validator and self._validator.kind & Kind.CHOICE else None
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
assert hasattr(self, "_value")
|
||||
assert hasattr(self, "_device")
|
||||
|
||||
if self._validator.kind == KIND.range:
|
||||
return (self._validator.min_value, self._validator.max_value)
|
||||
if self._validator.kind == Kind.RANGE:
|
||||
return self._validator.min_value, self._validator.max_value
|
||||
|
||||
def _pre_read(self, cached, key=None):
|
||||
if self.persist and self._value is None and getattr(self._device, "persister", None):
|
||||
@@ -295,7 +281,7 @@ class Settings(Setting):
|
||||
self._value[int(key)] = value
|
||||
self._pre_write(save)
|
||||
|
||||
def write_key_value(self, key, value, save=True):
|
||||
def write_key_value(self, key, value, save=True) -> Any | None:
|
||||
assert hasattr(self, "_value")
|
||||
assert hasattr(self, "_device")
|
||||
assert key is not None
|
||||
@@ -613,9 +599,9 @@ class RangeFieldSetting(Setting):
|
||||
class RegisterRW:
|
||||
__slots__ = ("register",)
|
||||
|
||||
kind = _NamedInt(0x01, _("register"))
|
||||
kind = NamedInt(0x01, _("register"))
|
||||
|
||||
def __init__(self, register):
|
||||
def __init__(self, register: int):
|
||||
assert isinstance(register, int)
|
||||
self.register = register
|
||||
|
||||
@@ -627,12 +613,21 @@ class RegisterRW:
|
||||
|
||||
|
||||
class FeatureRW:
|
||||
kind = _NamedInt(0x02, _("feature"))
|
||||
kind = NamedInt(0x02, _("feature"))
|
||||
default_read_fnid = 0x00
|
||||
default_write_fnid = 0x10
|
||||
|
||||
def __init__(self, feature, read_fnid=0x00, write_fnid=0x10, prefix=b"", suffix=b"", read_prefix=b"", no_reply=False):
|
||||
assert isinstance(feature, _NamedInt)
|
||||
def __init__(
|
||||
self,
|
||||
feature: hidpp20_constants.SupportedFeature,
|
||||
read_fnid=0x00,
|
||||
write_fnid=0x10,
|
||||
prefix=b"",
|
||||
suffix=b"",
|
||||
read_prefix=b"",
|
||||
no_reply=False,
|
||||
):
|
||||
assert isinstance(feature, hidpp20_constants.SupportedFeature)
|
||||
self.feature = feature
|
||||
self.read_fnid = read_fnid
|
||||
self.write_fnid = write_fnid
|
||||
@@ -643,7 +638,10 @@ class FeatureRW:
|
||||
|
||||
def read(self, device, data_bytes=b""):
|
||||
assert self.feature is not None
|
||||
return device.feature_request(self.feature, self.read_fnid, self.prefix, self.read_prefix, data_bytes)
|
||||
if self.read_fnid is not None:
|
||||
return device.feature_request(self.feature, self.read_fnid, self.prefix, self.read_prefix, data_bytes)
|
||||
else:
|
||||
return b""
|
||||
|
||||
def write(self, device, data_bytes):
|
||||
assert self.feature is not None
|
||||
@@ -653,20 +651,20 @@ class FeatureRW:
|
||||
|
||||
|
||||
class FeatureRWMap(FeatureRW):
|
||||
kind = _NamedInt(0x02, _("feature"))
|
||||
kind = NamedInt(0x02, _("feature"))
|
||||
default_read_fnid = 0x00
|
||||
default_write_fnid = 0x10
|
||||
default_key_byte_count = 1
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
feature,
|
||||
feature: hidpp20_constants.SupportedFeature,
|
||||
read_fnid=default_read_fnid,
|
||||
write_fnid=default_write_fnid,
|
||||
key_byte_count=default_key_byte_count,
|
||||
no_reply=False,
|
||||
):
|
||||
assert isinstance(feature, _NamedInt)
|
||||
assert isinstance(feature, hidpp20_constants.SupportedFeature)
|
||||
self.feature = feature
|
||||
self.read_fnid = read_fnid
|
||||
self.write_fnid = write_fnid
|
||||
@@ -675,717 +673,16 @@ class FeatureRWMap(FeatureRW):
|
||||
|
||||
def read(self, device, key):
|
||||
assert self.feature is not None
|
||||
key_bytes = _int2bytes(key, self.key_byte_count)
|
||||
key_bytes = common.int2bytes(key, self.key_byte_count)
|
||||
return device.feature_request(self.feature, self.read_fnid, key_bytes)
|
||||
|
||||
def write(self, device, key, data_bytes):
|
||||
assert self.feature is not None
|
||||
key_bytes = _int2bytes(key, self.key_byte_count)
|
||||
key_bytes = common.int2bytes(key, self.key_byte_count)
|
||||
reply = device.feature_request(self.feature, self.write_fnid, key_bytes, data_bytes, no_reply=self.no_reply)
|
||||
return reply if not self.no_reply else True
|
||||
|
||||
|
||||
class Validator:
|
||||
@classmethod
|
||||
def build(cls, setting_class, device, **kwargs):
|
||||
return cls(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def to_string(cls, value):
|
||||
return str(value)
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) != 1:
|
||||
return False
|
||||
return args[0] == current
|
||||
|
||||
|
||||
class BooleanValidator(Validator):
|
||||
__slots__ = ("true_value", "false_value", "read_skip_byte_count", "write_prefix_bytes", "mask", "needs_current_value")
|
||||
|
||||
kind = KIND.toggle
|
||||
default_true = 0x01
|
||||
default_false = 0x00
|
||||
# mask specifies all the affected bits in the value
|
||||
default_mask = 0xFF
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
true_value=default_true,
|
||||
false_value=default_false,
|
||||
mask=default_mask,
|
||||
read_skip_byte_count=0,
|
||||
write_prefix_bytes=b"",
|
||||
):
|
||||
if isinstance(true_value, int):
|
||||
assert isinstance(false_value, int)
|
||||
if mask is None:
|
||||
mask = self.default_mask
|
||||
else:
|
||||
assert isinstance(mask, int)
|
||||
assert true_value & false_value == 0
|
||||
assert true_value & mask == true_value
|
||||
assert false_value & mask == false_value
|
||||
self.needs_current_value = mask != self.default_mask
|
||||
elif isinstance(true_value, bytes):
|
||||
if false_value is None or false_value == self.default_false:
|
||||
false_value = b"\x00" * len(true_value)
|
||||
else:
|
||||
assert isinstance(false_value, bytes)
|
||||
if mask is None or mask == self.default_mask:
|
||||
mask = b"\xFF" * len(true_value)
|
||||
else:
|
||||
assert isinstance(mask, bytes)
|
||||
assert len(mask) == len(true_value) == len(false_value)
|
||||
tv = _bytes2int(true_value)
|
||||
fv = _bytes2int(false_value)
|
||||
mv = _bytes2int(mask)
|
||||
assert tv != fv # true and false might be something other than bit values
|
||||
assert tv & mv == tv
|
||||
assert fv & mv == fv
|
||||
self.needs_current_value = any(m != 0xFF for m in mask)
|
||||
else:
|
||||
raise Exception(f"invalid mask '{mask!r}', type {type(mask)}")
|
||||
|
||||
self.true_value = true_value
|
||||
self.false_value = false_value
|
||||
self.mask = mask
|
||||
self.read_skip_byte_count = read_skip_byte_count
|
||||
self.write_prefix_bytes = write_prefix_bytes
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
reply_bytes = reply_bytes[self.read_skip_byte_count :]
|
||||
if isinstance(self.mask, int):
|
||||
reply_value = ord(reply_bytes[:1]) & self.mask
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("BooleanValidator: validate read %r => %02X", reply_bytes, reply_value)
|
||||
if reply_value == self.true_value:
|
||||
return True
|
||||
if reply_value == self.false_value:
|
||||
return False
|
||||
logger.warning(
|
||||
"BooleanValidator: reply %02X mismatched %02X/%02X/%02X",
|
||||
reply_value,
|
||||
self.true_value,
|
||||
self.false_value,
|
||||
self.mask,
|
||||
)
|
||||
return False
|
||||
|
||||
count = len(self.mask)
|
||||
mask = _bytes2int(self.mask)
|
||||
reply_value = _bytes2int(reply_bytes[:count]) & mask
|
||||
|
||||
true_value = _bytes2int(self.true_value)
|
||||
if reply_value == true_value:
|
||||
return True
|
||||
|
||||
false_value = _bytes2int(self.false_value)
|
||||
if reply_value == false_value:
|
||||
return False
|
||||
|
||||
logger.warning(
|
||||
"BooleanValidator: reply %r mismatched %r/%r/%r", reply_bytes, self.true_value, self.false_value, self.mask
|
||||
)
|
||||
return False
|
||||
|
||||
def prepare_write(self, new_value, current_value=None):
|
||||
if new_value is None:
|
||||
new_value = False
|
||||
else:
|
||||
assert isinstance(new_value, bool), f"New value {new_value} for boolean setting is not a boolean"
|
||||
|
||||
to_write = self.true_value if new_value else self.false_value
|
||||
|
||||
if isinstance(self.mask, int):
|
||||
if current_value is not None and self.needs_current_value:
|
||||
to_write |= ord(current_value[:1]) & (0xFF ^ self.mask)
|
||||
if current_value is not None and to_write == ord(current_value[:1]):
|
||||
return None
|
||||
to_write = bytes([to_write])
|
||||
else:
|
||||
to_write = bytearray(to_write)
|
||||
count = len(self.mask)
|
||||
for i in range(0, count):
|
||||
b = ord(to_write[i : i + 1])
|
||||
m = ord(self.mask[i : i + 1])
|
||||
assert b & m == b
|
||||
# b &= m
|
||||
if current_value is not None and self.needs_current_value:
|
||||
b |= ord(current_value[i : i + 1]) & (0xFF ^ m)
|
||||
to_write[i] = b
|
||||
to_write = bytes(to_write)
|
||||
|
||||
if current_value is not None and to_write == current_value[: len(to_write)]:
|
||||
return None
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("BooleanValidator: prepare_write(%s, %s) => %r", new_value, current_value, to_write)
|
||||
|
||||
return self.write_prefix_bytes + to_write
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 1:
|
||||
return None
|
||||
val = bool_or_toggle(current, args[0])
|
||||
return [val] if val is not None else None
|
||||
|
||||
|
||||
class BitFieldValidator(Validator):
|
||||
__slots__ = ("byte_count", "options")
|
||||
|
||||
kind = KIND.multiple_toggle
|
||||
|
||||
def __init__(self, options, byte_count=None):
|
||||
assert isinstance(options, list)
|
||||
self.options = options
|
||||
self.byte_count = (max(x.bit_length() for x in options) + 7) // 8
|
||||
if byte_count:
|
||||
assert isinstance(byte_count, int) and byte_count >= self.byte_count
|
||||
self.byte_count = byte_count
|
||||
|
||||
def to_string(self, value):
|
||||
def element_to_string(key, val):
|
||||
k = next((k for k in self.options if int(key) == k), None)
|
||||
return str(k) + ":" + str(val) if k is not None else "?"
|
||||
|
||||
return "{" + ", ".join([element_to_string(k, value[k]) for k in value]) + "}"
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
r = _bytes2int(reply_bytes[: self.byte_count])
|
||||
value = {int(k): False for k in self.options}
|
||||
m = 1
|
||||
for _ignore in range(8 * self.byte_count):
|
||||
if m in self.options:
|
||||
value[int(m)] = bool(r & m)
|
||||
m <<= 1
|
||||
return value
|
||||
|
||||
def prepare_write(self, new_value):
|
||||
assert isinstance(new_value, dict)
|
||||
w = 0
|
||||
for k, v in new_value.items():
|
||||
if v:
|
||||
w |= int(k)
|
||||
return _int2bytes(w, self.byte_count)
|
||||
|
||||
def get_options(self):
|
||||
return self.options
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 2:
|
||||
return None
|
||||
key = next((key for key in self.options if key == args[0]), None)
|
||||
if key is None:
|
||||
return None
|
||||
val = bool_or_toggle(current[int(key)], args[1])
|
||||
return None if val is None else [int(key), val]
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) != 2:
|
||||
return False
|
||||
key = next((key for key in self.options if key == args[0]), None)
|
||||
if key is None:
|
||||
return False
|
||||
return args[1] == current[int(key)]
|
||||
|
||||
|
||||
class BitFieldWithOffsetAndMaskValidator(Validator):
|
||||
__slots__ = ("byte_count", "options", "_option_from_key", "_mask_from_offset", "_option_from_offset_mask")
|
||||
|
||||
kind = KIND.multiple_toggle
|
||||
sep = 0x01
|
||||
|
||||
def __init__(self, options, om_method=None, byte_count=None):
|
||||
assert isinstance(options, list)
|
||||
# each element of options is an instance of a class
|
||||
# that has an id (which is used as an index in other dictionaries)
|
||||
# and where om_method is a method that returns a byte offset and byte mask
|
||||
# that says how to access and modify the bit toggle for the option
|
||||
self.options = options
|
||||
self.om_method = om_method
|
||||
# to retrieve the options efficiently:
|
||||
self._option_from_key = {}
|
||||
self._mask_from_offset = {}
|
||||
self._option_from_offset_mask = {}
|
||||
for opt in options:
|
||||
offset, mask = om_method(opt)
|
||||
self._option_from_key[int(opt)] = opt
|
||||
try:
|
||||
self._mask_from_offset[offset] |= mask
|
||||
except KeyError:
|
||||
self._mask_from_offset[offset] = mask
|
||||
try:
|
||||
mask_to_opt = self._option_from_offset_mask[offset]
|
||||
except KeyError:
|
||||
mask_to_opt = {}
|
||||
self._option_from_offset_mask[offset] = mask_to_opt
|
||||
mask_to_opt[mask] = opt
|
||||
self.byte_count = (max(om_method(x)[1].bit_length() for x in options) + 7) // 8 # is this correct??
|
||||
if byte_count:
|
||||
assert isinstance(byte_count, int) and byte_count >= self.byte_count
|
||||
self.byte_count = byte_count
|
||||
|
||||
def prepare_read(self):
|
||||
r = []
|
||||
for offset, mask in self._mask_from_offset.items():
|
||||
b = offset << (8 * (self.byte_count + 1))
|
||||
b |= (self.sep << (8 * self.byte_count)) | mask
|
||||
r.append(_int2bytes(b, self.byte_count + 2))
|
||||
return r
|
||||
|
||||
def prepare_read_key(self, key):
|
||||
option = self._option_from_key.get(key, None)
|
||||
if option is None:
|
||||
return None
|
||||
offset, mask = option.om_method(option)
|
||||
b = offset << (8 * (self.byte_count + 1))
|
||||
b |= (self.sep << (8 * self.byte_count)) | mask
|
||||
return _int2bytes(b, self.byte_count + 2)
|
||||
|
||||
def validate_read(self, reply_bytes_dict):
|
||||
values = {int(k): False for k in self.options}
|
||||
for query, b in reply_bytes_dict.items():
|
||||
offset = _bytes2int(query[0:1])
|
||||
b += (self.byte_count - len(b)) * b"\x00"
|
||||
value = _bytes2int(b[: self.byte_count])
|
||||
mask_to_opt = self._option_from_offset_mask.get(offset, {})
|
||||
m = 1
|
||||
for _ignore in range(8 * self.byte_count):
|
||||
if m in mask_to_opt:
|
||||
values[int(mask_to_opt[m])] = bool(value & m)
|
||||
m <<= 1
|
||||
return values
|
||||
|
||||
def prepare_write(self, new_value):
|
||||
assert isinstance(new_value, dict)
|
||||
w = {}
|
||||
for k, v in new_value.items():
|
||||
option = self._option_from_key[int(k)]
|
||||
offset, mask = self.om_method(option)
|
||||
if offset not in w:
|
||||
w[offset] = 0
|
||||
if v:
|
||||
w[offset] |= mask
|
||||
return [
|
||||
_int2bytes(
|
||||
(offset << (8 * (2 * self.byte_count + 1)))
|
||||
| (self.sep << (16 * self.byte_count))
|
||||
| (self._mask_from_offset[offset] << (8 * self.byte_count))
|
||||
| value,
|
||||
2 * self.byte_count + 2,
|
||||
)
|
||||
for offset, value in w.items()
|
||||
]
|
||||
|
||||
def get_options(self):
|
||||
return [int(opt) if isinstance(opt, int) else opt.as_int() for opt in self.options]
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 2:
|
||||
return None
|
||||
key = next((option.id for option in self.options if option.as_int() == args[0]), None)
|
||||
if key is None:
|
||||
return None
|
||||
val = bool_or_toggle(current[int(key)], args[1])
|
||||
return None if val is None else [int(key), val]
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) != 2:
|
||||
return False
|
||||
key = next((option.id for option in self.options if option.as_int() == args[0]), None)
|
||||
if key is None:
|
||||
return False
|
||||
return args[1] == current[int(key)]
|
||||
|
||||
|
||||
class ChoicesValidator(Validator):
|
||||
"""Translates between NamedInts and a byte sequence.
|
||||
:param choices: a list of NamedInts
|
||||
:param byte_count: the size of the derived byte sequence. If None, it
|
||||
will be calculated from the choices."""
|
||||
|
||||
kind = KIND.choice
|
||||
|
||||
def __init__(self, choices=None, byte_count=None, read_skip_byte_count=0, write_prefix_bytes=b""):
|
||||
assert choices is not None
|
||||
assert isinstance(choices, _NamedInts)
|
||||
assert len(choices) > 1
|
||||
self.choices = choices
|
||||
self.needs_current_value = False
|
||||
|
||||
max_bits = max(x.bit_length() for x in choices)
|
||||
self._byte_count = (max_bits // 8) + (1 if max_bits % 8 else 0)
|
||||
if byte_count:
|
||||
assert self._byte_count <= byte_count
|
||||
self._byte_count = byte_count
|
||||
assert self._byte_count < 8
|
||||
self._read_skip_byte_count = read_skip_byte_count
|
||||
self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b""
|
||||
assert self._byte_count + self._read_skip_byte_count <= 14
|
||||
assert self._byte_count + len(self._write_prefix_bytes) <= 14
|
||||
|
||||
def to_string(self, value):
|
||||
return str(self.choices[value]) if isinstance(value, int) else str(value)
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
reply_value = _bytes2int(reply_bytes[self._read_skip_byte_count : self._read_skip_byte_count + self._byte_count])
|
||||
valid_value = self.choices[reply_value]
|
||||
assert valid_value is not None, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
|
||||
return valid_value
|
||||
|
||||
def prepare_write(self, new_value, current_value=None):
|
||||
if new_value is None:
|
||||
value = self.choices[:][0]
|
||||
else:
|
||||
value = self.choice(new_value)
|
||||
if value is None:
|
||||
raise ValueError(f"invalid choice {new_value!r}")
|
||||
assert isinstance(value, _NamedInt)
|
||||
return self._write_prefix_bytes + value.bytes(self._byte_count)
|
||||
|
||||
def choice(self, value):
|
||||
if isinstance(value, int):
|
||||
return self.choices[value]
|
||||
try:
|
||||
int(value)
|
||||
if int(value) in self.choices:
|
||||
return self.choices[int(value)]
|
||||
except Exception:
|
||||
pass
|
||||
if value in self.choices:
|
||||
return self.choices[value]
|
||||
else:
|
||||
return None
|
||||
|
||||
def acceptable(self, args, current):
|
||||
choice = self.choice(args[0]) if len(args) == 1 else None
|
||||
return None if choice is None else [choice]
|
||||
|
||||
|
||||
class ChoicesMapValidator(ChoicesValidator):
|
||||
kind = KIND.map_choice
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
choices_map,
|
||||
key_byte_count=0,
|
||||
key_postfix_bytes=b"",
|
||||
byte_count=0,
|
||||
read_skip_byte_count=0,
|
||||
write_prefix_bytes=b"",
|
||||
extra_default=None,
|
||||
mask=-1,
|
||||
activate=0,
|
||||
):
|
||||
assert choices_map is not None
|
||||
assert isinstance(choices_map, dict)
|
||||
max_key_bits = 0
|
||||
max_value_bits = 0
|
||||
for key, choices in choices_map.items():
|
||||
assert isinstance(key, _NamedInt)
|
||||
assert isinstance(choices, _NamedInts)
|
||||
max_key_bits = max(max_key_bits, key.bit_length())
|
||||
for key_value in choices:
|
||||
assert isinstance(key_value, _NamedInt)
|
||||
max_value_bits = max(max_value_bits, key_value.bit_length())
|
||||
self._key_byte_count = (max_key_bits + 7) // 8
|
||||
if key_byte_count:
|
||||
assert self._key_byte_count <= key_byte_count
|
||||
self._key_byte_count = key_byte_count
|
||||
self._byte_count = (max_value_bits + 7) // 8
|
||||
if byte_count:
|
||||
assert self._byte_count <= byte_count
|
||||
self._byte_count = byte_count
|
||||
|
||||
self.choices = choices_map
|
||||
self.needs_current_value = False
|
||||
self.extra_default = extra_default
|
||||
self._key_postfix_bytes = key_postfix_bytes
|
||||
self._read_skip_byte_count = read_skip_byte_count if read_skip_byte_count else 0
|
||||
self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b""
|
||||
self.activate = activate
|
||||
self.mask = mask
|
||||
assert self._byte_count + self._read_skip_byte_count + self._key_byte_count <= 14
|
||||
assert self._byte_count + len(self._write_prefix_bytes) + self._key_byte_count <= 14
|
||||
|
||||
def to_string(self, value):
|
||||
def element_to_string(key, val):
|
||||
k, c = next(((k, c) for k, c in self.choices.items() if int(key) == k), (None, None))
|
||||
return str(k) + ":" + str(c[val]) if k is not None else "?"
|
||||
|
||||
return "{" + ", ".join([element_to_string(k, value[k]) for k in sorted(value)]) + "}"
|
||||
|
||||
def validate_read(self, reply_bytes, key):
|
||||
start = self._key_byte_count + self._read_skip_byte_count
|
||||
end = start + self._byte_count
|
||||
reply_value = _bytes2int(reply_bytes[start:end]) & self.mask
|
||||
# reprogrammable keys starts out as 0, which is not a choice, so don't use assert here
|
||||
if self.extra_default is not None and self.extra_default == reply_value:
|
||||
return int(self.choices[key][0])
|
||||
if reply_value not in self.choices[key]:
|
||||
assert reply_value in self.choices[key], "%s: failed to validate read value %02X" % (
|
||||
self.__class__.__name__,
|
||||
reply_value,
|
||||
)
|
||||
return reply_value
|
||||
|
||||
def prepare_key(self, key):
|
||||
return key.to_bytes(self._key_byte_count, "big") + self._key_postfix_bytes
|
||||
|
||||
def prepare_write(self, key, new_value):
|
||||
choices = self.choices.get(key)
|
||||
if choices is None or (new_value not in choices and new_value != self.extra_default):
|
||||
logger.error("invalid choice %r for %s", new_value, key)
|
||||
return None
|
||||
new_value = new_value | self.activate
|
||||
return self._write_prefix_bytes + new_value.to_bytes(self._byte_count, "big")
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 2:
|
||||
return None
|
||||
key, choices = next(((key, item) for key, item in self.choices.items() if key == args[0]), (None, None))
|
||||
if choices is None or args[1] not in choices:
|
||||
return None
|
||||
choice = next((item for item in choices if item == args[1]), None)
|
||||
return [int(key), int(choice)] if choice is not None else None
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) != 2:
|
||||
return False
|
||||
key = next((key for key in self.choices if key == int(args[0])), None)
|
||||
if key is None:
|
||||
return False
|
||||
return args[1] == current[int(key)]
|
||||
|
||||
|
||||
class RangeValidator(Validator):
|
||||
kind = KIND.range
|
||||
"""Translates between integers and a byte sequence.
|
||||
:param min_value: minimum accepted value (inclusive)
|
||||
:param max_value: maximum accepted value (inclusive)
|
||||
:param byte_count: the size of the derived byte sequence. If None, it
|
||||
will be calculated from the range."""
|
||||
min_value = 0
|
||||
max_value = 255
|
||||
|
||||
@classmethod
|
||||
def build(cls, setting_class, device, **kwargs):
|
||||
kwargs["min_value"] = setting_class.min_value
|
||||
kwargs["max_value"] = setting_class.max_value
|
||||
return cls(**kwargs)
|
||||
|
||||
def __init__(self, min_value=0, max_value=255, byte_count=1):
|
||||
assert max_value > min_value
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
self.needs_current_value = True # read and check before write (needed for ADC power and probably a good idea anyway)
|
||||
|
||||
self._byte_count = math.ceil(math.log(max_value + 1, 256))
|
||||
if byte_count:
|
||||
assert self._byte_count <= byte_count
|
||||
self._byte_count = byte_count
|
||||
assert self._byte_count < 8
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
reply_value = _bytes2int(reply_bytes[: self._byte_count])
|
||||
assert reply_value >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
|
||||
assert reply_value <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
|
||||
return reply_value
|
||||
|
||||
def prepare_write(self, new_value, current_value=None):
|
||||
if new_value < self.min_value or new_value > self.max_value:
|
||||
raise ValueError(f"invalid choice {new_value!r}")
|
||||
current_value = self.validate_read(current_value) if current_value is not None else None
|
||||
to_write = _int2bytes(new_value, self._byte_count)
|
||||
# current value is known and same as value to be written return None to signal not to write it
|
||||
return None if current_value is not None and current_value == new_value else to_write
|
||||
|
||||
def acceptable(self, args, current):
|
||||
arg = args[0]
|
||||
# None if len(args) != 1 or type(arg) != int or arg < self.min_value or arg > self.max_value else args)
|
||||
return None if len(args) != 1 or isinstance(arg, int) or arg < self.min_value or arg > self.max_value else args
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) == 1:
|
||||
return args[0] == current
|
||||
elif len(args) == 2:
|
||||
return args[0] <= current and current <= args[1]
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class HeteroValidator(Validator):
|
||||
kind = KIND.hetero
|
||||
|
||||
@classmethod
|
||||
def build(cls, setting_class, device, **kwargs):
|
||||
return cls(**kwargs)
|
||||
|
||||
def __init__(self, data_class=None, options=None, readable=True):
|
||||
assert data_class is not None and options is not None
|
||||
self.data_class = data_class
|
||||
self.options = options
|
||||
self.readable = readable
|
||||
self.needs_current_value = False
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
if self.readable:
|
||||
reply_value = self.data_class.from_bytes(reply_bytes, options=self.options)
|
||||
return reply_value
|
||||
|
||||
def prepare_write(self, new_value, current_value=None):
|
||||
to_write = new_value.to_bytes(options=self.options)
|
||||
return to_write
|
||||
|
||||
def acceptable(self, args, current): # should this actually do some checking?
|
||||
return True
|
||||
|
||||
|
||||
class PackedRangeValidator(Validator):
|
||||
kind = KIND.packed_range
|
||||
"""Several range values, all the same size, all the same min and max"""
|
||||
min_value = 0
|
||||
max_value = 255
|
||||
count = 1
|
||||
rsbc = 0
|
||||
write_prefix_bytes = b""
|
||||
|
||||
def __init__(
|
||||
self, keys, min_value=0, max_value=255, count=1, byte_count=1, read_skip_byte_count=0, write_prefix_bytes=b""
|
||||
):
|
||||
assert max_value > min_value
|
||||
self.needs_current_value = True
|
||||
self.keys = keys
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
self.count = count
|
||||
self.bc = math.ceil(math.log(max_value + 1 - min(0, min_value), 256))
|
||||
if byte_count:
|
||||
assert self.bc <= byte_count
|
||||
self.bc = byte_count
|
||||
assert self.bc * self.count
|
||||
self.rsbc = read_skip_byte_count
|
||||
self.write_prefix_bytes = write_prefix_bytes
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
rvs = {
|
||||
n: _bytes2int(reply_bytes[self.rsbc + n * self.bc : self.rsbc + (n + 1) * self.bc], signed=True)
|
||||
for n in range(self.count)
|
||||
}
|
||||
for n in range(self.count):
|
||||
assert rvs[n] >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}"
|
||||
assert rvs[n] <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}"
|
||||
return rvs
|
||||
|
||||
def prepare_write(self, new_values):
|
||||
if len(new_values) != self.count:
|
||||
raise ValueError(f"wrong number of values {new_values!r}")
|
||||
for new_value in new_values.values():
|
||||
if new_value < self.min_value or new_value > self.max_value:
|
||||
raise ValueError(f"invalid value {new_value!r}")
|
||||
bytes = self.write_prefix_bytes + b"".join(_int2bytes(new_values[n], self.bc, signed=True) for n in range(self.count))
|
||||
return bytes
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 2 or int(args[0]) < 0 or int(args[0]) >= self.count:
|
||||
return None
|
||||
return None if not isinstance(args[1], int) or args[1] < self.min_value or args[1] > self.max_value else args
|
||||
|
||||
def compare(self, args, current):
|
||||
logger.warning("compare not implemented for packed range settings")
|
||||
return False
|
||||
|
||||
|
||||
class MultipleRangeValidator(Validator):
|
||||
kind = KIND.multiple_range
|
||||
|
||||
def __init__(self, items, sub_items):
|
||||
assert isinstance(items, list) # each element must have .index and its __int__ must return its id (not its index)
|
||||
assert isinstance(sub_items, dict)
|
||||
# sub_items: items -> class with .minimum, .maximum, .length (in bytes), .id (a string) and .widget (e.g. 'Scale')
|
||||
self.items = items
|
||||
self.keys = _NamedInts(**{str(item): int(item) for item in items})
|
||||
self._item_from_id = {int(k): k for k in items}
|
||||
self.sub_items = sub_items
|
||||
|
||||
def prepare_read_item(self, item):
|
||||
return _int2bytes((self._item_from_id[int(item)].index << 1) | 0xFF, 2)
|
||||
|
||||
def validate_read_item(self, reply_bytes, item):
|
||||
item = self._item_from_id[int(item)]
|
||||
start = 0
|
||||
value = {}
|
||||
for sub_item in self.sub_items[item]:
|
||||
r = reply_bytes[start : start + sub_item.length]
|
||||
if len(r) < sub_item.length:
|
||||
r += b"\x00" * (sub_item.length - len(value))
|
||||
v = _bytes2int(r)
|
||||
if not (sub_item.minimum < v < sub_item.maximum):
|
||||
logger.warning(
|
||||
f"{self.__class__.__name__}: failed to validate read value for {item}.{sub_item}: "
|
||||
+ f"{v} not in [{sub_item.minimum}..{sub_item.maximum}]"
|
||||
)
|
||||
value[str(sub_item)] = v
|
||||
start += sub_item.length
|
||||
return value
|
||||
|
||||
def prepare_write(self, value):
|
||||
seq = []
|
||||
w = b""
|
||||
for item in value.keys():
|
||||
_item = self._item_from_id[int(item)]
|
||||
b = _int2bytes(_item.index, 1)
|
||||
for sub_item in self.sub_items[_item]:
|
||||
try:
|
||||
v = value[int(item)][str(sub_item)]
|
||||
except KeyError:
|
||||
return None
|
||||
if not (sub_item.minimum <= v <= sub_item.maximum):
|
||||
raise ValueError(
|
||||
f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]"
|
||||
)
|
||||
b += _int2bytes(v, sub_item.length)
|
||||
if len(w) + len(b) > 15:
|
||||
seq.append(b + b"\xFF")
|
||||
w = b""
|
||||
w += b
|
||||
seq.append(w + b"\xFF")
|
||||
return seq
|
||||
|
||||
def prepare_write_item(self, item, value):
|
||||
_item = self._item_from_id[int(item)]
|
||||
w = _int2bytes(_item.index, 1)
|
||||
for sub_item in self.sub_items[_item]:
|
||||
try:
|
||||
v = value[str(sub_item)]
|
||||
except KeyError:
|
||||
return None
|
||||
if not (sub_item.minimum <= v <= sub_item.maximum):
|
||||
raise ValueError(f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]")
|
||||
w += _int2bytes(v, sub_item.length)
|
||||
return w + b"\xFF"
|
||||
|
||||
def acceptable(self, args, current):
|
||||
# just one item, with at least one sub-item
|
||||
if not isinstance(args, list) or len(args) != 2 or not isinstance(args[1], dict):
|
||||
return None
|
||||
item = next((p for p in self.items if p.id == args[0] or str(p) == args[0]), None)
|
||||
if not item:
|
||||
return None
|
||||
for sub_key, value in args[1].items():
|
||||
sub_item = next((it for it in self.sub_items[item] if it.id == sub_key), None)
|
||||
if not sub_item:
|
||||
return None
|
||||
if not isinstance(value, int) or not (sub_item.minimum <= value <= sub_item.maximum):
|
||||
return None
|
||||
return [int(item), {**args[1]}]
|
||||
|
||||
def compare(self, args, current):
|
||||
logger.warning("compare not implemented for multiple range settings")
|
||||
return False
|
||||
|
||||
|
||||
class ActionSettingRW:
|
||||
"""Special RW class for settings that turn on and off special processing when a key or button is depressed"""
|
||||
|
||||
@@ -1418,13 +715,16 @@ class ActionSettingRW:
|
||||
pass
|
||||
|
||||
def read(self, device): # need to return bytes, as if read from device
|
||||
return _int2bytes(self.key.key, 2) if self.active and self.key else b"\x00\x00"
|
||||
return common.int2bytes(self.key.key, 2) if self.active and self.key else b"\x00\x00"
|
||||
|
||||
def write(self, device, data_bytes):
|
||||
def handler(device, n): # Called on notification events from the device
|
||||
if n.sub_id < 0x40 and device.features.get_feature(n.sub_id) == _hidpp20_constants.FEATURE.REPROG_CONTROLS_V4:
|
||||
if (
|
||||
n.sub_id < 0x40
|
||||
and device.features.get_feature(n.sub_id) == hidpp20_constants.SupportedFeature.REPROG_CONTROLS_V4
|
||||
):
|
||||
if n.address == 0x00:
|
||||
cids = _unpack("!HHHH", n.data[:8])
|
||||
cids = struct.unpack("!HHHH", n.data[:8])
|
||||
if not self.pressed and int(self.key.key) in cids: # trigger key pressed
|
||||
self.pressed = True
|
||||
self.press_action()
|
||||
@@ -1438,7 +738,7 @@ class ActionSettingRW:
|
||||
self.key_action(key)
|
||||
elif n.address == 0x10:
|
||||
if self.pressed:
|
||||
dx, dy = _unpack("!hh", n.data[:4])
|
||||
dx, dy = struct.unpack("!hh", n.data[:4])
|
||||
self.move_action(dx, dy)
|
||||
|
||||
divertSetting = next(filter(lambda s: s.name == self.divert_setting_name, device.settings), None)
|
||||
@@ -1446,7 +746,7 @@ class ActionSettingRW:
|
||||
logger.warning("setting %s not found on %s", self.divert_setting_name, device.name)
|
||||
return None
|
||||
self.device = device
|
||||
key = _bytes2int(data_bytes)
|
||||
key = common.bytes2int(data_bytes)
|
||||
if key: # Enable
|
||||
self.key = next((k for k in device.keys if k.key == key), None)
|
||||
if self.key:
|
||||
@@ -1484,13 +784,13 @@ class RawXYProcessing:
|
||||
self.keys = [] # the keys that can initiate processing
|
||||
self.initiating_key = None # the key that did initiate processing
|
||||
self.active = False
|
||||
self.feature_offset = device.features[_hidpp20_constants.FEATURE.REPROG_CONTROLS_V4]
|
||||
self.feature_offset = device.features[hidpp20_constants.SupportedFeature.REPROG_CONTROLS_V4]
|
||||
assert self.feature_offset is not False
|
||||
|
||||
def handler(self, device, n): # Called on notification events from the device
|
||||
if n.sub_id < 0x40 and device.features.get_feature(n.sub_id) == _hidpp20_constants.FEATURE.REPROG_CONTROLS_V4:
|
||||
if n.sub_id < 0x40 and device.features.get_feature(n.sub_id) == hidpp20_constants.SupportedFeature.REPROG_CONTROLS_V4:
|
||||
if n.address == 0x00:
|
||||
cids = _unpack("!HHHH", n.data[:8])
|
||||
cids = struct.unpack("!HHHH", n.data[:8])
|
||||
## generalize to list of keys
|
||||
if not self.initiating_key: # no initiating key pressed
|
||||
for k in self.keys:
|
||||
@@ -1508,7 +808,7 @@ class RawXYProcessing:
|
||||
self.key_action(key)
|
||||
elif n.address == 0x10:
|
||||
if self.initiating_key:
|
||||
dx, dy = _unpack("!hh", n.data[:4])
|
||||
dx, dy = struct.unpack("!hh", n.data[:4])
|
||||
self.move_action(dx, dy)
|
||||
|
||||
def start(self, key):
|
||||
@@ -1556,8 +856,8 @@ class RawXYProcessing:
|
||||
|
||||
|
||||
def apply_all_settings(device):
|
||||
if device.features and _hidpp20_constants.FEATURE.HIRES_WHEEL in device.features:
|
||||
_sleep(0.2) # delay to try to get out of race condition with Linux HID++ driver
|
||||
if device.features and hidpp20_constants.SupportedFeature.HIRES_WHEEL in device.features:
|
||||
time.sleep(0.2) # delay to try to get out of race condition with Linux HID++ driver
|
||||
persister = getattr(device, "persister", None)
|
||||
sensitives = persister.get("_sensitive", {}) if persister else {}
|
||||
for s in device.settings:
|
||||
@@ -1566,4 +866,4 @@ def apply_all_settings(device):
|
||||
s.apply()
|
||||
|
||||
|
||||
Setting.validator_class = BooleanValidator
|
||||
Setting.validator_class = settings_validator.BooleanValidator
|
||||
|
||||
206
lib/logitech_receiver/settings_new.py
Normal file
@@ -0,0 +1,206 @@
|
||||
## Copyright (C) 2025 Solaar contributors
|
||||
##
|
||||
## This program is free software; you can redistribute it and/or modify
|
||||
## it under the terms of the GNU General Public License as published by
|
||||
## the Free Software Foundation; either version 2 of the License, or
|
||||
## (at your option) any later version.
|
||||
##
|
||||
## This program is distributed in the hope that it will be useful,
|
||||
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
## GNU General Public License for more details.
|
||||
##
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
## A new way of supporting settings, using a feature-specifi device class to store, read, and write relevant information
|
||||
## The setting uses the device class to interact with the device feature.
|
||||
## The setting uses a persist class to keep track of the setting.
|
||||
|
||||
## Interface:
|
||||
|
||||
import logging
|
||||
|
||||
from .settings import Kind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Setting:
|
||||
name = None # Solaar internal name for the setting
|
||||
label = None # Solaar user name for the setting (translatable)
|
||||
description = None # Solaar extra desciption for the setting (translatable)
|
||||
feature = None # Logitech feature that the setting uses
|
||||
min_version = 0 # Minimum version of the feature needed
|
||||
setup = None # method name on Device class to get the device object
|
||||
get = None # method name on the device object to get the setting value
|
||||
set = None # method name on the device object to set the setting value
|
||||
acceptable = None # method name on the device object to check for acceptable values
|
||||
choices_universe = None # All possible acceptable keys, for settings with keys
|
||||
kind = Kind.NONE # What GUI interface to use
|
||||
persist = True # Whether to remember the setting
|
||||
display = True # display setting in UI
|
||||
_device = None # The device that this setting is for
|
||||
_device_object = None # The object that interacts with the feature for the device
|
||||
_value = None # Stored value as maintained by Solaar, used for persistence
|
||||
|
||||
def __init__(self, device, device_object):
|
||||
self._device = device
|
||||
self._device_object = device_object
|
||||
|
||||
@classmethod
|
||||
def build(cls, device):
|
||||
cls.check_properties(cls)
|
||||
device_object = getattr(device, cls.setup)()
|
||||
if device_object:
|
||||
setting = cls(device, device_object)
|
||||
return setting
|
||||
|
||||
@classmethod
|
||||
def check_properties(cl, cls):
|
||||
assert cls.name and cls.label and cls.description, "New settings require a name, label, and description"
|
||||
assert cls.feature, "New settings require a feature"
|
||||
assert cls.setup, "New settings require a setup device method"
|
||||
assert cls.get and cls.set and cls.acceptable, "New settings require get, set, and acceptable methods"
|
||||
|
||||
def setup_from_class(self, clss):
|
||||
"""Copy settings methods for a new setting from a settting class"""
|
||||
self.name = clss.name
|
||||
self.label = clss.label
|
||||
self.description = clss.description
|
||||
self.feature = clss.feature
|
||||
self.min_version = clss.min_version
|
||||
self.setup = clss.setup
|
||||
self.get = clss.get
|
||||
self.set = clss.set
|
||||
self.acceptable = clss.acceptable
|
||||
self.choices_universe = clss.choices_universe
|
||||
self.kind = clss.kind
|
||||
self.persist = clss.persist
|
||||
|
||||
def _pre_read(self, cached):
|
||||
"""Get information from and save information to the persister"""
|
||||
# Get the persister map if available and not done already
|
||||
if self.persist and self._value is None and getattr(self._device, "persister", None):
|
||||
self._value = self._device.persister.get(self.name)
|
||||
# If this is new save its current value for the next time
|
||||
if cached and self._value is not None:
|
||||
if getattr(self._device, "persister", None) and self.name not in self._device.persister:
|
||||
self._device.persister[self.name] = self._value if self.persist else None
|
||||
|
||||
def read(self, cached=True):
|
||||
"""Get all the data for the setting. If cached is True the data in the _value can be used."""
|
||||
self._pre_read(cached)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: setting read %r from %s", self.name, self._value, self._device)
|
||||
if cached and self._value is not None:
|
||||
return self._value
|
||||
if cached:
|
||||
self._value = getattr(self._device_object, self.get)()
|
||||
return self._value
|
||||
if self._device.online:
|
||||
self._value = getattr(self._device_object.query(), self.get)()
|
||||
return self._value
|
||||
|
||||
def write(self, value, save=True):
|
||||
"""Write the value to the device. If saved is True also save in the persister"""
|
||||
pass ## fill out
|
||||
|
||||
def apply(self):
|
||||
"""Write saved data to the device, using persisted data if available"""
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: apply (%s)", self.name, self._device)
|
||||
value = None
|
||||
try:
|
||||
value = self.read(self.persist) # Don't use persisted value if setting doesn't persist
|
||||
if self.persist and value is not None: # If setting doesn't persist no need to write value just read
|
||||
self.write(value, save=False)
|
||||
except Exception as e:
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("%s: error applying %s so ignore it (%s): %s", self.name, value, self._device, repr(e))
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
if self.kind == Kind.RANGE:
|
||||
return self.min_value, self.max_value
|
||||
|
||||
def val_to_string(self, value):
|
||||
return str(value)
|
||||
|
||||
|
||||
## key mapping from symbols to values????
|
||||
|
||||
|
||||
class Settings(Setting):
|
||||
"""A setting descriptor for multiple keys.
|
||||
Supported by a class that provides the interface to the device, see ForceSensingButtonArray in hidpp20.py
|
||||
Picks out a field from the mapped device feature objects."""
|
||||
|
||||
# setup creates a dictionary with entries for all the keys
|
||||
# _value is a map from keys to values
|
||||
# get, set, and acceptable are methods of dict value objects, not of the device object itself #### FIX THIS! MAYBE??
|
||||
|
||||
def __init__(self, device, device_object):
|
||||
super().__init__(device, device_object)
|
||||
self._value = {}
|
||||
|
||||
def read(self, cached=True):
|
||||
self._pre_read(cached)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: settings read %r from %s", self.name, self._value, self._device)
|
||||
for key in self._device_object:
|
||||
self.read_key(key, cached)
|
||||
return self._value
|
||||
|
||||
def read_key(self, key, cached=True):
|
||||
"""Get the data for the key. If cached is True the data in the device_object can be used."""
|
||||
self._pre_read(cached)
|
||||
if key not in self._device_object:
|
||||
logger.error("%s: settings illegal read key %r for %s", self.name, key, self._device)
|
||||
return None
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: settings key %r read %r from %s", self.name, key, self._value, self._device)
|
||||
if cached and key in self._value and self._value[key] is not None:
|
||||
return self._value[key]
|
||||
if cached:
|
||||
data = self._device_object[key]
|
||||
self._value[key] = getattr(data, self.get)()
|
||||
return self._value[key]
|
||||
if self._device.online:
|
||||
data = self._device_object.query_key(key)
|
||||
self._value[key] = getattr(data, self.get)()
|
||||
return self._value[key]
|
||||
|
||||
def write(self, value, save=True):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: settings read %r from %s", self.name, self._value, self._device)
|
||||
if isinstance(value, dict):
|
||||
for key, val in value.items():
|
||||
self.write_key_value(key, val, save)
|
||||
else: # to mimic interface for non-dict setting
|
||||
key = next(iter(self._device_object))
|
||||
self.write_key_value(key, value, save)
|
||||
return value
|
||||
|
||||
def write_key_value(self, key, value, save=True):
|
||||
"""Write the data for the key. If saved is True also save in the persister"""
|
||||
if key not in self._device_object:
|
||||
logger.error("%s: settings illegal write key %r for %s", self.name, key, self._device)
|
||||
return None
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("%s: settings write key %r value %r to %s", self.name, key, value, self._device)
|
||||
if self._device.online:
|
||||
if self._device_object[key] is None:
|
||||
self.read_key(key)
|
||||
if self._device_object[key] is None:
|
||||
logger.error("%s: settings illegal write key %r for %s", self.name, key, self._device)
|
||||
return None
|
||||
if not getattr(self._device_object[key], self.acceptable)(value):
|
||||
logger.error("%s: settings illegal write key %r value %r for %s", self.name, key, value, self._device)
|
||||
return None
|
||||
self._value[key] = value
|
||||
if self._device.persister and self.persist and save:
|
||||
self._device.persister[self.name][key] = value
|
||||
getattr(self._device_object[key], self.set)(value)
|
||||
return value
|
||||
745
lib/logitech_receiver/settings_validator.py
Normal file
@@ -0,0 +1,745 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
from logitech_receiver import common
|
||||
from logitech_receiver.common import NamedInt
|
||||
from logitech_receiver.common import NamedInts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def bool_or_toggle(current: bool | str, new: bool | str) -> bool:
|
||||
if isinstance(new, bool):
|
||||
return new
|
||||
|
||||
try:
|
||||
return bool(int(new))
|
||||
except (TypeError, ValueError):
|
||||
new = str(new).lower()
|
||||
|
||||
if new in ("true", "yes", "on", "t", "y"):
|
||||
return True
|
||||
if new in ("false", "no", "off", "f", "n"):
|
||||
return False
|
||||
if new in ("~", "toggle"):
|
||||
return not current
|
||||
return None
|
||||
|
||||
|
||||
class Kind(IntEnum):
|
||||
TOGGLE = 0x01
|
||||
CHOICE = 0x02
|
||||
RANGE = 0x04
|
||||
MAP_CHOICE = 0x0A
|
||||
MULTIPLE_TOGGLE = 0x10
|
||||
PACKED_RANGE = 0x20
|
||||
MULTIPLE_RANGE = 0x40
|
||||
HETERO = 0x80
|
||||
|
||||
|
||||
class Validator:
|
||||
@classmethod
|
||||
def build(cls, setting_class, device, **kwargs) -> Validator:
|
||||
return cls(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def to_string(cls, value) -> str:
|
||||
return str(value)
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) != 1:
|
||||
return False
|
||||
return args[0] == current
|
||||
|
||||
|
||||
class BooleanValidator(Validator):
|
||||
__slots__ = ("true_value", "false_value", "read_skip_byte_count", "write_prefix_bytes", "mask", "needs_current_value")
|
||||
|
||||
kind = Kind.TOGGLE
|
||||
default_true = 0x01
|
||||
default_false = 0x00
|
||||
# mask specifies all the affected bits in the value
|
||||
default_mask = 0xFF
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
true_value=default_true,
|
||||
false_value=default_false,
|
||||
mask=default_mask,
|
||||
read_skip_byte_count=0,
|
||||
write_prefix_bytes=b"",
|
||||
):
|
||||
if isinstance(true_value, int):
|
||||
assert isinstance(false_value, int)
|
||||
if mask is None:
|
||||
mask = self.default_mask
|
||||
else:
|
||||
assert isinstance(mask, int)
|
||||
assert true_value & false_value == 0
|
||||
assert true_value & mask == true_value
|
||||
assert false_value & mask == false_value
|
||||
self.needs_current_value = mask != self.default_mask
|
||||
elif isinstance(true_value, bytes):
|
||||
if false_value is None or false_value == self.default_false:
|
||||
false_value = b"\x00" * len(true_value)
|
||||
else:
|
||||
assert isinstance(false_value, bytes)
|
||||
if mask is None or mask == self.default_mask:
|
||||
mask = b"\xff" * len(true_value)
|
||||
else:
|
||||
assert isinstance(mask, bytes)
|
||||
assert len(mask) == len(true_value) == len(false_value)
|
||||
tv = common.bytes2int(true_value)
|
||||
fv = common.bytes2int(false_value)
|
||||
mv = common.bytes2int(mask)
|
||||
assert tv != fv # true and false might be something other than bit values
|
||||
assert tv & mv == tv
|
||||
assert fv & mv == fv
|
||||
self.needs_current_value = any(m != 0xFF for m in mask)
|
||||
else:
|
||||
raise Exception(f"invalid mask '{mask!r}', type {type(mask)}")
|
||||
|
||||
self.true_value = true_value
|
||||
self.false_value = false_value
|
||||
self.mask = mask
|
||||
self.read_skip_byte_count = read_skip_byte_count
|
||||
self.write_prefix_bytes = write_prefix_bytes
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
reply_bytes = reply_bytes[self.read_skip_byte_count :]
|
||||
if isinstance(self.mask, int):
|
||||
reply_value = ord(reply_bytes[:1]) & self.mask
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("BooleanValidator: validate read %r => %02X", reply_bytes, reply_value)
|
||||
if reply_value == self.true_value:
|
||||
return True
|
||||
if reply_value == self.false_value:
|
||||
return False
|
||||
logger.warning(
|
||||
"BooleanValidator: reply %02X mismatched %02X/%02X/%02X",
|
||||
reply_value,
|
||||
self.true_value,
|
||||
self.false_value,
|
||||
self.mask,
|
||||
)
|
||||
return False
|
||||
|
||||
count = len(self.mask)
|
||||
mask = common.bytes2int(self.mask)
|
||||
reply_value = common.bytes2int(reply_bytes[:count]) & mask
|
||||
|
||||
true_value = common.bytes2int(self.true_value)
|
||||
if reply_value == true_value:
|
||||
return True
|
||||
|
||||
false_value = common.bytes2int(self.false_value)
|
||||
if reply_value == false_value:
|
||||
return False
|
||||
|
||||
logger.warning(
|
||||
"BooleanValidator: reply %r mismatched %r/%r/%r", reply_bytes, self.true_value, self.false_value, self.mask
|
||||
)
|
||||
return False
|
||||
|
||||
def prepare_write(self, new_value, current_value=None):
|
||||
if new_value is None:
|
||||
new_value = False
|
||||
else:
|
||||
assert isinstance(new_value, bool), f"New value {new_value} for boolean setting is not a boolean"
|
||||
|
||||
to_write = self.true_value if new_value else self.false_value
|
||||
|
||||
if isinstance(self.mask, int):
|
||||
if current_value is not None and self.needs_current_value:
|
||||
to_write |= ord(current_value[:1]) & (0xFF ^ self.mask)
|
||||
if current_value is not None and to_write == ord(current_value[:1]):
|
||||
return None
|
||||
to_write = bytes([to_write])
|
||||
else:
|
||||
to_write = bytearray(to_write)
|
||||
count = len(self.mask)
|
||||
for i in range(0, count):
|
||||
b = ord(to_write[i : i + 1])
|
||||
m = ord(self.mask[i : i + 1])
|
||||
assert b & m == b
|
||||
# b &= m
|
||||
if current_value is not None and self.needs_current_value:
|
||||
b |= ord(current_value[i : i + 1]) & (0xFF ^ m)
|
||||
to_write[i] = b
|
||||
to_write = bytes(to_write)
|
||||
|
||||
if current_value is not None and to_write == current_value[: len(to_write)]:
|
||||
return None
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("BooleanValidator: prepare_write(%s, %s) => %r", new_value, current_value, to_write)
|
||||
|
||||
return self.write_prefix_bytes + to_write
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 1:
|
||||
return None
|
||||
val = bool_or_toggle(current, args[0])
|
||||
return [val] if val is not None else None
|
||||
|
||||
|
||||
class BitFieldValidator(Validator):
|
||||
__slots__ = ("byte_count", "options")
|
||||
|
||||
kind = Kind.MULTIPLE_TOGGLE
|
||||
|
||||
def __init__(self, options, byte_count=None):
|
||||
assert isinstance(options, list)
|
||||
self.options = options
|
||||
self.byte_count = (max(x.bit_length() for x in options) + 7) // 8
|
||||
if byte_count:
|
||||
assert isinstance(byte_count, int) and byte_count >= self.byte_count
|
||||
self.byte_count = byte_count
|
||||
|
||||
def to_string(self, value) -> str:
|
||||
def element_to_string(key, val):
|
||||
k = next((k for k in self.options if int(key) == k), None)
|
||||
return str(k) + ":" + str(val) if k is not None else "?"
|
||||
|
||||
return "{" + ", ".join([element_to_string(k, value[k]) for k in value]) + "}"
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
r = common.bytes2int(reply_bytes[: self.byte_count])
|
||||
value = {int(k): False for k in self.options}
|
||||
m = 1
|
||||
for _ignore in range(8 * self.byte_count):
|
||||
if m in self.options:
|
||||
value[int(m)] = bool(r & m)
|
||||
m <<= 1
|
||||
return value
|
||||
|
||||
def prepare_write(self, new_value):
|
||||
assert isinstance(new_value, dict)
|
||||
w = 0
|
||||
for k, v in new_value.items():
|
||||
if v:
|
||||
w |= int(k)
|
||||
return common.int2bytes(w, self.byte_count)
|
||||
|
||||
def get_options(self):
|
||||
return self.options
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 2:
|
||||
return None
|
||||
key = next((key for key in self.options if key == args[0]), None)
|
||||
if key is None:
|
||||
return None
|
||||
val = bool_or_toggle(current[int(key)], args[1])
|
||||
return None if val is None else [int(key), val]
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) != 2:
|
||||
return False
|
||||
key = next((key for key in self.options if key == args[0]), None)
|
||||
if key is None:
|
||||
return False
|
||||
return args[1] == current[int(key)]
|
||||
|
||||
|
||||
class BitFieldWithOffsetAndMaskValidator(Validator):
|
||||
__slots__ = ("byte_count", "options", "_option_from_key", "_mask_from_offset", "_option_from_offset_mask")
|
||||
|
||||
kind = Kind.MULTIPLE_TOGGLE
|
||||
sep = 0x01
|
||||
|
||||
def __init__(self, options, om_method=None, byte_count=None):
|
||||
assert isinstance(options, list)
|
||||
# each element of options is an instance of a class
|
||||
# that has an id (which is used as an index in other dictionaries)
|
||||
# and where om_method is a method that returns a byte offset and byte mask
|
||||
# that says how to access and modify the bit toggle for the option
|
||||
self.options = options
|
||||
self.om_method = om_method
|
||||
# to retrieve the options efficiently:
|
||||
self._option_from_key = {}
|
||||
self._mask_from_offset = {}
|
||||
self._option_from_offset_mask = {}
|
||||
for opt in options:
|
||||
offset, mask = om_method(opt)
|
||||
self._option_from_key[int(opt)] = opt
|
||||
try:
|
||||
self._mask_from_offset[offset] |= mask
|
||||
except KeyError:
|
||||
self._mask_from_offset[offset] = mask
|
||||
try:
|
||||
mask_to_opt = self._option_from_offset_mask[offset]
|
||||
except KeyError:
|
||||
mask_to_opt = {}
|
||||
self._option_from_offset_mask[offset] = mask_to_opt
|
||||
mask_to_opt[mask] = opt
|
||||
self.byte_count = (max(om_method(x)[1].bit_length() for x in options) + 7) // 8 # is this correct??
|
||||
if byte_count:
|
||||
assert isinstance(byte_count, int) and byte_count >= self.byte_count
|
||||
self.byte_count = byte_count
|
||||
|
||||
def prepare_read(self):
|
||||
r = []
|
||||
for offset, mask in self._mask_from_offset.items():
|
||||
b = offset << (8 * (self.byte_count + 1))
|
||||
b |= (self.sep << (8 * self.byte_count)) | mask
|
||||
r.append(common.int2bytes(b, self.byte_count + 2))
|
||||
return r
|
||||
|
||||
def prepare_read_key(self, key):
|
||||
option = self._option_from_key.get(key, None)
|
||||
if option is None:
|
||||
return None
|
||||
offset, mask = option.om_method(option)
|
||||
b = offset << (8 * (self.byte_count + 1))
|
||||
b |= (self.sep << (8 * self.byte_count)) | mask
|
||||
return common.int2bytes(b, self.byte_count + 2)
|
||||
|
||||
def validate_read(self, reply_bytes_dict):
|
||||
values = {int(k): False for k in self.options}
|
||||
for query, b in reply_bytes_dict.items():
|
||||
offset = common.bytes2int(query[0:1])
|
||||
b += (self.byte_count - len(b)) * b"\x00"
|
||||
value = common.bytes2int(b[: self.byte_count])
|
||||
mask_to_opt = self._option_from_offset_mask.get(offset, {})
|
||||
m = 1
|
||||
for _ignore in range(8 * self.byte_count):
|
||||
if m in mask_to_opt:
|
||||
values[int(mask_to_opt[m])] = bool(value & m)
|
||||
m <<= 1
|
||||
return values
|
||||
|
||||
def prepare_write(self, new_value):
|
||||
assert isinstance(new_value, dict)
|
||||
w = {}
|
||||
for k, v in new_value.items():
|
||||
option = self._option_from_key[int(k)]
|
||||
offset, mask = self.om_method(option)
|
||||
if offset not in w:
|
||||
w[offset] = 0
|
||||
if v:
|
||||
w[offset] |= mask
|
||||
return [
|
||||
common.int2bytes(
|
||||
(offset << (8 * (2 * self.byte_count + 1)))
|
||||
| (self.sep << (16 * self.byte_count))
|
||||
| (self._mask_from_offset[offset] << (8 * self.byte_count))
|
||||
| value,
|
||||
2 * self.byte_count + 2,
|
||||
)
|
||||
for offset, value in w.items()
|
||||
]
|
||||
|
||||
def get_options(self):
|
||||
return [int(opt) if isinstance(opt, int) else opt.as_int() for opt in self.options]
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 2:
|
||||
return None
|
||||
key = next((option.id for option in self.options if option.as_int() == args[0]), None)
|
||||
if key is None:
|
||||
return None
|
||||
val = bool_or_toggle(current[int(key)], args[1])
|
||||
return None if val is None else [int(key), val]
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) != 2:
|
||||
return False
|
||||
key = next((option.id for option in self.options if option.as_int() == args[0]), None)
|
||||
if key is None:
|
||||
return False
|
||||
return args[1] == current[int(key)]
|
||||
|
||||
|
||||
class ChoicesValidator(Validator):
|
||||
"""Translates between NamedInts and a byte sequence.
|
||||
:param choices: a list of NamedInts
|
||||
:param byte_count: the size of the derived byte sequence. If None, it
|
||||
will be calculated from the choices."""
|
||||
|
||||
kind = Kind.CHOICE
|
||||
|
||||
def __init__(self, choices=None, byte_count=None, read_skip_byte_count=0, write_prefix_bytes=b""):
|
||||
assert choices is not None
|
||||
assert isinstance(choices, NamedInts)
|
||||
assert len(choices) > 1
|
||||
self.choices = choices
|
||||
self.needs_current_value = False
|
||||
|
||||
max_bits = max(x.bit_length() for x in choices)
|
||||
self._byte_count = (max_bits // 8) + (1 if max_bits % 8 else 0)
|
||||
if byte_count:
|
||||
assert self._byte_count <= byte_count
|
||||
self._byte_count = byte_count
|
||||
assert self._byte_count < 8
|
||||
self._read_skip_byte_count = read_skip_byte_count
|
||||
self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b""
|
||||
assert self._byte_count + self._read_skip_byte_count <= 14
|
||||
assert self._byte_count + len(self._write_prefix_bytes) <= 14
|
||||
|
||||
def to_string(self, value) -> str:
|
||||
return str(self.choices[value]) if isinstance(value, int) else str(value)
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
reply_value = common.bytes2int(reply_bytes[self._read_skip_byte_count : self._read_skip_byte_count + self._byte_count])
|
||||
valid_value = self.choices[reply_value]
|
||||
assert valid_value is not None, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
|
||||
return valid_value
|
||||
|
||||
def prepare_write(self, new_value, current_value=None):
|
||||
if new_value is None:
|
||||
value = self.choices[:][0]
|
||||
else:
|
||||
value = self.choice(new_value)
|
||||
if value is None:
|
||||
raise ValueError(f"invalid choice {new_value!r}")
|
||||
assert isinstance(value, NamedInt)
|
||||
return self._write_prefix_bytes + value.bytes(self._byte_count)
|
||||
|
||||
def choice(self, value):
|
||||
if isinstance(value, int):
|
||||
return self.choices[value]
|
||||
try:
|
||||
int(value)
|
||||
if int(value) in self.choices:
|
||||
return self.choices[int(value)]
|
||||
except Exception:
|
||||
pass
|
||||
if value in self.choices:
|
||||
return self.choices[value]
|
||||
else:
|
||||
return None
|
||||
|
||||
def acceptable(self, args, current):
|
||||
choice = self.choice(args[0]) if len(args) == 1 else None
|
||||
return None if choice is None else [choice]
|
||||
|
||||
|
||||
class ChoicesMapValidator(ChoicesValidator):
|
||||
kind = Kind.MAP_CHOICE
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
choices_map,
|
||||
key_byte_count=0,
|
||||
key_postfix_bytes=b"",
|
||||
byte_count=0,
|
||||
read_skip_byte_count=0,
|
||||
write_prefix_bytes=b"",
|
||||
extra_default=None,
|
||||
mask=-1,
|
||||
activate=0,
|
||||
):
|
||||
assert choices_map is not None
|
||||
assert isinstance(choices_map, dict)
|
||||
max_key_bits = 0
|
||||
max_value_bits = 0
|
||||
for key, choices in choices_map.items():
|
||||
assert isinstance(key, NamedInt)
|
||||
assert isinstance(choices, NamedInts)
|
||||
max_key_bits = max(max_key_bits, key.bit_length())
|
||||
for key_value in choices:
|
||||
assert isinstance(key_value, NamedInt)
|
||||
max_value_bits = max(max_value_bits, key_value.bit_length())
|
||||
self._key_byte_count = (max_key_bits + 7) // 8
|
||||
if key_byte_count:
|
||||
assert self._key_byte_count <= key_byte_count
|
||||
self._key_byte_count = key_byte_count
|
||||
self._byte_count = (max_value_bits + 7) // 8
|
||||
if byte_count:
|
||||
assert self._byte_count <= byte_count
|
||||
self._byte_count = byte_count
|
||||
|
||||
self.choices = choices_map
|
||||
self.needs_current_value = False
|
||||
self.extra_default = extra_default
|
||||
self._key_postfix_bytes = key_postfix_bytes
|
||||
self._read_skip_byte_count = read_skip_byte_count if read_skip_byte_count else 0
|
||||
self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b""
|
||||
self.activate = activate
|
||||
self.mask = mask
|
||||
assert self._byte_count + self._read_skip_byte_count + self._key_byte_count <= 14
|
||||
assert self._byte_count + len(self._write_prefix_bytes) + self._key_byte_count <= 14
|
||||
|
||||
def to_string(self, value) -> str:
|
||||
def element_to_string(key, val):
|
||||
k, c = next(((k, c) for k, c in self.choices.items() if int(key) == k), (None, None))
|
||||
return str(k) + ":" + str(c[val]) if k is not None else "?"
|
||||
|
||||
return "{" + ", ".join([element_to_string(k, value[k]) for k in sorted(value)]) + "}"
|
||||
|
||||
def validate_read(self, reply_bytes, key):
|
||||
start = self._key_byte_count + self._read_skip_byte_count
|
||||
end = start + self._byte_count
|
||||
reply_value = common.bytes2int(reply_bytes[start:end]) & self.mask
|
||||
# reprogrammable keys starts out as 0, which is not a choice, so don't use assert here
|
||||
if self.extra_default is not None and self.extra_default == reply_value:
|
||||
return int(self.choices[key][0])
|
||||
if reply_value not in self.choices[key]:
|
||||
assert reply_value in self.choices[key], "%s: failed to validate read value %02X" % (
|
||||
self.__class__.__name__,
|
||||
reply_value,
|
||||
)
|
||||
return reply_value
|
||||
|
||||
def prepare_key(self, key):
|
||||
return key.to_bytes(self._key_byte_count, "big") + self._key_postfix_bytes
|
||||
|
||||
def prepare_write(self, key, new_value):
|
||||
choices = self.choices.get(key)
|
||||
if choices is None or (new_value not in choices and new_value != self.extra_default):
|
||||
logger.error("invalid choice %r for %s", new_value, key)
|
||||
return None
|
||||
new_value = new_value | self.activate
|
||||
return self._write_prefix_bytes + new_value.to_bytes(self._byte_count, "big")
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 2:
|
||||
return None
|
||||
key, choices = next(((key, item) for key, item in self.choices.items() if key == args[0]), (None, None))
|
||||
if choices is None or args[1] not in choices:
|
||||
return None
|
||||
choice = next((item for item in choices if item == args[1]), None)
|
||||
return [int(key), int(choice)] if choice is not None else None
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) != 2:
|
||||
return False
|
||||
key = next((key for key in self.choices if key == int(args[0])), None)
|
||||
if key is None:
|
||||
return False
|
||||
return args[1] == current[int(key)]
|
||||
|
||||
|
||||
class RangeValidator(Validator):
|
||||
kind = Kind.RANGE
|
||||
"""Translates between integers and a byte sequence.
|
||||
:param min_value: minimum accepted value (inclusive)
|
||||
:param max_value: maximum accepted value (inclusive)
|
||||
:param byte_count: the size of the derived byte sequence. If None, it
|
||||
will be calculated from the range."""
|
||||
min_value = 0
|
||||
max_value = 255
|
||||
|
||||
@classmethod
|
||||
def build(cls, setting_class, device, **kwargs):
|
||||
kwargs["min_value"] = setting_class.min_value
|
||||
kwargs["max_value"] = setting_class.max_value
|
||||
return cls(**kwargs)
|
||||
|
||||
def __init__(self, min_value=0, max_value=255, byte_count=1, read_skip_byte_count=0, write_prefix_bytes=b""):
|
||||
assert max_value > min_value
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
self.read_skip_byte_count = read_skip_byte_count
|
||||
self.write_prefix_bytes = write_prefix_bytes
|
||||
self.needs_current_value = True # read and check before write (needed for ADC power and probably a good idea anyway)
|
||||
self._byte_count = math.ceil(math.log(max_value + 1, 256))
|
||||
if byte_count:
|
||||
assert self._byte_count <= byte_count
|
||||
self._byte_count = byte_count
|
||||
assert self._byte_count < 8
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
reply_value = common.bytes2int(reply_bytes[self.read_skip_byte_count : self.read_skip_byte_count + self._byte_count])
|
||||
assert reply_value >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
|
||||
assert reply_value <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
|
||||
return reply_value
|
||||
|
||||
def prepare_write(self, new_value, current_value=None):
|
||||
if new_value < self.min_value or new_value > self.max_value:
|
||||
raise ValueError(f"invalid choice {new_value!r}")
|
||||
current_value = self.validate_read(current_value) if current_value is not None else None
|
||||
to_write = self.write_prefix_bytes + common.int2bytes(new_value, self._byte_count)
|
||||
# current value is known and same as value to be written return None to signal not to write it
|
||||
return None if current_value is not None and current_value == new_value else to_write
|
||||
|
||||
def acceptable(self, args, current):
|
||||
arg = args[0]
|
||||
# None if len(args) != 1 or type(arg) != int or arg < self.min_value or arg > self.max_value else args)
|
||||
return None if len(args) != 1 or isinstance(arg, int) or arg < self.min_value or arg > self.max_value else args
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) == 1:
|
||||
return args[0] == current
|
||||
elif len(args) == 2:
|
||||
return args[0] <= current <= args[1]
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class HeteroValidator(Validator):
|
||||
kind = Kind.HETERO
|
||||
|
||||
@classmethod
|
||||
def build(cls, setting_class, device, **kwargs):
|
||||
return cls(**kwargs)
|
||||
|
||||
def __init__(self, data_class=None, options=None, readable=True):
|
||||
assert data_class is not None and options is not None
|
||||
self.data_class = data_class
|
||||
self.options = options
|
||||
self.readable = readable
|
||||
self.needs_current_value = False
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
if self.readable:
|
||||
reply_value = self.data_class.from_bytes(reply_bytes, options=self.options)
|
||||
return reply_value
|
||||
|
||||
def prepare_write(self, new_value, current_value=None):
|
||||
to_write = new_value.to_bytes(options=self.options)
|
||||
return to_write
|
||||
|
||||
def acceptable(self, args, current): # should this actually do some checking?
|
||||
return True
|
||||
|
||||
|
||||
class PackedRangeValidator(Validator):
|
||||
kind = Kind.PACKED_RANGE
|
||||
"""Several range values, all the same size, all the same min and max"""
|
||||
min_value = 0
|
||||
max_value = 255
|
||||
count = 1
|
||||
rsbc = 0
|
||||
write_prefix_bytes = b""
|
||||
|
||||
def __init__(
|
||||
self, keys, min_value=0, max_value=255, count=1, byte_count=1, read_skip_byte_count=0, write_prefix_bytes=b""
|
||||
):
|
||||
assert max_value > min_value
|
||||
self.needs_current_value = True
|
||||
self.keys = keys
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
self.count = count
|
||||
self.bc = math.ceil(math.log(max_value + 1 - min(0, min_value), 256))
|
||||
if byte_count:
|
||||
assert self.bc <= byte_count
|
||||
self.bc = byte_count
|
||||
assert self.bc * self.count
|
||||
self.rsbc = read_skip_byte_count
|
||||
self.write_prefix_bytes = write_prefix_bytes
|
||||
|
||||
def validate_read(self, reply_bytes):
|
||||
rvs = {
|
||||
n: common.bytes2int(reply_bytes[self.rsbc + n * self.bc : self.rsbc + (n + 1) * self.bc], signed=True)
|
||||
for n in range(self.count)
|
||||
}
|
||||
for n in range(self.count):
|
||||
assert rvs[n] >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}"
|
||||
assert rvs[n] <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}"
|
||||
return rvs
|
||||
|
||||
def prepare_write(self, new_values):
|
||||
if len(new_values) != self.count:
|
||||
raise ValueError(f"wrong number of values {new_values!r}")
|
||||
for new_value in new_values.values():
|
||||
if new_value < self.min_value or new_value > self.max_value:
|
||||
raise ValueError(f"invalid value {new_value!r}")
|
||||
bytes = self.write_prefix_bytes + b"".join(
|
||||
common.int2bytes(new_values[n], self.bc, signed=True) for n in range(self.count)
|
||||
)
|
||||
return bytes
|
||||
|
||||
def acceptable(self, args, current):
|
||||
if len(args) != 2 or int(args[0]) < 0 or int(args[0]) >= self.count:
|
||||
return None
|
||||
return None if not isinstance(args[1], int) or args[1] < self.min_value or args[1] > self.max_value else args
|
||||
|
||||
def compare(self, args, current):
|
||||
logger.warning("compare not implemented for packed range settings")
|
||||
return False
|
||||
|
||||
|
||||
class MultipleRangeValidator(Validator):
|
||||
kind = Kind.MULTIPLE_RANGE
|
||||
|
||||
def __init__(self, items, sub_items):
|
||||
assert isinstance(items, list) # each element must have .index and its __int__ must return its id (not its index)
|
||||
assert isinstance(sub_items, dict)
|
||||
# sub_items: items -> class with .minimum, .maximum, .length (in bytes), .id (a string) and .widget (e.g. 'Scale')
|
||||
self.items = items
|
||||
self.keys = NamedInts(**{str(item): int(item) for item in items})
|
||||
self._item_from_id = {int(k): k for k in items}
|
||||
self.sub_items = sub_items
|
||||
|
||||
def prepare_read_item(self, item):
|
||||
return common.int2bytes((self._item_from_id[int(item)].index << 1) | 0xFF, 2)
|
||||
|
||||
def validate_read_item(self, reply_bytes, item):
|
||||
item = self._item_from_id[int(item)]
|
||||
start = 0
|
||||
value = {}
|
||||
for sub_item in self.sub_items[item]:
|
||||
r = reply_bytes[start : start + sub_item.length]
|
||||
if len(r) < sub_item.length:
|
||||
r += b"\x00" * (sub_item.length - len(value))
|
||||
v = common.bytes2int(r)
|
||||
if not (sub_item.minimum < v < sub_item.maximum):
|
||||
logger.warning(
|
||||
f"{self.__class__.__name__}: failed to validate read value for {item}.{sub_item}: "
|
||||
+ f"{v} not in [{sub_item.minimum}..{sub_item.maximum}]"
|
||||
)
|
||||
value[str(sub_item)] = v
|
||||
start += sub_item.length
|
||||
return value
|
||||
|
||||
def prepare_write(self, value):
|
||||
seq = []
|
||||
w = b""
|
||||
for item in value.keys():
|
||||
_item = self._item_from_id[int(item)]
|
||||
b = common.int2bytes(_item.index, 1)
|
||||
for sub_item in self.sub_items[_item]:
|
||||
try:
|
||||
v = value[int(item)][str(sub_item)]
|
||||
except KeyError:
|
||||
return None
|
||||
if not (sub_item.minimum <= v <= sub_item.maximum):
|
||||
raise ValueError(
|
||||
f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]"
|
||||
)
|
||||
b += common.int2bytes(v, sub_item.length)
|
||||
if len(w) + len(b) > 15:
|
||||
seq.append(b + b"\xff")
|
||||
w = b""
|
||||
w += b
|
||||
seq.append(w + b"\xff")
|
||||
return seq
|
||||
|
||||
def prepare_write_item(self, item, value):
|
||||
_item = self._item_from_id[int(item)]
|
||||
w = common.int2bytes(_item.index, 1)
|
||||
for sub_item in self.sub_items[_item]:
|
||||
try:
|
||||
v = value[str(sub_item)]
|
||||
except KeyError:
|
||||
return None
|
||||
if not (sub_item.minimum <= v <= sub_item.maximum):
|
||||
raise ValueError(f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]")
|
||||
w += common.int2bytes(v, sub_item.length)
|
||||
return w + b"\xff"
|
||||
|
||||
def acceptable(self, args, current):
|
||||
# just one item, with at least one sub-item
|
||||
if not isinstance(args, list) or len(args) != 2 or not isinstance(args[1], dict):
|
||||
return None
|
||||
item = next((p for p in self.items if p.id == args[0] or str(p) == args[0]), None)
|
||||
if not item:
|
||||
return None
|
||||
for sub_key, value in args[1].items():
|
||||
sub_item = next((it for it in self.sub_items[item] if it.id == sub_key), None)
|
||||
if not sub_item:
|
||||
return None
|
||||
if not isinstance(value, int) or not (sub_item.minimum <= value <= sub_item.maximum):
|
||||
return None
|
||||
return [int(item), {**args[1]}]
|
||||
|
||||
def compare(self, args, current):
|
||||
logger.warning("compare not implemented for multiple range settings")
|
||||
return False
|
||||
@@ -15,31 +15,36 @@
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
# Reprogrammable keys information
|
||||
# Mostly from Logitech documentation, but with some edits for better Lunix compatibility
|
||||
# Mostly from Logitech documentation, but with some edits for better Linux compatibility
|
||||
|
||||
import os as _os
|
||||
import os
|
||||
|
||||
import yaml as _yaml
|
||||
from enum import IntEnum
|
||||
|
||||
from .common import NamedInts as _NamedInts
|
||||
from .common import UnsortedNamedInts as _UnsortedNamedInts
|
||||
import yaml
|
||||
|
||||
_XDG_CONFIG_HOME = _os.environ.get("XDG_CONFIG_HOME") or _os.path.expanduser(_os.path.join("~", ".config"))
|
||||
_keys_file_path = _os.path.join(_XDG_CONFIG_HOME, "solaar", "keys.yaml")
|
||||
from .common import NamedInts
|
||||
from .common import UnsortedNamedInts
|
||||
|
||||
_XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser(os.path.join("~", ".config"))
|
||||
_keys_file_path = os.path.join(_XDG_CONFIG_HOME, "solaar", "keys.yaml")
|
||||
|
||||
|
||||
# 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
|
||||
CONTROL = _NamedInts(
|
||||
# Keys added afterwards based on information from Logitech and users
|
||||
CONTROL = NamedInts(
|
||||
{
|
||||
"Volume_Up": 0x0001,
|
||||
"Volume_Down": 0x0002,
|
||||
"Volume_Up_old": 0x0001,
|
||||
"Volume_Down_old": 0x0002,
|
||||
"Mute": 0x0003,
|
||||
"Play__Pause": 0x0004,
|
||||
"Play__Pause_old": 0x0004,
|
||||
"Next": 0x0005,
|
||||
"Previous": 0x0006,
|
||||
"Stop": 0x0007,
|
||||
"Application_Switcher": 0x0008,
|
||||
"Burn": 0x0009,
|
||||
"Calculator": 0x000A, # Craft Keyboard top 4th from right
|
||||
"Calculator": 0x000A, # Craft Keyboard top 4th from right; Logitech
|
||||
"Calendar": 0x000B,
|
||||
"Close": 0x000C,
|
||||
"Eject": 0x000D,
|
||||
@@ -53,7 +58,7 @@ CONTROL = _NamedInts(
|
||||
"Undo_As_HID": 0x0015,
|
||||
"Redo_As_Ctrl_Y": 0x0016,
|
||||
"Redo_As_HID": 0x0017,
|
||||
"Print_As_Ctrl_P": 0x0018,
|
||||
"Print_As_Ctrl_P": 0x0018, # Logitech, modified
|
||||
"Print_As_HID": 0x0019,
|
||||
"Save_As_Ctrl_S": 0x001A,
|
||||
"Save_As_HID": 0x001B,
|
||||
@@ -108,13 +113,13 @@ CONTROL = _NamedInts(
|
||||
"Pause_Break": 0x004D,
|
||||
"Scroll_Lock": 0x004E,
|
||||
"Contextual_Menu": 0x004F,
|
||||
"Left_Button": 0x0050, # LEFT_CLICK
|
||||
"Right_Button": 0x0051, # RIGHT_CLICK
|
||||
"Middle_Button": 0x0052, # MIDDLE_BUTTON
|
||||
"Back_Button": 0x0053, # from M510v2 was BACK_AS_BUTTON_4
|
||||
"Left_Button": 0x0050, # LEFT_CLICK; Logitech
|
||||
"Right_Button": 0x0051, # RIGHT_CLICK; Logitech
|
||||
"Middle_Button": 0x0052, # MIDDLE_BUTTON; Logitech
|
||||
"Back_Button": 0x0053, # from M510v2 was BACK_AS_BUTTON_4; Logitech
|
||||
"Back": 0x0054, # BACK_AS_HID
|
||||
"Back_As_Alt_Win_Arrow": 0x0055,
|
||||
"Forward_Button": 0x0056, # from M510v2 was FORWARD_AS_BUTTON_5
|
||||
"Forward_Button": 0x0056, # from M510v2 was FORWARD_AS_BUTTON_5; Logitech
|
||||
"Forward_As_HID": 0x0057,
|
||||
"Forward_As_Alt_Win_Arrow": 0x0058,
|
||||
"Button_6": 0x0059,
|
||||
@@ -138,8 +143,8 @@ CONTROL = _NamedInts(
|
||||
"Button_22": 0x006B,
|
||||
"Button_23": 0x006C,
|
||||
"Button_24": 0x006D,
|
||||
"Show_Desktop": 0x006E, # Craft Keyboard Fn F5
|
||||
"Lock_PC": 0x006F, # Craft Keyboard top 1st from right
|
||||
"Show_Desktop": 0x006E, # Craft Keyboard Fn F5; Logitch
|
||||
"Screen_Lock": 0x006F, # Craft Keyboard top 1st from right; Logitech
|
||||
"Fn_F1": 0x0070,
|
||||
"Fn_F2": 0x0071,
|
||||
"Fn_F3": 0x0072,
|
||||
@@ -187,7 +192,7 @@ CONTROL = _NamedInts(
|
||||
"Metro_Search": 0x00A3,
|
||||
"Combo_Sleep": 0x00A4,
|
||||
"Metro_Share": 0x00A5,
|
||||
"Metro_Settings": 0x00A6,
|
||||
"OS_Settings": 0x00A6, # Logitech
|
||||
"Metro_Devices": 0x00A7,
|
||||
"Metro_Start_Screen": 0x00A9,
|
||||
"Zoomin": 0x00AA,
|
||||
@@ -210,23 +215,23 @@ CONTROL = _NamedInts(
|
||||
"Fn_Down": 0x00C0,
|
||||
"Fn_Up": 0x00C1,
|
||||
"Multiplatform_Lock": 0x00C2,
|
||||
"Mouse_Gesture_Button": 0x00C3, # Thumb_Button on MX Master - Logitech name App_Switch_Gesture
|
||||
"Smart_Shift": 0x00C4, # Top_Button on MX Master
|
||||
"Mouse_Gesture_Button": 0x00C3, # Thumb_Button on MX Master - Logitech name App_Switch_Gesture; Logitech
|
||||
"Smart_Shift": 0x00C4, # Top_Button on MX Master; Logitech
|
||||
"Microphone": 0x00C5,
|
||||
"Wifi": 0x00C6,
|
||||
"Brightness_Down": 0x00C7, # Craft Keyboard Fn F1
|
||||
"Brightness_Up": 0x00C8, # Craft Keyboard Fn F2
|
||||
"Brightness_Down": 0x00C7, # Craft Keyboard Fn F1, Logitech
|
||||
"Brightness_Up": 0x00C8, # Craft Keyboard Fn F2, Logitech
|
||||
"Display_Out__Project_Screen_": 0x00C9,
|
||||
"View_Open_Apps": 0x00CA,
|
||||
"View_All_Apps": 0x00CB,
|
||||
"Switch_App": 0x00CC,
|
||||
"Fn_Inversion_Change": 0x00CD,
|
||||
"MultiPlatform_Back": 0x00CE,
|
||||
"MultiPlatform_Back": 0x00CE, # Logitech
|
||||
"MultiPlatform_Forward": 0x00CF,
|
||||
"MultiPlatform_Gesture_Button": 0x00D0,
|
||||
"Host_Switch_Channel_1": 0x00D1, # Craft Keyboard
|
||||
"Host_Switch_Channel_2": 0x00D2, # Craft Keyboard
|
||||
"Host_Switch_Channel_3": 0x00D3, # Craft Keyboard
|
||||
"Host_Switch_Channel_1": 0x00D1, # Craft Keyboard; Logitech
|
||||
"Host_Switch_Channel_2": 0x00D2, # Craft Keyboard; Logitech
|
||||
"Host_Switch_Channel_3": 0x00D3, # Craft Keyboard; Logitech
|
||||
"MultiPlatform_Search": 0x00D4,
|
||||
"MultiPlatform_Home__Mission_Control": 0x00D5,
|
||||
"MultiPlatform_Menu__Show__Hide_Virtual_Keyboard__Launchpad": 0x00D6,
|
||||
@@ -239,21 +244,21 @@ CONTROL = _NamedInts(
|
||||
"Multi_Platform_Language_Switch": 0x00DD,
|
||||
"F_Lock": 0x00DE,
|
||||
"Switch_Highlight": 0x00DF,
|
||||
"Mission_Control__Task_View": 0x00E0, # Craft Keyboard Fn F3 Switch_Workspace
|
||||
"Mission_Control__Task_View": 0x00E0, # Craft Keyboard Fn F3 Switch_Workspace; Logitech
|
||||
"Dashboard_Launchpad__Action_Center": 0x00E1, # Craft Keyboard Fn F4 Application_Launcher
|
||||
"Backlight_Down": 0x00E2, # Craft Keyboard Fn F6
|
||||
"Backlight_Up": 0x00E3, # Craft Keyboard Fn F7
|
||||
"Previous_Fn": 0x00E4, # Craft Keyboard Fn F8 Previous_Track
|
||||
"Play__Pause_Fn": 0x00E5, # Craft Keyboard Fn F9 Play__Pause
|
||||
"Next_Fn": 0x00E6, # Craft Keyboard Fn F10 Next_Track
|
||||
"Mute_Fn": 0x00E7, # Craft Keyboard Fn F11 Mute
|
||||
"Volume_Down_Fn": 0x00E8, # Craft Keyboard Fn F12 Volume_Down
|
||||
"Volume_Up_Fn": 0x00E9, # Craft Keyboard next to F12 Volume_Down
|
||||
"Backlight_Down": 0x00E2, # Craft Keyboard Fn F6, Logitech
|
||||
"Backlight_Up": 0x00E3, # Craft Keyboard Fn F7, Logitech
|
||||
"Previous_Track": 0x00E4, # Craft Keyboard Fn F8 Previous_Track; Logitech
|
||||
"Play__Pause": 0x00E5, # Craft Keyboard Fn F9 Play__Pause; Logitech
|
||||
"Next_Track": 0x00E6, # Craft Keyboard Fn F10 Next_Track; Logitech
|
||||
"Mute_Sound": 0x00E7, # Craft Keyboard Fn F11 Mute; Logitech
|
||||
"Volume_Down": 0x00E8, # Craft Keyboard Fn F12 Volume_Down; Logitech
|
||||
"Volume_Up": 0x00E9, # Craft Keyboard next to F12 Volume_Down; Logitech
|
||||
"App_Contextual_Menu__Right_Click": 0x00EA, # Craft Keyboard top 2nd from right
|
||||
"Right_Arrow": 0x00EB,
|
||||
"Left_Arrow": 0x00EC,
|
||||
"DPI_Change": 0x00ED,
|
||||
"New_Tab": 0x00EE,
|
||||
"Open_New_Tab": 0x00EE, # Logitech
|
||||
"F2": 0x00EF,
|
||||
"F3": 0x00F0,
|
||||
"F4": 0x00F1,
|
||||
@@ -269,20 +274,20 @@ CONTROL = _NamedInts(
|
||||
"Laser_Button_Short_Press": 0x00FB,
|
||||
"Laser_Button_Long_Press": 0x00FC,
|
||||
"DPI_Switch": 0x00FD,
|
||||
"Multiplatform_Home__Show_Desktop": 0x00FE,
|
||||
"Multiplatform_Home__Show_Desktop": 0x00FE, # Logitech
|
||||
"Multiplatform_App_Switch__Show_Dashboard": 0x00FF,
|
||||
"Multiplatform_App_Switch_2": 0x0100, # Multiplatform_App_Switch
|
||||
"Fn_Inversion__Hot_Key": 0x0101,
|
||||
"LeftAndRightClick": 0x0102,
|
||||
"Voice_Dictation": 0x0103, # MX Keys for Business Fn F5 ; MX Mini Fn F6 Dictation
|
||||
"Emoji_Smiley_Heart_Eyes": 0x0104,
|
||||
"Emoji_Crying_Face": 0x0105,
|
||||
"Emoji_Smiley": 0x0106,
|
||||
"Emoji_Smilie_With_Tears": 0x0107,
|
||||
"Open_Emoji_Panel": 0x0108, # MX Keys for Business Fn F6 ; MX Mini Fn F7 Emoji
|
||||
"Multiplatform_App_Switch__Launchpad": 0x0109,
|
||||
"Snipping_Tool": 0x010A, # MX Keys for Business top 3rd from right; MX Mini Fn F8 Screenshot
|
||||
"Grave_Accent": 0x010B,
|
||||
"Dictation": 0x0103, # MX Keys for Business Fn F5 ; MX Mini Fn F6 Dictation; Logitech
|
||||
"Emoji_Smiley_Heart_Eyes": 0x0104, # Logitech
|
||||
"Emoji_Crying_Face": 0x0105, # Logitech
|
||||
"Emoji_Smiley": 0x0106, # Logitech
|
||||
"Emoji_Smilie_With_Tears": 0x0107, # Logitech
|
||||
"Emoji": 0x0108, # MX Keys for Business Fn F6 ; MX Mini Fn F7 Emoji, Logitech
|
||||
"Multiplatform_App_Switch__Launchpad": 0x0109, # Logitech
|
||||
"Screen_Capture": 0x010A, # MX Keys for Business top 3rd from right; MX Mini Fn F8 Screenshot; Logitech
|
||||
"Grave_Accent": 0x010B, # Logitech
|
||||
"Tab_Key": 0x010C,
|
||||
"Caps_Lock": 0x010D,
|
||||
"Left_Shift": 0x010E,
|
||||
@@ -295,309 +300,322 @@ CONTROL = _NamedInts(
|
||||
"Right_Shift": 0x0115,
|
||||
"Insert": 0x0116,
|
||||
"Delete": 0x0117, # MX Mini Lock (on delete key in function row)
|
||||
"Home": 0x118,
|
||||
"End": 0x119,
|
||||
"Home": 0x118, # Logitech
|
||||
"End": 0x119, # Logitech
|
||||
"Page_Up": 0x11A,
|
||||
"Page_Down": 0x11B,
|
||||
"Mute_Microphone": 0x11C, # MX Keys for Business Fn F7 ; MX Mini Fn F9 Microphone Mute
|
||||
"Do_Not_Disturb": 0x11D,
|
||||
"Mute_Microphone": 0x11C, # MX Keys for Business Fn F7 ; MX Mini Fn F9 Microphone Mute; Logitech
|
||||
"Do_Not_Disturb": 0x11D, # Logitech
|
||||
"Backslash": 0x11E,
|
||||
"Refresh": 0x11F,
|
||||
"Refresh": 0x11F, # Logitech
|
||||
"Close_Tab": 0x120,
|
||||
"Lang_Switch": 0x121,
|
||||
"Lang_Switch": 0x121, # Logitech
|
||||
"Standard_Key_A": 0x122,
|
||||
"Standard_Key_B": 0x123,
|
||||
"Standard_Key_C": 0x124, # There are lots more of these
|
||||
"Right_Option__Start__2": 0x013C, # On MX Mechanical Mini
|
||||
"Play_Pause": 0x0141, # On MX Mechanical Mini
|
||||
"Play__Pause_mini": 0x0141, # On MX Mechanical Mini
|
||||
"Haptic": 0x01A0, # Logitech
|
||||
"Circle": 0x01A3,
|
||||
"Triangle": 0x01A4,
|
||||
"Diamond": 0x01A5,
|
||||
"Star": 0x01A6,
|
||||
"Cut": 0x1A9, # Logitech
|
||||
"Copy": 0x1AA, # Logitech
|
||||
"Paste": 0x1AB, # Logitech
|
||||
"Video_On_Off": 0x01AC, # Logitech
|
||||
"AI": 0x1B4, # Logitech
|
||||
}
|
||||
)
|
||||
|
||||
for i in range(1, 33): # add in G keys - these are not really Logitech Controls
|
||||
CONTROL[0x1000 + i] = "G" + str(i)
|
||||
CONTROL[0x1000 + i] = f"G{str(i)}"
|
||||
for i in range(1, 9): # add in M keys - these are not really Logitech Controls
|
||||
CONTROL[0x1100 + i] = "M" + str(i)
|
||||
CONTROL[0x1100 + i] = f"M{str(i)}"
|
||||
CONTROL[0x1200] = "MR" # add in MR key - this is not really a Logitech Control
|
||||
|
||||
CONTROL._fallback = lambda x: f"unknown:{x:04X}"
|
||||
|
||||
# <tasks.xml awk -F\" '/<Task /{gsub(/ /, "_", $6); printf("\t%s=0x%04X,\n", $6, $4)}'
|
||||
TASK = _NamedInts(
|
||||
Volume_Up=0x0001,
|
||||
Volume_Down=0x0002,
|
||||
Mute=0x0003,
|
||||
|
||||
class Task(IntEnum):
|
||||
"""
|
||||
<tasks.xml awk -F\" '/<Task /{gsub(/ /, "_", $6); printf("\t%s=0x%04X,\n", $6, $4)}'
|
||||
"""
|
||||
|
||||
VOLUME_UP = 0x0001
|
||||
VOLUME_DOWN = 0x0002
|
||||
MUTE = 0x0003
|
||||
# Multimedia tasks:
|
||||
Play__Pause=0x0004,
|
||||
Next=0x0005,
|
||||
Previous=0x0006,
|
||||
Stop=0x0007,
|
||||
Application_Switcher=0x0008,
|
||||
BurnMediaPlayer=0x0009,
|
||||
Calculator=0x000A,
|
||||
Calendar=0x000B,
|
||||
Close_Application=0x000C,
|
||||
Eject=0x000D,
|
||||
Email=0x000E,
|
||||
Help=0x000F,
|
||||
OffDocument=0x0010,
|
||||
OffSpreadsheet=0x0011,
|
||||
OffPowerpnt=0x0012,
|
||||
Undo=0x0013,
|
||||
Redo=0x0014,
|
||||
Print=0x0015,
|
||||
Save=0x0016,
|
||||
SmartKeySet=0x0017,
|
||||
Favorites=0x0018,
|
||||
GadgetsSet=0x0019,
|
||||
HomePage=0x001A,
|
||||
WindowsRestore=0x001B,
|
||||
WindowsMinimize=0x001C,
|
||||
Music=0x001D, # also known as MediaPlayer
|
||||
PLAY_PAUSE = 0x0004
|
||||
NEXT = 0x0005
|
||||
PREVIOUS = 0x0006
|
||||
STOP = 0x0007
|
||||
APPLICATION_SWITCHER = 0x0008
|
||||
BURN_MEDIA_PLAYER = 0x0009
|
||||
CALCULATOR = 0x000A
|
||||
CALENDAR = 0x000B
|
||||
CLOSE_APPLICATION = 0x000C
|
||||
EJECT = 0x000D
|
||||
EMAIL = 0x000E
|
||||
HELP = 0x000F
|
||||
OFF_DOCUMENT = 0x0010
|
||||
OFF_SPREADSHEET = 0x0011
|
||||
OFF_POWERPNT = 0x0012
|
||||
UNDO = 0x0013
|
||||
REDO = 0x0014
|
||||
PRINT = 0x0015
|
||||
SAVE = 0x0016
|
||||
SMART_KEY_SET = 0x0017
|
||||
FAVORITES = 0x0018
|
||||
GADGETS_SET = 0x0019
|
||||
HOME_PAGE = 0x001A
|
||||
WINDOWS_RESTORE = 0x001B
|
||||
WINDOWS_MINIMIZE = 0x001C
|
||||
MUSIC = 0x001D # also known as MediaPlayer
|
||||
# Both 0x001E and 0x001F are known as MediaCenterSet
|
||||
Media_Center_Logitech=0x001E,
|
||||
Media_Center_Microsoft=0x001F,
|
||||
UserMenu=0x0020,
|
||||
Messenger=0x0021,
|
||||
PersonalFolders=0x0022,
|
||||
MyMusic=0x0023,
|
||||
Webcam=0x0024,
|
||||
PicturesFolder=0x0025,
|
||||
MyVideos=0x0026,
|
||||
My_Computer=0x0027,
|
||||
PictureAppSet=0x0028,
|
||||
Search=0x0029, # also known as AdvSmartSearch
|
||||
RecordMediaPlayer=0x002A,
|
||||
BrowserRefresh=0x002B,
|
||||
RotateRight=0x002C,
|
||||
Search_Files=0x002D, # SearchForFiles
|
||||
MM_SHUFFLE=0x002E,
|
||||
Sleep=0x002F, # also known as StandBySet
|
||||
BrowserStop=0x0030,
|
||||
OneTouchSync=0x0031,
|
||||
ZoomSet=0x0032,
|
||||
ZoomBtnInSet2=0x0033,
|
||||
ZoomBtnInSet=0x0034,
|
||||
ZoomBtnOutSet2=0x0035,
|
||||
ZoomBtnOutSet=0x0036,
|
||||
ZoomBtnResetSet=0x0037,
|
||||
Left_Click=0x0038, # LeftClick
|
||||
Right_Click=0x0039, # RightClick
|
||||
Mouse_Middle_Button=0x003A, # from M510v2 was MiddleMouseButton
|
||||
Back=0x003B,
|
||||
Mouse_Back_Button=0x003C, # from M510v2 was BackEx
|
||||
BrowserForward=0x003D,
|
||||
Mouse_Forward_Button=0x003E, # from M510v2 was BrowserForwardEx
|
||||
Mouse_Scroll_Left_Button_=0x003F, # from M510v2 was HorzScrollLeftSet
|
||||
Mouse_Scroll_Right_Button=0x0040, # from M510v2 was HorzScrollRightSet
|
||||
QuickSwitch=0x0041,
|
||||
BatteryStatus=0x0042,
|
||||
Show_Desktop=0x0043, # ShowDesktop
|
||||
WindowsLock=0x0044,
|
||||
FileLauncher=0x0045,
|
||||
FolderLauncher=0x0046,
|
||||
GotoWebAddress=0x0047,
|
||||
GenericMouseButton=0x0048,
|
||||
KeystrokeAssignment=0x0049,
|
||||
LaunchProgram=0x004A,
|
||||
MinMaxWindow=0x004B,
|
||||
VOLUMEMUTE_NoOSD=0x004C,
|
||||
New=0x004D,
|
||||
Copy=0x004E,
|
||||
CruiseDown=0x004F,
|
||||
CruiseUp=0x0050,
|
||||
Cut=0x0051,
|
||||
Do_Nothing=0x0052,
|
||||
PageDown=0x0053,
|
||||
PageUp=0x0054,
|
||||
Paste=0x0055,
|
||||
SearchPicture=0x0056,
|
||||
Reply=0x0057,
|
||||
PhotoGallerySet=0x0058,
|
||||
MM_REWIND=0x0059,
|
||||
MM_FASTFORWARD=0x005A,
|
||||
Send=0x005B,
|
||||
ControlPanel=0x005C,
|
||||
UniversalScroll=0x005D,
|
||||
AutoScroll=0x005E,
|
||||
GenericButton=0x005F,
|
||||
MM_NEXT=0x0060,
|
||||
MM_PREVIOUS=0x0061,
|
||||
Do_Nothing_One=0x0062, # also known as Do_Nothing
|
||||
SnapLeft=0x0063,
|
||||
SnapRight=0x0064,
|
||||
WinMinRestore=0x0065,
|
||||
WinMaxRestore=0x0066,
|
||||
WinStretch=0x0067,
|
||||
SwitchMonitorLeft=0x0068,
|
||||
SwitchMonitorRight=0x0069,
|
||||
ShowPresentation=0x006A,
|
||||
ShowMobilityCenter=0x006B,
|
||||
HorzScrollNoRepeatSet=0x006C,
|
||||
TouchBackForwardHorzScroll=0x0077,
|
||||
MetroAppSwitch=0x0078,
|
||||
MetroAppBar=0x0079,
|
||||
MetroCharms=0x007A,
|
||||
Calculator_VKEY=0x007B, # also known as Calculator
|
||||
MetroSearch=0x007C,
|
||||
MetroStartScreen=0x0080,
|
||||
MetroShare=0x007D,
|
||||
MetroSettings=0x007E,
|
||||
MetroDevices=0x007F,
|
||||
MetroBackLeftHorz=0x0082,
|
||||
MetroForwRightHorz=0x0083,
|
||||
Win8_Back=0x0084, # also known as MetroCharms
|
||||
Win8_Forward=0x0085, # also known as AppSwitchBar
|
||||
Win8Charm_Appswitch_GifAnimation=0x0086,
|
||||
Win8BackHorzLeft=0x008B, # also known as Back
|
||||
Win8ForwardHorzRight=0x008C, # also known as BrowserForward
|
||||
MetroSearch2=0x0087,
|
||||
MetroShare2=0x0088,
|
||||
MetroSettings2=0x008A,
|
||||
MetroDevices2=0x0089,
|
||||
Win8MetroWin7Forward=0x008D, # also known as MetroStartScreen
|
||||
Win8ShowDesktopWin7Back=0x008E, # also known as ShowDesktop
|
||||
MetroApplicationSwitch=0x0090, # also known as MetroStartScreen
|
||||
ShowUI=0x0092,
|
||||
MEDIA_CENTER_LOGITECH = 0x001E
|
||||
MEDIA_CENTER_MICROSOFT = 0x001F
|
||||
USER_MENU = 0x0020
|
||||
MESSENGER = 0x0021
|
||||
PERSONAL_FOLDERS = 0x0022
|
||||
MY_MUSIC = 0x0023
|
||||
WEBCAM = 0x0024
|
||||
PICTURES_FOLDER = 0x0025
|
||||
MY_VIDEOS = 0x0026
|
||||
MY_COMPUTER = 0x0027
|
||||
PICTURE_APP_SET = 0x0028
|
||||
SEARCH = 0x0029 # also known as AdvSmartSearch
|
||||
RECORD_MEDIA_PLAYER = 0x002A
|
||||
BROWSER_REFRESH = 0x002B
|
||||
ROTATE_RIGHT = 0x002C
|
||||
SEARCH_FILES = 0x002D # SearchForFiles
|
||||
MM_SHUFFLE = 0x002E
|
||||
SLEEP = 0x002F # also known as StandBySet
|
||||
BROWSER_STOP = 0x0030
|
||||
ONE_TOUCH_SYNC = 0x0031
|
||||
ZOOM_SET = 0x0032
|
||||
ZOOM_BTN_IN_SET_2 = 0x0033
|
||||
ZOOM_BTN_IN_SET = 0x0034
|
||||
ZOOM_BTN_OUT_SET_2 = 0x0035
|
||||
ZOOM_BTN_OUT_SET = 0x0036
|
||||
ZOOM_BTN_RESET_SET = 0x0037
|
||||
LEFT_CLICK = 0x0038 # LeftClick
|
||||
RIGHT_CLICK = 0x0039 # RightClick
|
||||
MOUSE_MIDDLE_BUTTON = 0x003A # from M510v2 was MiddleMouseButton
|
||||
BACK = 0x003B
|
||||
MOUSE_BACK_BUTTON = 0x003C # from M510v2 was BackEx
|
||||
BROWSER_FORWARD = 0x003D
|
||||
MOUSE_FORWARD_BUTTON = 0x003E # from M510v2 was BrowserForwardEx
|
||||
MOUSE_SCROLL_LEFT_BUTTON = 0x003F # from M510v2 was HorzScrollLeftSet
|
||||
MOUSE_SCROLL_RIGHT_BUTTON = 0x0040 # from M510v2 was HorzScrollRightSet
|
||||
QUICK_SWITCH = 0x0041
|
||||
BATTERY_STATUS = 0x0042
|
||||
SHOW_DESKTOP = 0x0043 # ShowDesktop
|
||||
WINDOWS_LOCK = 0x0044
|
||||
FILE_LAUNCHER = 0x0045
|
||||
FOLDER_LAUNCHER = 0x0046
|
||||
GOTO_WEB_ADDRESS = 0x0047
|
||||
GENERIC_MOUSE_BUTTON = 0x0048
|
||||
KEYSTROKE_ASSIGNMENT = 0x0049
|
||||
LAUNCH_PROGRAM = 0x004A
|
||||
MIN_MAX_WINDOW = 0x004B
|
||||
VOLUME_MUTE_NO_OSD = 0x004C
|
||||
NEW = 0x004D
|
||||
COPY = 0x004E
|
||||
CRUISE_DOWN = 0x004F
|
||||
CRUISE_UP = 0x0050
|
||||
CUT = 0x0051
|
||||
DO_NOTHING = 0x0052
|
||||
PAGE_DOWN = 0x0053
|
||||
PAGE_UP = 0x0054
|
||||
PASTE = 0x0055
|
||||
SEARCH_PICTURE = 0x0056
|
||||
REPLY = 0x0057
|
||||
PHOTO_GALLERY_SET = 0x0058
|
||||
MM_REWIND = 0x0059
|
||||
MM_FASTFORWARD = 0x005A
|
||||
SEND = 0x005B
|
||||
CONTROL_PANEL = 0x005C
|
||||
UNIVERSAL_SCROLL = 0x005D
|
||||
AUTO_SCROLL = 0x005E
|
||||
GENERIC_BUTTON = 0x005F
|
||||
MM_NEXT = 0x0060
|
||||
MM_PREVIOUS = 0x0061
|
||||
DO_NOTHING_ONE = 0x0062 # also known as Do_Nothing
|
||||
SNAP_LEFT = 0x0063
|
||||
SNAP_RIGHT = 0x0064
|
||||
WIN_MIN_RESTORE = 0x0065
|
||||
WIN_MAX_RESTORE = 0x0066
|
||||
WIN_STRETCH = 0x0067
|
||||
SWITCH_MONITOR_LEFT = 0x0068
|
||||
SWITCH_MONITOR_RIGHT = 0x0069
|
||||
SHOW_PRESENTATION = 0x006A
|
||||
SHOW_MOBILITY_CENTER = 0x006B
|
||||
HORZ_SCROLL_NO_REPEAT_SET = 0x006C
|
||||
TOUCH_BACK_FORWARD_HORZ_SCROLL = 0x0077
|
||||
METRO_APP_SWITCH = 0x0078
|
||||
METRO_APP_BAR = 0x0079
|
||||
METRO_CHARMS = 0x007A
|
||||
CALCULATOR_VKEY = 0x007B # also known as Calculator
|
||||
METRO_SEARCH = 0x007C
|
||||
METRO_START_SCREEN = 0x0080
|
||||
METRO_SHARE = 0x007D
|
||||
METRO_SETTINGS = 0x007E
|
||||
METRO_DEVICES = 0x007F
|
||||
METRO_BACK_LEFT_HORZ = 0x0082
|
||||
METRO_FORW_RIGHT_HORZ = 0x0083
|
||||
WIN8_BACK = 0x0084 # also known as MetroCharms
|
||||
WIN8_FORWARD = 0x0085 # also known as AppSwitchBar
|
||||
WIN8_CHARM_APPSWITCH_GIF_ANIMATION = 0x0086
|
||||
WIN8_BACK_HORZ_LEFT = 0x008B # also known as Back
|
||||
WIN8_FORWARD_HORZ_RIGHT = 0x008C # also known as BrowserForward
|
||||
METRO_SEARCH_2 = 0x0087
|
||||
METROA_SHARE_2 = 0x0088
|
||||
METRO_SETTINGS_2 = 0x008A
|
||||
METRO_DEVICES_2 = 0x0089
|
||||
WIN8_METRO_WIN7_FORWARD = 0x008D # also known as MetroStartScreen
|
||||
WIN8_SHOW_DESKTOP_WIN7_BACK = 0x008E # also known as ShowDesktop
|
||||
METRO_APPLICATION_SWITCH = 0x0090 # also known as MetroStartScreen
|
||||
SHOW_UI = 0x0092
|
||||
# https://docs.google.com/document/d/1Dpx_nWRQAZox_zpZ8SNc9nOkSDE9svjkghOCbzopabc/edit
|
||||
# Extract to csv. Eliminate extra linefeeds and spaces. Turn / into __ and space into _
|
||||
# awk -F, '/0x/{gsub(" \\+ ","_",$2); gsub("_-","_Down",$2); gsub("_\\+","_Up",$2);
|
||||
# gsub("[()\"-]","",$2); gsub(" ","_",$2); printf("\t%s=0x%04X,\n", $2, $1)}' < tasks.csv > tasks.py
|
||||
Switch_Presentation__Switch_Screen=0x0093, # on K400 Plus
|
||||
Minimize_Window=0x0094,
|
||||
Maximize_Window=0x0095, # on K400 Plus
|
||||
MultiPlatform_App_Switch=0x0096,
|
||||
MultiPlatform_Home=0x0097,
|
||||
MultiPlatform_Menu=0x0098,
|
||||
MultiPlatform_Back=0x0099,
|
||||
Switch_Language=0x009A, # Mac_switch_language
|
||||
Screen_Capture=0x009B, # Mac_screen_Capture, on Craft Keyboard
|
||||
Gesture_Button=0x009C,
|
||||
Smart_Shift=0x009D,
|
||||
AppExpose=0x009E,
|
||||
Smart_Zoom=0x009F,
|
||||
Lookup=0x00A0,
|
||||
Microphone_on__off=0x00A1,
|
||||
Wifi_on__off=0x00A2,
|
||||
Brightness_Down=0x00A3,
|
||||
Brightness_Up=0x00A4,
|
||||
Display_Out=0x00A5,
|
||||
View_Open_Apps=0x00A6,
|
||||
View_All_Open_Apps=0x00A7,
|
||||
AppSwitch=0x00A8,
|
||||
Gesture_Button_Navigation=0x00A9, # Mouse_Thumb_Button on MX Master
|
||||
Fn_inversion=0x00AA,
|
||||
Multiplatform_Back=0x00AB,
|
||||
Multiplatform_Forward=0x00AC,
|
||||
Multiplatform_Gesture_Button=0x00AD,
|
||||
HostSwitch_Channel_1=0x00AE,
|
||||
HostSwitch_Channel_2=0x00AF,
|
||||
HostSwitch_Channel_3=0x00B0,
|
||||
Multiplatform_Search=0x00B1,
|
||||
Multiplatform_Home__Mission_Control=0x00B2,
|
||||
Multiplatform_Menu__Launchpad=0x00B3,
|
||||
Virtual_Gesture_Button=0x00B4,
|
||||
Cursor=0x00B5,
|
||||
Keyboard_Right_Arrow=0x00B6,
|
||||
SW_Custom_Highlight=0x00B7,
|
||||
Keyboard_Left_Arrow=0x00B8,
|
||||
TBD=0x00B9,
|
||||
Multiplatform_Language_Switch=0x00BA,
|
||||
SW_Custom_Highlight_2=0x00BB,
|
||||
Fast_Forward=0x00BC,
|
||||
Fast_Backward=0x00BD,
|
||||
Switch_Highlighting=0x00BE,
|
||||
Mission_Control__Task_View=0x00BF, # Switch_Workspace on Craft Keyboard
|
||||
Dashboard_Launchpad__Action_Center=0x00C0, # Application_Launcher on Craft Keyboard
|
||||
Backlight_Down=0x00C1, # Backlight_Down_FW_internal_function
|
||||
Backlight_Up=0x00C2, # Backlight_Up_FW_internal_function
|
||||
Right_Click__App_Contextual_Menu=0x00C3, # Context_Menu on Craft Keyboard
|
||||
DPI_Change=0x00C4,
|
||||
New_Tab=0x00C5,
|
||||
F2=0x00C6,
|
||||
F3=0x00C7,
|
||||
F4=0x00C8,
|
||||
F5=0x00C9,
|
||||
F6=0x00CA,
|
||||
F7=0x00CB,
|
||||
F8=0x00CC,
|
||||
F1=0x00CD,
|
||||
Laser_Button=0x00CE,
|
||||
Laser_Button_Long_Press=0x00CF,
|
||||
Start_Presentation=0x00D0,
|
||||
Blank_Screen=0x00D1,
|
||||
DPI_Switch=0x00D2, # AdjustDPI on MX Vertical
|
||||
Home__Show_Desktop=0x00D3,
|
||||
App_Switch__Dashboard=0x00D4,
|
||||
App_Switch=0x00D5,
|
||||
Fn_Inversion=0x00D6,
|
||||
LeftAndRightClick=0x00D7,
|
||||
Voice_Dictation=0x00D8,
|
||||
Emoji_Smiling_Face_With_Heart_Shaped_Eyes=0x00D9,
|
||||
Emoji_Loudly_Crying_Face=0x00DA,
|
||||
Emoji_Smiley=0x00DB,
|
||||
Emoji_Smiley_With_Tears=0x00DC,
|
||||
Open_Emoji_Panel=0x00DD,
|
||||
Multiplatform_App_Switch__Launchpad=0x00DE,
|
||||
Snipping_Tool=0x00DF,
|
||||
Grave_Accent=0x00E0,
|
||||
Standard_Tab_Key=0x00E1,
|
||||
Caps_Lock=0x00E2,
|
||||
Left_Shift=0x00E3,
|
||||
Left_Control=0x00E4,
|
||||
Left_Option__Start=0x00E5,
|
||||
Left_Command__Alt=0x00E6,
|
||||
Right_Command__Alt=0x00E7,
|
||||
Right_Option__Start=0x00E8,
|
||||
Right_Control=0x00E9,
|
||||
Right_Shift=0x0EA,
|
||||
Insert=0x00EB,
|
||||
Delete=0x00EC,
|
||||
Home=0x00ED,
|
||||
End=0x00EE,
|
||||
Page_Up=0x00EF,
|
||||
Page_Down=0x00F0,
|
||||
Mute_Microphone=0x00F1,
|
||||
Do_Not_Disturb=0x00F2,
|
||||
Backslash=0x00F3,
|
||||
Refresh=0x00F4,
|
||||
Close_Tab=0x00F5,
|
||||
Lang_Switch=0x00F6,
|
||||
Standard_Alphabetical_Key=0x00F7,
|
||||
Right_Option__Start__2=0x00F8,
|
||||
Left_Option=0x00F9,
|
||||
Right_Option=0x00FA,
|
||||
Left_Cmd=0x00FB,
|
||||
Right_Cmd=0x00FC,
|
||||
)
|
||||
TASK._fallback = lambda x: f"unknown:{x:04X}"
|
||||
# Capabilities and desired software handling for a control
|
||||
# Ref: https://drive.google.com/file/d/10imcbmoxTJ1N510poGdsviEhoFfB_Ua4/view
|
||||
# We treat bytes 4 and 8 of `getCidInfo` as a single bitfield
|
||||
KEY_FLAG = _NamedInts(
|
||||
analytics_key_events=0x400,
|
||||
force_raw_XY=0x200,
|
||||
raw_XY=0x100,
|
||||
virtual=0x80,
|
||||
persistently_divertable=0x40,
|
||||
divertable=0x20,
|
||||
reprogrammable=0x10,
|
||||
FN_sensitive=0x08,
|
||||
nonstandard=0x04,
|
||||
is_FN=0x02,
|
||||
mse=0x01,
|
||||
)
|
||||
# Flags describing the reporting method of a control
|
||||
# We treat bytes 2 and 5 of `get/setCidReporting` as a single bitfield
|
||||
MAPPING_FLAG = _NamedInts(
|
||||
analytics_key_events_reporting=0x100,
|
||||
force_raw_XY_diverted=0x40,
|
||||
raw_XY_diverted=0x10,
|
||||
persistently_diverted=0x04,
|
||||
diverted=0x01,
|
||||
)
|
||||
CID_GROUP_BIT = _NamedInts(g8=0x80, g7=0x40, g6=0x20, g5=0x10, g4=0x08, g3=0x04, g2=0x02, g1=0x01)
|
||||
CID_GROUP = _NamedInts(g8=8, g7=7, g6=6, g5=5, g4=4, g3=3, g2=2, g1=1)
|
||||
DISABLE = _NamedInts(
|
||||
SWITCH_PRESENTATION_SWITCH_SCREEN = 0x0093 # on K400 Plus
|
||||
MINIMIZE_WINDOW = 0x0094
|
||||
MAXIMIZE_WINDOW = 0x0095 # on K400 Plus
|
||||
MULTI_PLATFORM_APP_SWITCH = 0x0096
|
||||
MULTI_PLATFORM_HOME = 0x0097
|
||||
MULTI_PLATFORM_MENU = 0x0098
|
||||
MULTI_PLATFORM_BACK = 0x0099
|
||||
SWITCH_LANGUAGE = 0x009A # Mac_switch_language
|
||||
SCREEN_CAPTURE = 0x009B # Mac_screen_Capture, on Craft Keyboard
|
||||
GESTURE_BUTTON = 0x009C
|
||||
SMART_SHIFT = 0x009D
|
||||
APP_EXPOSE = 0x009E
|
||||
SMART_ZOOM = 0x009F
|
||||
LOOKUP = 0x00A0
|
||||
MICROPHEON_ON_OFF = 0x00A1
|
||||
WIFI_ON_OFF = 0x00A2
|
||||
BRIGHTNESS_DOWN = 0x00A3
|
||||
BRIGHTNESS_UP = 0x00A4
|
||||
DISPLAY_OUT = 0x00A5
|
||||
VIEW_OPEN_APPS = 0x00A6
|
||||
VIEW_ALL_OPEN_APPS = 0x00A7
|
||||
APP_SWITCH = 0x00A8
|
||||
GESTURE_BUTTON_NAVIGATION = 0x00A9 # Mouse_Thumb_Button on MX Master
|
||||
FN_INVERSION = 0x00AA
|
||||
MULTI_PLATFORM_BACK_2 = 0x00AB # Alternative
|
||||
MULTI_PLATFORM_FORWARD = 0x00AC
|
||||
MULTI_PLATFORM_Gesture_Button = 0x00AD
|
||||
HostSwitch_Channel_1 = 0x00AE
|
||||
HostSwitch_Channel_2 = 0x00AF
|
||||
HostSwitch_Channel_3 = 0x00B0
|
||||
MULTI_PLATFORM_SEARCH = 0x00B1
|
||||
MULTI_PLATFORM_HOME_MISSION_CONTROL = 0x00B2
|
||||
MULTI_PLATFORM_MENU_LAUNCHPAD = 0x00B3
|
||||
VIRTUAL_GESTURE_BUTTON = 0x00B4
|
||||
CURSOR = 0x00B5
|
||||
KEYBOARD_RIGHT_ARROW = 0x00B6
|
||||
SW_CUSTOM_HIGHLIGHT = 0x00B7
|
||||
KEYBOARD_LEFT_ARROW = 0x00B8
|
||||
TBD = 0x00B9
|
||||
MULTI_PLATFORM_Language_Switch = 0x00BA
|
||||
SW_CUSTOM_HIGHLIGHT_2 = 0x00BB
|
||||
FAST_FORWARD = 0x00BC
|
||||
FAST_BACKWARD = 0x00BD
|
||||
SWITCH_HIGHLIGHTING = 0x00BE
|
||||
MISSION_CONTROL_TASK_VIEW = 0x00BF # Switch_Workspace on Craft Keyboard
|
||||
DASHBOARD_LAUNCHPAD_ACTION_CENTER = 0x00C0 # Application_Launcher on Craft
|
||||
# Keyboard
|
||||
BACKLIGHT_DOWN = 0x00C1 # Backlight_Down_FW_internal_function
|
||||
BACKLIGHT_UP = 0x00C2 # Backlight_Up_FW_internal_function
|
||||
RIGHT_CLICK_APP_CONTEXT_MENU = 0x00C3 # Context_Menu on Craft Keyboard
|
||||
DPI_Change = 0x00C4
|
||||
NEW_TAB = 0x00C5
|
||||
F2 = 0x00C6
|
||||
F3 = 0x00C7
|
||||
F4 = 0x00C8
|
||||
F5 = 0x00C9
|
||||
F6 = 0x00CA
|
||||
F7 = 0x00CB
|
||||
F8 = 0x00CC
|
||||
F1 = 0x00CD
|
||||
LASER_BUTTON = 0x00CE
|
||||
LASER_BUTTON_LONG_PRESS = 0x00CF
|
||||
START_PRESENTATION = 0x00D0
|
||||
BLANK_SCREEN = 0x00D1
|
||||
DPI_Switch = 0x00D2 # AdjustDPI on MX Vertical
|
||||
HOME_SHOW_DESKTOP = 0x00D3
|
||||
APP_SWITCH_DASHBOARD = 0x00D4
|
||||
APP_SWITCH_2 = 0x00D5 # Alternative
|
||||
FN_INVERSION_2 = 0x00D6 # Alternative
|
||||
LEFT_AND_RIGHT_CLICK = 0x00D7
|
||||
VOICE_DICTATION = 0x00D8
|
||||
EMOJI_SMILING_FACE_WITH_HEART_SHAPED_EYES = 0x00D9
|
||||
EMOJI_LOUDLY_CRYING_FACE = 0x00DA
|
||||
EMOJI_SMILEY = 0x00DB
|
||||
EMOJI_SMILE_WITH_TEARS = 0x00DC
|
||||
OPEN_EMOJI_PANEL = 0x00DD
|
||||
MULTI_PLATFORM_APP_SWITCH_LAUNCHPAD = 0x00DE
|
||||
SNIPPING_TOOL = 0x00DF
|
||||
GRAVE_ACCENT = 0x00E0
|
||||
STANDARD_TAB_KEY = 0x00E1
|
||||
CAPS_LOCK = 0x00E2
|
||||
LEFT_SHIFT = 0x00E3
|
||||
LEFT_CONTROL = 0x00E4
|
||||
LEFT_OPTION_START = 0x00E5
|
||||
LEFT_COMMAND_ALT = 0x00E6
|
||||
RIGHT_COMMAND_ALT = 0x00E7
|
||||
RIGHT_OPTION_START = 0x00E8
|
||||
RIGHT_CONTROL = 0x00E9
|
||||
RIGHT_SHIFT = 0x0EA
|
||||
INSERT = 0x00EB
|
||||
DELETE = 0x00EC
|
||||
HOME = 0x00ED
|
||||
END = 0x00EE
|
||||
PAGE_UP_2 = 0x00EF # Alternative
|
||||
PAGE_DOWN_2 = 0x00F0 # Alternative
|
||||
MUTE_MICROPHONE = 0x00F1
|
||||
DO_NOT_DISTURB = 0x00F2
|
||||
BACKSLASH = 0x00F3
|
||||
REFRESH = 0x00F4
|
||||
CLOSE_TAB = 0x00F5
|
||||
LANG_SWITCH = 0x00F6
|
||||
STANDARD_ALPHABETICAL_KEY = 0x00F7
|
||||
RRIGH_OPTION_START_2 = 0x00F8
|
||||
LEFT_OPTION = 0x00F9
|
||||
RIGHT_OPTION = 0x00FA
|
||||
LEFT_CMD = 0x00FB
|
||||
RIGHT_CMD = 0x00FC
|
||||
|
||||
def __str__(self):
|
||||
return self.name.replace("_", " ").title()
|
||||
|
||||
|
||||
class CIDGroupBit(IntEnum):
|
||||
g1 = 0x01
|
||||
g2 = 0x02
|
||||
g3 = 0x04
|
||||
g4 = 0x08
|
||||
g5 = 0x10
|
||||
g6 = 0x20
|
||||
g7 = 0x40
|
||||
g8 = 0x80
|
||||
|
||||
|
||||
class CidGroup(IntEnum):
|
||||
g1 = 1
|
||||
g2 = 2
|
||||
g3 = 3
|
||||
g4 = 4
|
||||
g5 = 5
|
||||
g6 = 6
|
||||
g7 = 7
|
||||
g8 = 8
|
||||
|
||||
|
||||
DISABLE = NamedInts(
|
||||
Caps_Lock=0x01,
|
||||
Num_Lock=0x02,
|
||||
Scroll_Lock=0x04,
|
||||
@@ -608,7 +626,7 @@ DISABLE._fallback = lambda x: f"unknown:{x:02X}"
|
||||
|
||||
# HID USB Keycodes from https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf
|
||||
# Modified by information from Linux HID driver linux/drivers/hid/hid-input.c
|
||||
USB_HID_KEYCODES = _NamedInts(
|
||||
USB_HID_KEYCODES = NamedInts(
|
||||
A=0x04,
|
||||
B=0x05,
|
||||
C=0x06,
|
||||
@@ -780,7 +798,7 @@ USB_HID_KEYCODES[0x26] = "9"
|
||||
USB_HID_KEYCODES[0x27] = "0"
|
||||
USB_HID_KEYCODES[0x64] = "102ND"
|
||||
|
||||
HID_CONSUMERCODES = _NamedInts(
|
||||
HID_CONSUMERCODES = NamedInts(
|
||||
{
|
||||
# Unassigned=0x00,
|
||||
# Consumer_Control=0x01,
|
||||
@@ -1167,9 +1185,9 @@ HID_CONSUMERCODES._fallback = lambda x: f"unknown:{x:04X}"
|
||||
|
||||
## Information for x1c00 Persistent from https://drive.google.com/drive/folders/0BxbRzx7vEV7eWmgwazJ3NUFfQ28
|
||||
|
||||
KEYMOD = _NamedInts(CTRL=0x01, SHIFT=0x02, ALT=0x04, META=0x08, RCTRL=0x10, RSHIFT=0x20, RALT=0x40, RMETA=0x80)
|
||||
KEYMOD = NamedInts(CTRL=0x01, SHIFT=0x02, ALT=0x04, META=0x08, RCTRL=0x10, RSHIFT=0x20, RALT=0x40, RMETA=0x80)
|
||||
|
||||
ACTIONID = _NamedInts(
|
||||
ACTIONID = NamedInts(
|
||||
Empty=0x00,
|
||||
Key=0x01,
|
||||
Mouse=0x02,
|
||||
@@ -1182,7 +1200,7 @@ ACTIONID = _NamedInts(
|
||||
Power=0x09,
|
||||
)
|
||||
|
||||
MOUSE_BUTTONS = _NamedInts(
|
||||
MOUSE_BUTTONS = NamedInts(
|
||||
Mouse_Button_Left=0x0001,
|
||||
Mouse_Button_Right=0x0002,
|
||||
Mouse_Button_Middle=0x0004,
|
||||
@@ -1202,14 +1220,14 @@ MOUSE_BUTTONS = _NamedInts(
|
||||
)
|
||||
MOUSE_BUTTONS._fallback = lambda x: f"unknown mouse button:{x:04X}"
|
||||
|
||||
HORIZONTAL_SCROLL = _NamedInts(
|
||||
Horizontal_Scroll_Left=0x4000,
|
||||
Horizontal_Scroll_Right=0x8000,
|
||||
)
|
||||
HORIZONTAL_SCROLL._fallback = lambda x: f"unknown horizontal scroll:{x:04X}"
|
||||
|
||||
class HorizontalScroll(IntEnum):
|
||||
Left = 0x4000
|
||||
Right = 0x8000
|
||||
|
||||
|
||||
# Construct universe for Persistent Remappable Keys setting (only for supported values)
|
||||
KEYS = _UnsortedNamedInts()
|
||||
KEYS = UnsortedNamedInts()
|
||||
KEYS_Default = 0x7FFFFFFF # Special value to reset key to default - has to be different from all others
|
||||
KEYS[KEYS_Default] = "Default" # Value to reset to default
|
||||
KEYS[0] = "None" # Value for no output
|
||||
@@ -1241,13 +1259,13 @@ for code in MOUSE_BUTTONS:
|
||||
KEYS[(ACTIONID.Mouse << 24) + (int(code) << 8)] = str(code)
|
||||
|
||||
# Add Horizontal Scroll
|
||||
for code in HORIZONTAL_SCROLL:
|
||||
for code in HorizontalScroll:
|
||||
KEYS[(ACTIONID.Hscroll << 24) + (int(code) << 8)] = str(code)
|
||||
|
||||
|
||||
# Construct subsets for known devices
|
||||
def persistent_keys(action_ids):
|
||||
keys = _UnsortedNamedInts()
|
||||
keys = UnsortedNamedInts()
|
||||
keys[KEYS_Default] = "Default" # Value to reset to default
|
||||
keys[0] = "No Output (only as default)"
|
||||
for key in KEYS:
|
||||
@@ -1259,7 +1277,7 @@ def persistent_keys(action_ids):
|
||||
KEYS_KEYS_CONSUMER = persistent_keys([ACTIONID.Key, ACTIONID.Consumer])
|
||||
KEYS_KEYS_MOUSE_HSCROLL = persistent_keys([ACTIONID.Key, ACTIONID.Mouse, ACTIONID.Hscroll])
|
||||
|
||||
COLORS = _UnsortedNamedInts(
|
||||
COLORS = UnsortedNamedInts(
|
||||
{
|
||||
# from Xorg rgb.txt,v 1.3 2000/08/17
|
||||
"red": 0xFF0000,
|
||||
@@ -1400,11 +1418,11 @@ COLORS = _UnsortedNamedInts(
|
||||
}
|
||||
)
|
||||
|
||||
COLORSPLUS = _UnsortedNamedInts({"No change": -1})
|
||||
COLORSPLUS = UnsortedNamedInts({"No change": -1})
|
||||
for i in COLORS:
|
||||
COLORSPLUS[int(i)] = str(i)
|
||||
|
||||
KEYCODES = _NamedInts(
|
||||
KEYCODES = NamedInts(
|
||||
{
|
||||
"A": 1,
|
||||
"B": 2,
|
||||
@@ -1529,11 +1547,11 @@ KEYCODES = _NamedInts(
|
||||
|
||||
# load in override dictionary for KEYCODES
|
||||
try:
|
||||
if _os.path.isfile(_keys_file_path):
|
||||
if os.path.isfile(_keys_file_path):
|
||||
with open(_keys_file_path) as keys_file:
|
||||
keys = _yaml.safe_load(keys_file)
|
||||
keys = yaml.safe_load(keys_file)
|
||||
if isinstance(keys, dict):
|
||||
keys = _NamedInts(**keys)
|
||||
keys = NamedInts(**keys)
|
||||
for k in KEYCODES:
|
||||
if int(k) not in keys and str(k) not in keys:
|
||||
keys[int(k)] = str(k)
|
||||
|
||||
@@ -14,20 +14,28 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
import pkgutil as _pkgutil
|
||||
import subprocess as _subprocess
|
||||
import sys as _sys
|
||||
import pkgutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
NAME = "Solaar"
|
||||
|
||||
try:
|
||||
__version__ = (
|
||||
_subprocess.check_output(["git", "describe", "--always"], cwd=_sys.path[0], stderr=_subprocess.DEVNULL)
|
||||
subprocess.check_output(
|
||||
[
|
||||
"git",
|
||||
"describe",
|
||||
"--always",
|
||||
],
|
||||
cwd=sys.path[0],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
.strip()
|
||||
.decode()
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
__version__ = _pkgutil.get_data("solaar", "commit").strip().decode()
|
||||
__version__ = pkgutil.get_data("solaar", "commit").strip().decode()
|
||||
except Exception:
|
||||
__version__ = _pkgutil.get_data("solaar", "version").strip().decode()
|
||||
__version__ = pkgutil.get_data("solaar", "version").strip().decode()
|
||||
|
||||
@@ -14,36 +14,32 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
import argparse as _argparse
|
||||
import argparse
|
||||
import logging
|
||||
import sys as _sys
|
||||
import sys
|
||||
|
||||
from importlib import import_module
|
||||
from traceback import extract_tb
|
||||
from traceback import format_exc
|
||||
|
||||
import logitech_receiver.device as _device
|
||||
import logitech_receiver.receiver as _receiver
|
||||
|
||||
from logitech_receiver.base import receivers
|
||||
from logitech_receiver.base import receivers_and_devices
|
||||
from logitech_receiver import base
|
||||
from logitech_receiver import device
|
||||
from logitech_receiver import receiver
|
||||
|
||||
from solaar import NAME
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
def _create_parser():
|
||||
parser = _argparse.ArgumentParser(
|
||||
prog=NAME.lower(), add_help=False, epilog=f"For details on individual actions, run `{NAME.lower()} <action> --help`."
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=NAME.lower(),
|
||||
add_help=False,
|
||||
epilog=f"For details on individual actions, run `{NAME.lower()} <action> --help`.",
|
||||
)
|
||||
subparsers = parser.add_subparsers(title="actions", help="optional action to perform")
|
||||
subparsers = parser.add_subparsers(title="actions", help="command-line action to perform")
|
||||
|
||||
sp = subparsers.add_parser("show", help="show information about devices")
|
||||
sp = subparsers.add_parser("show", description="Show information about device or all devices.")
|
||||
sp.add_argument(
|
||||
"device",
|
||||
nargs="?",
|
||||
@@ -53,29 +49,34 @@ def _create_parser():
|
||||
)
|
||||
sp.set_defaults(action="show")
|
||||
|
||||
sp = subparsers.add_parser("probe", help="probe a receiver (debugging use only)")
|
||||
sp = subparsers.add_parser("probe", description="Probe a receiver (debugging use only).")
|
||||
sp.add_argument(
|
||||
"receiver", nargs="?", help="select receiver by name substring or serial number when more than one is present"
|
||||
)
|
||||
sp.set_defaults(action="probe")
|
||||
|
||||
sp = subparsers.add_parser("profiles", help="read or write onboard profiles", epilog="Only works on active devices.")
|
||||
sp = subparsers.add_parser(
|
||||
"profiles",
|
||||
description="Print or load YAML dump of profiles.",
|
||||
epilog="Only works on active devices.",
|
||||
)
|
||||
sp.add_argument(
|
||||
"device",
|
||||
help="device to read or write profiles of; may be a device number (1..6), a serial number, "
|
||||
"a substring of a device's name",
|
||||
help="device to read or load profiles; may be a device number (1..6), a serial number, "
|
||||
"or a substring of a device's name",
|
||||
)
|
||||
sp.add_argument("profiles", nargs="?", help="file containing YAML dump of profiles")
|
||||
sp.add_argument("profiles", nargs="?", help="file containing YAML dump of profiles to load")
|
||||
sp.set_defaults(action="profiles")
|
||||
|
||||
sp = subparsers.add_parser(
|
||||
"config",
|
||||
help="read/write device-specific settings",
|
||||
description="Print or load device-specific settings. Only some settings can be loaded. "
|
||||
"Loading complex settings uses the same syntax as in ~/.config/solaar/config.yaml",
|
||||
epilog="Please note that configuration only works on active devices.",
|
||||
)
|
||||
sp.add_argument(
|
||||
"device",
|
||||
help="device to configure; may be a device number (1..6), a serial number, " "or a substring of a device's name",
|
||||
help="device to configure; may be a device number (1..6), a serial number, or a substring of a device's name",
|
||||
)
|
||||
sp.add_argument("setting", nargs="?", help="device-specific setting; leave empty to list available settings")
|
||||
sp.add_argument("value_key", nargs="?", help="new value for the setting or key for keyed settings")
|
||||
@@ -85,7 +86,7 @@ def _create_parser():
|
||||
|
||||
sp = subparsers.add_parser(
|
||||
"pair",
|
||||
help="pair a new device",
|
||||
description="Pair a new device with a receiver. The device has to be compatible with the receiver.",
|
||||
epilog="The Logitech Unifying Receiver supports up to 6 paired devices at the same time.",
|
||||
)
|
||||
sp.add_argument(
|
||||
@@ -93,7 +94,7 @@ def _create_parser():
|
||||
)
|
||||
sp.set_defaults(action="pair")
|
||||
|
||||
sp = subparsers.add_parser("unpair", help="unpair a device")
|
||||
sp = subparsers.add_parser("unpair", description="Unpair a device from its receiver. Not all receivers allow unpairing.")
|
||||
sp.add_argument(
|
||||
"device",
|
||||
help="device to unpair; may be a device number (1..6), a serial number, " "or a substring of a device's name.",
|
||||
@@ -108,37 +109,35 @@ print_help = _cli_parser.print_help
|
||||
|
||||
|
||||
def _receivers(dev_path=None):
|
||||
for dev_info in receivers():
|
||||
for dev_info in base.receivers():
|
||||
if dev_path is not None and dev_path != dev_info.path:
|
||||
continue
|
||||
try:
|
||||
r = _receiver.ReceiverFactory.create_receiver(dev_info)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("[%s] => %s", dev_info.path, r)
|
||||
r = receiver.create_receiver(base, dev_info)
|
||||
logger.debug("[%s] => %s", dev_info.path, r)
|
||||
if r:
|
||||
yield r
|
||||
except Exception as e:
|
||||
logger.exception("opening " + str(dev_info))
|
||||
_sys.exit(f"{NAME.lower()}: error: {str(e)}")
|
||||
sys.exit(f"{NAME.lower()}: error: {str(e)}")
|
||||
|
||||
|
||||
def _receivers_and_devices(dev_path=None):
|
||||
for dev_info in receivers_and_devices():
|
||||
for dev_info in base.receivers_and_devices():
|
||||
if dev_path is not None and dev_path != dev_info.path:
|
||||
continue
|
||||
try:
|
||||
if dev_info.isDevice:
|
||||
d = _device.DeviceFactory.create_device(dev_info)
|
||||
d = device.create_device(base, dev_info)
|
||||
else:
|
||||
d = _receiver.ReceiverFactory.create_receiver(dev_info)
|
||||
d = receiver.create_receiver(base, dev_info)
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("[%s] => %s", dev_info.path, d)
|
||||
logger.debug("[%s] => %s", dev_info.path, d)
|
||||
if d is not None:
|
||||
yield d
|
||||
except Exception as e:
|
||||
logger.exception("opening " + str(dev_info))
|
||||
_sys.exit(f"{NAME.lower()}: error: {str(e)}")
|
||||
sys.exit(f"{NAME.lower()}: error: {str(e)}")
|
||||
|
||||
|
||||
def _find_receiver(receivers, name):
|
||||
@@ -190,9 +189,6 @@ def _find_device(receivers, name):
|
||||
break
|
||||
|
||||
|
||||
# raise Exception("no device found matching '%s'" % name)
|
||||
|
||||
|
||||
def run(cli_args=None, hidraw_path=None):
|
||||
if cli_args:
|
||||
action = cli_args[0]
|
||||
@@ -202,9 +198,9 @@ def run(cli_args=None, hidraw_path=None):
|
||||
# Python 3 has an undocumented 'feature' that breaks parsing empty args
|
||||
# http://bugs.python.org/issue16308
|
||||
if "cmd" not in args:
|
||||
_cli_parser.print_usage(_sys.stderr)
|
||||
_sys.stderr.write(f"{NAME.lower()}: error: too few arguments\n")
|
||||
_sys.exit(2)
|
||||
_cli_parser.print_usage(sys.stderr)
|
||||
sys.stderr.write(f"{NAME.lower()}: error: too few arguments\n")
|
||||
sys.exit(2)
|
||||
action = args.action
|
||||
assert action in actions
|
||||
|
||||
@@ -215,12 +211,12 @@ def run(cli_args=None, hidraw_path=None):
|
||||
c = list(_receivers(hidraw_path))
|
||||
if not c:
|
||||
raise Exception(
|
||||
'No supported device found. Use "lsusb" and "bluetoothctl devices Connected" to list connected devices.'
|
||||
'No supported device found. Use "lsusb" and "bluetoothctl devices Connected" to list connected devices.'
|
||||
)
|
||||
m = import_module("." + action, package=__name__)
|
||||
m.run(c, args, _find_receiver, _find_device)
|
||||
except AssertionError:
|
||||
tb_last = extract_tb(_sys.exc_info()[2])[-1]
|
||||
_sys.exit(f"{NAME.lower()}: assertion failed: {tb_last[0]} line {int(tb_last[1])}")
|
||||
tb_last = extract_tb(sys.exc_info()[2])[-1]
|
||||
sys.exit(f"{NAME.lower()}: assertion failed: {tb_last[0]} line {int(tb_last[1])}")
|
||||
except Exception:
|
||||
_sys.exit(f"{NAME.lower()}: error: {format_exc()}")
|
||||
sys.exit(f"{NAME.lower()}: error: {format_exc()}")
|
||||
|
||||
@@ -14,13 +14,16 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
import yaml as _yaml
|
||||
import yaml
|
||||
|
||||
from logitech_receiver import settings as _settings
|
||||
from logitech_receiver import settings_templates as _settings_templates
|
||||
from logitech_receiver.common import NamedInts as _NamedInts
|
||||
from logitech_receiver import settings
|
||||
from logitech_receiver import settings_templates
|
||||
from logitech_receiver.common import NamedInts
|
||||
from logitech_receiver.settings_templates import SettingsProtocol
|
||||
|
||||
from solaar import configuration as _configuration
|
||||
from solaar import configuration
|
||||
|
||||
APP_ID = "io.github.pwr_solaar.solaar"
|
||||
|
||||
|
||||
def _print_setting(s, verbose=True):
|
||||
@@ -28,9 +31,9 @@ def _print_setting(s, verbose=True):
|
||||
if verbose:
|
||||
if s.description:
|
||||
print("#", s.description.replace("\n", " "))
|
||||
if s.kind == _settings.KIND.toggle:
|
||||
if s.kind == settings.Kind.TOGGLE:
|
||||
print("# possible values: on/true/t/yes/y/1 or off/false/f/no/n/0 or Toggle/~")
|
||||
elif s.kind == _settings.KIND.choice:
|
||||
elif s.kind == settings.Kind.CHOICE:
|
||||
print(
|
||||
"# possible values: one of [",
|
||||
", ".join(str(v) for v in s.choices),
|
||||
@@ -51,7 +54,7 @@ def _print_setting_keyed(s, key, verbose=True):
|
||||
if verbose:
|
||||
if s.description:
|
||||
print("#", s.description.replace("\n", " "))
|
||||
if s.kind == _settings.KIND.multiple_toggle:
|
||||
if s.kind == settings.Kind.MULTIPLE_TOGGLE:
|
||||
k = next((k for k in s._labels if key == k), None)
|
||||
if k is None:
|
||||
print(s.name, "=? (key not found)")
|
||||
@@ -62,7 +65,7 @@ def _print_setting_keyed(s, key, verbose=True):
|
||||
print(s.name, "= ? (failed to read from device)")
|
||||
else:
|
||||
print(s.name, s.val_to_string({k: value[str(int(k))]}))
|
||||
elif s.kind == _settings.KIND.map_choice:
|
||||
elif s.kind == settings.Kind.MAP_CHOICE:
|
||||
k = next((k for k in s.choices.keys() if key == k), None)
|
||||
if k is None:
|
||||
print(s.name, "=? (key not found)")
|
||||
@@ -92,7 +95,7 @@ def select_choice(value, choices, setting, key):
|
||||
break
|
||||
if val is not None:
|
||||
value = val
|
||||
elif ivalue is not None and ivalue >= 1 and ivalue <= len(choices):
|
||||
elif ivalue is not None and 1 <= ivalue <= len(choices):
|
||||
value = choices[ivalue - 1]
|
||||
elif lvalue in ("higher", "lower"):
|
||||
old_value = setting.read() if key is None else setting.read_key(key)
|
||||
@@ -134,13 +137,13 @@ def select_range(value, setting):
|
||||
value = int(value)
|
||||
except ValueError as exc:
|
||||
raise Exception(f"{setting.name}: can't interpret '{value}' as integer") from exc
|
||||
min, max = setting.range
|
||||
if value < min or value > max:
|
||||
minimum, maximum = setting.range
|
||||
if value < minimum or value > maximum:
|
||||
raise Exception(f"{setting.name}: value '{value}' out of bounds")
|
||||
return value
|
||||
|
||||
|
||||
def run(receivers, args, find_receiver, find_device):
|
||||
def run(receivers, args, _find_receiver, find_device):
|
||||
assert receivers
|
||||
assert args.device
|
||||
|
||||
@@ -158,8 +161,7 @@ def run(receivers, args, find_receiver, find_device):
|
||||
if not args.setting: # print all settings, so first set them all up
|
||||
if not dev.settings:
|
||||
raise Exception(f"no settings for {dev.name}")
|
||||
_configuration.attach_to(dev)
|
||||
# _settings.apply_all_settings(dev)
|
||||
configuration.attach_to(dev)
|
||||
print(dev.name, f"({dev.codename}) [{dev.wpid}:{dev.serial}]")
|
||||
for s in dev.settings:
|
||||
print("")
|
||||
@@ -167,7 +169,7 @@ def run(receivers, args, find_receiver, find_device):
|
||||
return
|
||||
|
||||
setting_name = args.setting.lower()
|
||||
setting = _settings_templates.check_feature_setting(dev, setting_name)
|
||||
setting = settings_templates.check_feature_setting(dev, setting_name)
|
||||
if not setting and dev.descriptor and dev.descriptor.settings:
|
||||
for sclass in dev.descriptor.settings:
|
||||
if sclass.register and sclass.name == setting_name:
|
||||
@@ -179,7 +181,6 @@ def run(receivers, args, find_receiver, find_device):
|
||||
raise Exception(f"no setting '{args.setting}' for {dev.name}")
|
||||
|
||||
if args.value_key is None:
|
||||
# setting.apply()
|
||||
_print_setting(setting)
|
||||
return
|
||||
|
||||
@@ -192,7 +193,6 @@ def run(receivers, args, find_receiver, find_device):
|
||||
from gi.repository import Gtk
|
||||
|
||||
if Gtk.init_check()[0]: # can Gtk be initialized?
|
||||
APP_ID = "io.github.pwr_solaar.solaar"
|
||||
application = Gtk.Application.new(APP_ID, Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
|
||||
application.register()
|
||||
remote = application.get_is_remote()
|
||||
@@ -210,35 +210,36 @@ def run(receivers, args, find_receiver, find_device):
|
||||
if remote:
|
||||
argl = ["config", dev.serial or dev.unitId, setting.name]
|
||||
argl.extend([a for a in [args.value_key, args.extra_subkey, args.extra2] if a is not None])
|
||||
application.run(_yaml.safe_dump(argl))
|
||||
args = yaml.dump(argl)
|
||||
application.run(args)
|
||||
else:
|
||||
if dev.persister and setting.persist:
|
||||
dev.persister[setting.name] = setting._value
|
||||
|
||||
|
||||
def set(dev, setting, args, save):
|
||||
if setting.kind == _settings.KIND.toggle:
|
||||
def set(dev, setting: SettingsProtocol, args, save):
|
||||
if setting.kind == settings.Kind.TOGGLE:
|
||||
value = select_toggle(args.value_key, setting)
|
||||
args.value_key = value
|
||||
message = f"Setting {setting.name} of {dev.name} to {value}"
|
||||
result = setting.write(value, save=save)
|
||||
|
||||
elif setting.kind == _settings.KIND.range:
|
||||
elif setting.kind == settings.Kind.RANGE:
|
||||
value = select_range(args.value_key, setting)
|
||||
args.value_key = value
|
||||
message = f"Setting {setting.name} of {dev.name} to {value}"
|
||||
result = setting.write(value, save=save)
|
||||
|
||||
elif setting.kind == _settings.KIND.choice:
|
||||
elif setting.kind == settings.Kind.CHOICE:
|
||||
value = select_choice(args.value_key, setting.choices, setting, None)
|
||||
args.value_key = int(value)
|
||||
message = f"Setting {setting.name} of {dev.name} to {value}"
|
||||
result = setting.write(value, save=save)
|
||||
|
||||
elif setting.kind == _settings.KIND.map_choice:
|
||||
elif setting.kind == settings.Kind.MAP_CHOICE:
|
||||
if args.extra_subkey is None:
|
||||
_print_setting_keyed(setting, args.value_key)
|
||||
return (None, None, None)
|
||||
return None, None, None
|
||||
key = args.value_key
|
||||
ikey = to_int(key)
|
||||
k = next((k for k in setting.choices.keys() if key == k), None)
|
||||
@@ -253,13 +254,13 @@ def set(dev, setting, args, save):
|
||||
message = f"Setting {setting.name} of {dev.name} key {k!r} to {value!r}"
|
||||
result = setting.write_key_value(int(k), value, save=save)
|
||||
|
||||
elif setting.kind == _settings.KIND.multiple_toggle:
|
||||
elif setting.kind == settings.Kind.MULTIPLE_TOGGLE:
|
||||
if args.extra_subkey is None:
|
||||
_print_setting_keyed(setting, args.value_key)
|
||||
return (None, None, None)
|
||||
return None, None, None
|
||||
key = args.value_key
|
||||
all_keys = getattr(setting, "choices_universe", None)
|
||||
ikey = all_keys[int(key) if key.isdigit() else key] if isinstance(all_keys, _NamedInts) else to_int(key)
|
||||
ikey = all_keys[int(key) if key.isdigit() else key] if isinstance(all_keys, NamedInts) else to_int(key)
|
||||
k = next((k for k in setting._labels if key == k), None)
|
||||
if k is None and ikey is not None:
|
||||
k = next((k for k in setting._labels if ikey == k), None)
|
||||
@@ -272,12 +273,12 @@ def set(dev, setting, args, save):
|
||||
message = f"Setting {setting.name} key {k!r} to {value!r}"
|
||||
result = setting.write_key_value(str(int(k)), value, save=save)
|
||||
|
||||
elif setting.kind == _settings.KIND.multiple_range:
|
||||
elif setting.kind == settings.Kind.MULTIPLE_RANGE:
|
||||
if args.extra_subkey is None:
|
||||
raise Exception(f"{setting.name}: setting needs both key and value to set")
|
||||
key = args.value_key
|
||||
all_keys = getattr(setting, "choices_universe", None)
|
||||
ikey = all_keys[int(key) if key.isdigit() else key] if isinstance(all_keys, _NamedInts) else to_int(key)
|
||||
ikey = all_keys[int(key) if key.isdigit() else key] if isinstance(all_keys, NamedInts) else to_int(key)
|
||||
if args.extra2 is None or to_int(args.extra2) is None:
|
||||
raise Exception(f"{setting.name}: setting needs an integer value, not {args.extra2}")
|
||||
if not setting._value: # ensure that there are values to look through
|
||||
@@ -295,7 +296,25 @@ def set(dev, setting, args, save):
|
||||
result = setting.write_key_value(int(k), item, save=save)
|
||||
value = item
|
||||
|
||||
elif setting.kind == settings.Kind.MAP_RANGE:
|
||||
if args.extra_subkey is None:
|
||||
_print_setting_keyed(setting, args.value_key)
|
||||
return None, None, None
|
||||
key = int(args.value_key)
|
||||
value = int(args.extra_subkey)
|
||||
if key not in setting._device_object:
|
||||
raise Exception(f"{setting.name}: key '{key}' not in setting")
|
||||
message = f"Setting {setting.name} of {dev.name} key {key} to {value}"
|
||||
result = setting.write_key_value(key, value, save=save)
|
||||
|
||||
elif setting.kind == settings.Kind.HETERO:
|
||||
value = yaml.safe_load(args.value_key)
|
||||
args.value_key = value
|
||||
message = f"Setting {setting.name} of {dev.name} to {value}"
|
||||
result = setting.write(value, save=save)
|
||||
|
||||
else:
|
||||
print(f"Setting {setting.name}, with kind {setting.kind.name}, not implemented")
|
||||
raise Exception("NotImplemented")
|
||||
|
||||
return result, message, value
|
||||
|
||||
@@ -14,15 +14,14 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
from time import time as _timestamp
|
||||
from time import time
|
||||
|
||||
from logitech_receiver import base as _base
|
||||
from logitech_receiver import base
|
||||
from logitech_receiver import hidpp10
|
||||
from logitech_receiver import hidpp10_constants as _hidpp10_constants
|
||||
from logitech_receiver import notifications as _notifications
|
||||
from logitech_receiver import hidpp10_constants
|
||||
from logitech_receiver import notifications
|
||||
|
||||
_hidpp10 = hidpp10.Hidpp10()
|
||||
_R = _hidpp10_constants.REGISTERS
|
||||
|
||||
|
||||
def run(receivers, args, find_receiver, _ignore):
|
||||
@@ -39,9 +38,9 @@ def run(receivers, args, find_receiver, _ignore):
|
||||
assert receiver
|
||||
|
||||
# check if it's necessary to set the notification flags
|
||||
old_notification_flags = _hidpp10.get_notification_flags(receiver) or 0
|
||||
if not (old_notification_flags & _hidpp10_constants.NOTIFICATION_FLAG.wireless):
|
||||
_hidpp10.set_notification_flags(receiver, old_notification_flags | _hidpp10_constants.NOTIFICATION_FLAG.wireless)
|
||||
old_notification_flags = _hidpp10.get_notification_flags(receiver)
|
||||
if not (old_notification_flags & hidpp10_constants.NotificationFlag.WIRELESS):
|
||||
_hidpp10.set_notification_flags(receiver, old_notification_flags | hidpp10_constants.NotificationFlag.WIRELESS)
|
||||
|
||||
# get all current devices
|
||||
known_devices = [dev.number for dev in receiver]
|
||||
@@ -51,8 +50,8 @@ def run(receivers, args, find_receiver, _ignore):
|
||||
nonlocal known_devices
|
||||
assert n
|
||||
if n.devnumber == 0xFF:
|
||||
_notifications.process(receiver, n)
|
||||
elif n.sub_id == 0x41 and len(n.data) == _base._SHORT_MESSAGE_SIZE - 4:
|
||||
notifications.process(receiver, n)
|
||||
elif n.sub_id == 0x41 and len(n.data) == base.SHORT_MESSAGE_SIZE - 4:
|
||||
kd, known_devices = known_devices, None # only process one connection notification
|
||||
if kd is not None:
|
||||
if n.devnumber not in kd:
|
||||
@@ -67,13 +66,13 @@ def run(receivers, args, find_receiver, _ignore):
|
||||
if receiver.receiver_kind == "bolt": # Bolt receivers require authentication to pair a device
|
||||
receiver.discover(timeout=timeout)
|
||||
print("Bolt Pairing: long-press the pairing key or button on your device (timing out in", timeout, "seconds).")
|
||||
pairing_start = _timestamp()
|
||||
pairing_start = time()
|
||||
patience = 5 # the discovering notification may come slightly later, so be patient
|
||||
while receiver.pairing.discovering or _timestamp() - pairing_start < patience:
|
||||
while receiver.pairing.discovering or time() - pairing_start < patience:
|
||||
if receiver.pairing.device_address and receiver.pairing.device_authentication and receiver.pairing.device_name:
|
||||
break
|
||||
n = _base.read(receiver.handle)
|
||||
n = _base.make_notification(*n) if n else None
|
||||
n = base.read(receiver.handle)
|
||||
n = base.make_notification(*n) if n else None
|
||||
if n:
|
||||
receiver.handle.notifications_hook(n)
|
||||
address = receiver.pairing.device_address
|
||||
@@ -84,15 +83,15 @@ def run(receivers, args, find_receiver, _ignore):
|
||||
receiver.pair_device(
|
||||
address=address,
|
||||
authentication=authentication,
|
||||
entropy=20 if kind == _hidpp10_constants.DEVICE_KIND.keyboard else 10,
|
||||
entropy=20 if kind == hidpp10_constants.DEVICE_KIND.keyboard else 10,
|
||||
)
|
||||
pairing_start = _timestamp()
|
||||
pairing_start = time()
|
||||
patience = 5 # the discovering notification may come slightly later, so be patient
|
||||
while receiver.pairing.lock_open or _timestamp() - pairing_start < patience:
|
||||
while receiver.pairing.lock_open or time() - pairing_start < patience:
|
||||
if receiver.pairing.device_passkey:
|
||||
break
|
||||
n = _base.read(receiver.handle)
|
||||
n = _base.make_notification(*n) if n else None
|
||||
n = base.read(receiver.handle)
|
||||
n = base.make_notification(*n) if n else None
|
||||
if n:
|
||||
receiver.handle.notifications_hook(n)
|
||||
if authentication & 0x01:
|
||||
@@ -103,24 +102,26 @@ def run(receivers, args, find_receiver, _ignore):
|
||||
print(f"Bolt Pairing: press {passkey}")
|
||||
print("and then press left and right buttons simultaneously")
|
||||
while receiver.pairing.lock_open:
|
||||
n = _base.read(receiver.handle)
|
||||
n = _base.make_notification(*n) if n else None
|
||||
n = base.read(receiver.handle)
|
||||
n = base.make_notification(*n) if n else None
|
||||
if n:
|
||||
receiver.handle.notifications_hook(n)
|
||||
|
||||
else:
|
||||
receiver.set_lock(False, timeout=timeout)
|
||||
print("Pairing: turn your new device on (timing out in", timeout, "seconds).")
|
||||
pairing_start = _timestamp()
|
||||
print("Pairing: Turn your device on or press, hold, and release")
|
||||
print("a channel button or the channel switch button.")
|
||||
print("Timing out in", timeout, "seconds.")
|
||||
pairing_start = time()
|
||||
patience = 5 # the lock-open notification may come slightly later, wait for it a bit
|
||||
while receiver.pairing.lock_open or _timestamp() - pairing_start < patience:
|
||||
n = _base.read(receiver.handle)
|
||||
while receiver.pairing.lock_open or time() - pairing_start < patience:
|
||||
n = base.read(receiver.handle)
|
||||
if n:
|
||||
n = _base.make_notification(*n)
|
||||
n = base.make_notification(*n)
|
||||
if n:
|
||||
receiver.handle.notifications_hook(n)
|
||||
|
||||
if not (old_notification_flags & _hidpp10_constants.NOTIFICATION_FLAG.wireless):
|
||||
if not (old_notification_flags & hidpp10_constants.NotificationFlag.WIRELESS):
|
||||
# only clear the flags if they weren't set before, otherwise a
|
||||
# concurrently running Solaar app might stop working properly
|
||||
_hidpp10.set_notification_flags(receiver, old_notification_flags)
|
||||
|
||||
@@ -14,15 +14,14 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
from logitech_receiver import base as _base
|
||||
from logitech_receiver import hidpp10_constants as _hidpp10_constants
|
||||
from logitech_receiver.common import strhex as _strhex
|
||||
from logitech_receiver import base
|
||||
from logitech_receiver.common import strhex
|
||||
from logitech_receiver.hidpp10_constants import ErrorCode
|
||||
from logitech_receiver.hidpp10_constants import Registers
|
||||
|
||||
from solaar.cli.show import _print_device
|
||||
from solaar.cli.show import _print_receiver
|
||||
|
||||
_R = _hidpp10_constants.REGISTERS
|
||||
|
||||
|
||||
def run(receivers, args, find_receiver, _ignore):
|
||||
assert receivers
|
||||
@@ -45,37 +44,42 @@ def run(receivers, args, find_receiver, _ignore):
|
||||
|
||||
print("")
|
||||
print(" Register Dump")
|
||||
rgst = receiver.read_register(_R.notifications)
|
||||
print(" Notifications %#04x: %s" % (_R.notifications % 0x100, "0x" + _strhex(rgst) if rgst else "None"))
|
||||
rgst = receiver.read_register(_R.receiver_connection)
|
||||
print(" Connection State %#04x: %s" % (_R.receiver_connection % 0x100, "0x" + _strhex(rgst) if rgst else "None"))
|
||||
rgst = receiver.read_register(_R.devices_activity)
|
||||
print(" Device Activity %#04x: %s" % (_R.devices_activity % 0x100, "0x" + _strhex(rgst) if rgst else "None"))
|
||||
rgst = receiver.read_register(Registers.NOTIFICATIONS)
|
||||
print(" Notifications %#04x: %s" % (Registers.NOTIFICATIONS % 0x100, f"0x{strhex(rgst)}" if rgst else "None"))
|
||||
rgst = receiver.read_register(Registers.RECEIVER_CONNECTION)
|
||||
print(
|
||||
" Connection State %#04x: %s"
|
||||
% (Registers.RECEIVER_CONNECTION % 0x100, f"0x{strhex(rgst)}" if rgst else "None")
|
||||
)
|
||||
rgst = receiver.read_register(Registers.DEVICES_ACTIVITY)
|
||||
print(
|
||||
" Device Activity %#04x: %s" % (Registers.DEVICES_ACTIVITY % 0x100, f"0x{strhex(rgst)}" if rgst else "None")
|
||||
)
|
||||
|
||||
for sub_reg in range(0, 16):
|
||||
rgst = receiver.read_register(_R.receiver_info, sub_reg)
|
||||
rgst = receiver.read_register(Registers.RECEIVER_INFO, sub_reg)
|
||||
print(
|
||||
" Pairing Register %#04x %#04x: %s"
|
||||
% (_R.receiver_info % 0x100, sub_reg, "0x" + _strhex(rgst) if rgst else "None")
|
||||
% (Registers.RECEIVER_INFO % 0x100, sub_reg, f"0x{strhex(rgst)}" if rgst else "None")
|
||||
)
|
||||
for device in range(0, 7):
|
||||
for sub_reg in [0x10, 0x20, 0x30, 0x50]:
|
||||
rgst = receiver.read_register(_R.receiver_info, sub_reg + device)
|
||||
rgst = receiver.read_register(Registers.RECEIVER_INFO, sub_reg + device)
|
||||
print(
|
||||
" Pairing Register %#04x %#04x: %s"
|
||||
% (_R.receiver_info % 0x100, sub_reg + device, "0x" + _strhex(rgst) if rgst else "None")
|
||||
% (Registers.RECEIVER_INFO % 0x100, sub_reg + device, f"0x{strhex(rgst)}" if rgst else "None")
|
||||
)
|
||||
rgst = receiver.read_register(_R.receiver_info, 0x40 + device)
|
||||
rgst = receiver.read_register(Registers.RECEIVER_INFO, 0x40 + device)
|
||||
print(
|
||||
" Pairing Name %#04x %#02x: %s"
|
||||
% (_R.receiver_info % 0x100, 0x40 + device, rgst[2 : 2 + ord(rgst[1:2])] if rgst else "None")
|
||||
% (Registers.RECEIVER_INFO % 0x100, 0x40 + device, rgst[2 : 2 + ord(rgst[1:2])] if rgst else "None")
|
||||
)
|
||||
for part in range(1, 4):
|
||||
rgst = receiver.read_register(_R.receiver_info, 0x60 + device, part)
|
||||
rgst = receiver.read_register(Registers.RECEIVER_INFO, 0x60 + device, part)
|
||||
print(
|
||||
" Pairing Name %#04x %#02x %#02x: %2d %s"
|
||||
% (
|
||||
_R.receiver_info % 0x100,
|
||||
Registers.RECEIVER_INFO % 0x100,
|
||||
0x60 + device,
|
||||
part,
|
||||
ord(rgst[2:3]) if rgst else 0,
|
||||
@@ -83,39 +87,26 @@ def run(receivers, args, find_receiver, _ignore):
|
||||
)
|
||||
)
|
||||
for sub_reg in range(0, 5):
|
||||
rgst = receiver.read_register(_R.firmware, sub_reg)
|
||||
rgst = receiver.read_register(Registers.FIRMWARE, sub_reg)
|
||||
print(
|
||||
" Firmware %#04x %#04x: %s"
|
||||
% (_R.firmware % 0x100, sub_reg, "0x" + _strhex(rgst) if rgst is not None else "None")
|
||||
% (Registers.FIRMWARE % 0x100, sub_reg, f"0x{strhex(rgst)}" if rgst is not None else "None")
|
||||
)
|
||||
|
||||
print("")
|
||||
for reg in range(0, 0xFF):
|
||||
last = None
|
||||
for sub in range(0, 0xFF):
|
||||
rgst = _base.request(receiver.handle, 0xFF, 0x8100 | reg, sub, return_error=True)
|
||||
if isinstance(rgst, int) and rgst == _hidpp10_constants.ERROR.invalid_address:
|
||||
break
|
||||
elif isinstance(rgst, int) and rgst == _hidpp10_constants.ERROR.invalid_value:
|
||||
continue
|
||||
else:
|
||||
if not isinstance(last, bytes) or not isinstance(rgst, bytes) or last != rgst:
|
||||
print(
|
||||
" Register Short %#04x %#04x: %s"
|
||||
% (reg, sub, "0x" + _strhex(rgst) if isinstance(rgst, bytes) else str(rgst))
|
||||
)
|
||||
last = rgst
|
||||
last = None
|
||||
for sub in range(0, 0xFF):
|
||||
rgst = _base.request(receiver.handle, 0xFF, 0x8100 | (0x200 + reg), sub, return_error=True)
|
||||
if isinstance(rgst, int) and rgst == _hidpp10_constants.ERROR.invalid_address:
|
||||
break
|
||||
elif isinstance(rgst, int) and rgst == _hidpp10_constants.ERROR.invalid_value:
|
||||
continue
|
||||
else:
|
||||
if not isinstance(last, bytes) or not isinstance(rgst, bytes) or last != rgst:
|
||||
print(
|
||||
" Register Long %#04x %#04x: %s"
|
||||
% (reg, sub, "0x" + _strhex(rgst) if isinstance(rgst, bytes) else str(rgst))
|
||||
)
|
||||
last = rgst
|
||||
for offset, reg_type in [(0x00, "Short"), (0x200, "Long")]:
|
||||
last = None
|
||||
for sub in range(0, 0xFF):
|
||||
rgst = base.request(receiver.handle, 0xFF, 0x8100 | (offset + reg), sub, return_error=True)
|
||||
if isinstance(rgst, int) and rgst == ErrorCode.INVALID_ADDRESS:
|
||||
break
|
||||
elif isinstance(rgst, int) and rgst == ErrorCode.INVALID_VALUE:
|
||||
continue
|
||||
else:
|
||||
if not isinstance(last, bytes) or not isinstance(rgst, bytes) or last != rgst:
|
||||
print(
|
||||
" Register %s %#04x %#04x: %s"
|
||||
% (reg_type, reg, sub, "0x" + strhex(rgst) if isinstance(rgst, bytes) else str(rgst))
|
||||
)
|
||||
last = rgst
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
import traceback as _traceback
|
||||
import traceback
|
||||
|
||||
import yaml as _yaml
|
||||
import yaml
|
||||
|
||||
from logitech_receiver.hidpp20 import OnboardProfiles as _OnboardProfiles
|
||||
from logitech_receiver.hidpp20 import OnboardProfilesVersion as _OnboardProfilesVersion
|
||||
from logitech_receiver.hidpp20 import OnboardProfiles
|
||||
from logitech_receiver.hidpp20 import OnboardProfilesVersion
|
||||
|
||||
|
||||
def run(receivers, args, find_receiver, find_device):
|
||||
@@ -38,19 +38,21 @@ def run(receivers, args, find_receiver, find_device):
|
||||
if not dev:
|
||||
raise Exception(f"no online device found matching '{device_name}'")
|
||||
|
||||
if not (dev.online and dev.profiles):
|
||||
print(f"Device {dev.name} is either offline or has no onboard profiles")
|
||||
if not dev.online:
|
||||
print(f"Device {dev.name} is offline.")
|
||||
elif not dev.profiles:
|
||||
print(f"Device {dev.name} has no onboard profiles that Solaar supports.")
|
||||
elif not profiles_file:
|
||||
print(f"#Dumping profiles from {dev.name}")
|
||||
print(_yaml.dump(dev.profiles))
|
||||
print(yaml.dump(dev.profiles))
|
||||
else:
|
||||
try:
|
||||
with open(profiles_file, "r") as f:
|
||||
print(f"Reading profiles from {profiles_file}")
|
||||
profiles = _yaml.safe_load(f)
|
||||
if not isinstance(profiles, _OnboardProfiles):
|
||||
profiles = yaml.safe_load(f)
|
||||
if not isinstance(profiles, OnboardProfiles):
|
||||
print("Profiles file does not contain current onboard profiles")
|
||||
elif getattr(profiles, "version", None) != _OnboardProfilesVersion:
|
||||
elif getattr(profiles, "version", None) != OnboardProfilesVersion:
|
||||
version = getattr(profiles, "version", None)
|
||||
print(f"Missing or incorrect profile version {version} in loaded profile")
|
||||
elif getattr(profiles, "name", None) != dev.name:
|
||||
@@ -62,4 +64,4 @@ def run(receivers, args, find_receiver, find_device):
|
||||
print(f"Wrote {written} sectors to {dev.name}")
|
||||
except Exception as exc:
|
||||
print("Profiles not written:", exc)
|
||||
print(_traceback.format_exc())
|
||||
print(traceback.format_exc())
|
||||
|
||||
@@ -14,15 +14,18 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
from logitech_receiver import common
|
||||
from logitech_receiver import exceptions
|
||||
from logitech_receiver import hidpp10
|
||||
from logitech_receiver import hidpp10_constants as _hidpp10_constants
|
||||
from logitech_receiver import hidpp10_constants
|
||||
from logitech_receiver import hidpp20
|
||||
from logitech_receiver import hidpp20_constants as _hidpp20_constants
|
||||
from logitech_receiver import receiver as _receiver
|
||||
from logitech_receiver import settings_templates as _settings_templates
|
||||
from logitech_receiver.common import NamedInt as _NamedInt
|
||||
from logitech_receiver.common import strhex as _strhex
|
||||
from logitech_receiver import hidpp20_constants
|
||||
from logitech_receiver import receiver
|
||||
from logitech_receiver import settings_templates
|
||||
from logitech_receiver.common import LOGITECH_VENDOR_ID
|
||||
from logitech_receiver.common import NamedInt
|
||||
from logitech_receiver.common import strhex
|
||||
from logitech_receiver.hidpp20_constants import SupportedFeature
|
||||
|
||||
from solaar import NAME
|
||||
from solaar import __version__
|
||||
@@ -36,9 +39,9 @@ def _print_receiver(receiver):
|
||||
|
||||
print(receiver.name)
|
||||
print(" Device path :", receiver.path)
|
||||
print(f" USB id : 046d:{receiver.product_id}")
|
||||
print(f" USB id : {LOGITECH_VENDOR_ID:04x}:{receiver.product_id}")
|
||||
print(" Serial :", receiver.serial)
|
||||
pending = _hidpp10.get_configuration_pending_flags(receiver)
|
||||
pending = hidpp10.get_configuration_pending_flags(receiver)
|
||||
if pending:
|
||||
print(f" C Pending : {pending:02x}")
|
||||
if receiver.firmware:
|
||||
@@ -52,12 +55,12 @@ def _print_receiver(receiver):
|
||||
notification_flags = _hidpp10.get_notification_flags(receiver)
|
||||
if notification_flags is not None:
|
||||
if notification_flags:
|
||||
notification_names = _hidpp10_constants.NOTIFICATION_FLAG.flag_names(notification_flags)
|
||||
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags:06X})")
|
||||
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
|
||||
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags.value:06X})")
|
||||
else:
|
||||
print(" Notifications: (none)")
|
||||
|
||||
activity = receiver.read_register(_hidpp10_constants.REGISTERS.devices_activity)
|
||||
activity = receiver.read_register(hidpp10_constants.Registers.DEVICES_ACTIVITY)
|
||||
if activity:
|
||||
activity = [(d, ord(activity[d - 1 : d])) for d in range(1, receiver.max_devices)]
|
||||
activity_text = ", ".join(f"{int(d)}={int(a)}" for d, a in activity if a > 0)
|
||||
@@ -67,7 +70,7 @@ def _print_receiver(receiver):
|
||||
def _battery_text(level) -> str:
|
||||
if level is None:
|
||||
return "N/A"
|
||||
elif isinstance(level, _NamedInt):
|
||||
elif isinstance(level, NamedInt):
|
||||
return str(level)
|
||||
else:
|
||||
return f"{int(level)}%"
|
||||
@@ -79,8 +82,8 @@ def _battery_line(dev):
|
||||
level, nextLevel, status, voltage = battery.level, battery.next_level, battery.status, battery.voltage
|
||||
text = _battery_text(level)
|
||||
if voltage is not None:
|
||||
text = text + f" {voltage}mV "
|
||||
nextText = "" if nextLevel is None else ", next level " + _battery_text(nextLevel)
|
||||
text = f"{text} {voltage}mV "
|
||||
nextText = "" if nextLevel is None else f", next level {_battery_text(nextLevel)}"
|
||||
print(f" Battery: {text}, {status}{nextText}.")
|
||||
else:
|
||||
print(" Battery status unavailable.")
|
||||
@@ -103,7 +106,7 @@ def _print_device(dev, num=None):
|
||||
if dev.wpid:
|
||||
print(f" WPID : {dev.wpid}")
|
||||
if dev.product_id:
|
||||
print(f" USB id : 046d:{dev.product_id}")
|
||||
print(f" USB id : {LOGITECH_VENDOR_ID:04x}:{dev.product_id}")
|
||||
print(" Codename :", dev.codename)
|
||||
print(" Kind :", dev.kind)
|
||||
if dev.protocol:
|
||||
@@ -128,14 +131,14 @@ def _print_device(dev, num=None):
|
||||
notification_flags = _hidpp10.get_notification_flags(dev)
|
||||
if notification_flags is not None:
|
||||
if notification_flags:
|
||||
notification_names = _hidpp10_constants.NOTIFICATION_FLAG.flag_names(notification_flags)
|
||||
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
|
||||
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags:06X}).")
|
||||
else:
|
||||
print(" Notifications: (none).")
|
||||
device_features = _hidpp10.get_device_features(dev)
|
||||
if device_features is not None:
|
||||
if device_features:
|
||||
device_features_names = _hidpp10_constants.DEVICE_FEATURES.flag_names(device_features)
|
||||
device_features_names = hidpp10_constants.DeviceFeature.flag_names(device_features)
|
||||
print(f" Features: {', '.join(device_features_names)} (0x{device_features:06X})")
|
||||
else:
|
||||
print(" Features: (none)")
|
||||
@@ -143,15 +146,20 @@ def _print_device(dev, num=None):
|
||||
if dev.online and dev.features:
|
||||
print(f" Supports {len(dev.features)} HID++ 2.0 features:")
|
||||
dev_settings = []
|
||||
_settings_templates.check_feature_settings(dev, dev_settings)
|
||||
settings_templates.check_feature_settings(dev, dev_settings)
|
||||
for feature, index in dev.features.enumerate():
|
||||
flags = dev.request(0x0000, feature.bytes(2))
|
||||
if isinstance(feature, str):
|
||||
feature_bytes = bytes.fromhex(feature[-4:])
|
||||
else:
|
||||
feature_bytes = feature.to_bytes(2, byteorder="little")
|
||||
feature_int = int.from_bytes(feature_bytes, byteorder="little")
|
||||
flags = dev.request(0x0000, feature_bytes)
|
||||
flags = 0 if flags is None else ord(flags[1:2])
|
||||
flags = _hidpp20_constants.FEATURE_FLAG.flag_names(flags)
|
||||
version = dev.features.get_feature_version(int(feature))
|
||||
flags = common.flag_names(hidpp20_constants.FeatureFlag, flags)
|
||||
version = dev.features.get_feature_version(feature_int)
|
||||
version = version if version else 0
|
||||
print(" %2d: %-22s {%04X} V%s %s " % (index, feature, feature, version, ", ".join(flags)))
|
||||
if feature == _hidpp20_constants.FEATURE.HIRES_WHEEL:
|
||||
print(" %2d: %-22s {%04X} V%s %s " % (index, feature, feature_int, version, ", ".join(flags)))
|
||||
if feature == SupportedFeature.HIRES_WHEEL:
|
||||
wheel = _hidpp20.get_hires_wheel(dev)
|
||||
if wheel:
|
||||
multi, has_invert, has_switch, inv, res, target, ratchet = wheel
|
||||
@@ -168,7 +176,7 @@ def _print_device(dev, num=None):
|
||||
print(" HID++ notification")
|
||||
else:
|
||||
print(" HID notification")
|
||||
elif feature == _hidpp20_constants.FEATURE.MOUSE_POINTER:
|
||||
elif feature == SupportedFeature.MOUSE_POINTER:
|
||||
mouse_pointer = _hidpp20.get_mouse_pointer_info(dev)
|
||||
if mouse_pointer:
|
||||
print(f" DPI: {mouse_pointer['dpi']}")
|
||||
@@ -181,13 +189,13 @@ def _print_device(dev, num=None):
|
||||
print(" Provide vertical tuning, trackball")
|
||||
else:
|
||||
print(" No vertical tuning, standard mice")
|
||||
elif feature == _hidpp20_constants.FEATURE.VERTICAL_SCROLLING:
|
||||
elif feature == SupportedFeature.VERTICAL_SCROLLING:
|
||||
vertical_scrolling_info = _hidpp20.get_vertical_scrolling_info(dev)
|
||||
if vertical_scrolling_info:
|
||||
print(f" Roller type: {vertical_scrolling_info['roller']}")
|
||||
print(f" Ratchet per turn: {vertical_scrolling_info['ratchet']}")
|
||||
print(f" Scroll lines: {vertical_scrolling_info['lines']}")
|
||||
elif feature == _hidpp20_constants.FEATURE.HI_RES_SCROLLING:
|
||||
elif feature == SupportedFeature.HI_RES_SCROLLING:
|
||||
scrolling_mode, scrolling_resolution = _hidpp20.get_hi_res_scrolling_info(dev)
|
||||
if scrolling_mode:
|
||||
print(" Hi-res scrolling enabled")
|
||||
@@ -195,49 +203,46 @@ def _print_device(dev, num=None):
|
||||
print(" Hi-res scrolling disabled")
|
||||
if scrolling_resolution:
|
||||
print(f" Hi-res scrolling multiplier: {scrolling_resolution}")
|
||||
elif feature == _hidpp20_constants.FEATURE.POINTER_SPEED:
|
||||
elif feature == SupportedFeature.POINTER_SPEED:
|
||||
pointer_speed = _hidpp20.get_pointer_speed_info(dev)
|
||||
if pointer_speed:
|
||||
print(f" Pointer Speed: {pointer_speed}")
|
||||
elif feature == _hidpp20_constants.FEATURE.LOWRES_WHEEL:
|
||||
elif feature == SupportedFeature.LOWRES_WHEEL:
|
||||
wheel_status = _hidpp20.get_lowres_wheel_status(dev)
|
||||
if wheel_status:
|
||||
print(f" Wheel Reports: {wheel_status}")
|
||||
elif feature == _hidpp20_constants.FEATURE.NEW_FN_INVERSION:
|
||||
elif feature == SupportedFeature.NEW_FN_INVERSION:
|
||||
inversion = _hidpp20.get_new_fn_inversion(dev)
|
||||
if inversion:
|
||||
inverted, default_inverted = inversion
|
||||
print(" Fn-swap:", "enabled" if inverted else "disabled")
|
||||
print(" Fn-swap default:", "enabled" if default_inverted else "disabled")
|
||||
elif feature == _hidpp20_constants.FEATURE.HOSTS_INFO:
|
||||
elif feature == SupportedFeature.HOSTS_INFO:
|
||||
host_names = _hidpp20.get_host_names(dev)
|
||||
for host, (paired, name) in host_names.items():
|
||||
print(f" Host {host} ({'paired' if paired else 'unpaired'}): {name}")
|
||||
elif feature == _hidpp20_constants.FEATURE.DEVICE_NAME:
|
||||
elif feature == SupportedFeature.DEVICE_NAME:
|
||||
print(f" Name: {_hidpp20.get_name(dev)}")
|
||||
print(f" Kind: {_hidpp20.get_kind(dev)}")
|
||||
elif feature == _hidpp20_constants.FEATURE.DEVICE_FRIENDLY_NAME:
|
||||
elif feature == SupportedFeature.DEVICE_FRIENDLY_NAME:
|
||||
print(f" Friendly Name: {_hidpp20.get_friendly_name(dev)}")
|
||||
elif feature == _hidpp20_constants.FEATURE.DEVICE_FW_VERSION:
|
||||
elif feature == SupportedFeature.DEVICE_FW_VERSION:
|
||||
for fw in _hidpp20.get_firmware(dev):
|
||||
extras = _strhex(fw.extras) if fw.extras else ""
|
||||
extras = strhex(fw.extras) if fw.extras else ""
|
||||
print(f" Firmware: {fw.kind} {fw.name} {fw.version} {extras}")
|
||||
ids = _hidpp20.get_ids(dev)
|
||||
if ids:
|
||||
unitId, modelId, tid_map = ids
|
||||
print(f" Unit ID: {unitId} Model ID: {modelId} Transport IDs: {tid_map}")
|
||||
elif (
|
||||
feature == _hidpp20_constants.FEATURE.REPORT_RATE
|
||||
or feature == _hidpp20_constants.FEATURE.EXTENDED_ADJUSTABLE_REPORT_RATE
|
||||
):
|
||||
elif feature == SupportedFeature.REPORT_RATE or feature == SupportedFeature.EXTENDED_ADJUSTABLE_REPORT_RATE:
|
||||
print(f" Report Rate: {_hidpp20.get_polling_rate(dev)}")
|
||||
elif feature == _hidpp20_constants.FEATURE.CONFIG_CHANGE:
|
||||
response = dev.feature_request(_hidpp20_constants.FEATURE.CONFIG_CHANGE, 0x00)
|
||||
elif feature == SupportedFeature.CONFIG_CHANGE:
|
||||
response = dev.feature_request(SupportedFeature.CONFIG_CHANGE, 0x00)
|
||||
print(f" Configuration: {response.hex()}")
|
||||
elif feature == _hidpp20_constants.FEATURE.REMAINING_PAIRING:
|
||||
elif feature == SupportedFeature.REMAINING_PAIRING:
|
||||
print(f" Remaining Pairings: {int(_hidpp20.get_remaining_pairing(dev))}")
|
||||
elif feature == _hidpp20_constants.FEATURE.ONBOARD_PROFILES:
|
||||
if _hidpp20.get_onboard_mode(dev) == _hidpp20_constants.ONBOARD_MODES.MODE_HOST:
|
||||
elif feature == SupportedFeature.ONBOARD_PROFILES:
|
||||
if _hidpp20.get_onboard_mode(dev) == hidpp20_constants.OnboardMode.MODE_HOST:
|
||||
mode = "Host"
|
||||
else:
|
||||
mode = "On-Board"
|
||||
@@ -255,7 +260,8 @@ def _print_device(dev, num=None):
|
||||
v = setting.val_to_string(setting._device.persister.get(setting.name))
|
||||
print(f" {setting.label} (saved): {v}")
|
||||
try:
|
||||
v = setting.val_to_string(setting.read(False))
|
||||
v = setting.read(False)
|
||||
v = setting.val_to_string(v)
|
||||
except exceptions.FeatureCallError as e:
|
||||
v = "HID++ error " + str(e)
|
||||
except AssertionError as e:
|
||||
@@ -266,14 +272,17 @@ def _print_device(dev, num=None):
|
||||
print(f" Has {len(dev.keys)} reprogrammable keys:")
|
||||
for k in dev.keys:
|
||||
# TODO: add here additional variants for other REPROG_CONTROLS
|
||||
if dev.keys.keyversion == _hidpp20_constants.FEATURE.REPROG_CONTROLS_V2:
|
||||
if dev.keys.keyversion == SupportedFeature.REPROG_CONTROLS_V2:
|
||||
print(" %2d: %-26s => %-27s %s" % (k.index, k.key, k.default_task, ", ".join(k.flags)))
|
||||
if dev.keys.keyversion == _hidpp20_constants.FEATURE.REPROG_CONTROLS_V4:
|
||||
if dev.keys.keyversion == SupportedFeature.REPROG_CONTROLS_V4:
|
||||
print(" %2d: %-26s, default: %-27s => %-26s" % (k.index, k.key, k.default_task, k.mapped_to))
|
||||
gmask_fmt = ",".join(k.group_mask)
|
||||
gmask_fmt = gmask_fmt if gmask_fmt else "empty"
|
||||
print(f" {', '.join(k.flags)}, pos:{int(k.pos)}, group:{int(k.group):1}, group mask:{gmask_fmt}")
|
||||
report_fmt = ", ".join(k.mapping_flags)
|
||||
flag_names = list(common.flag_names(hidpp20.KeyFlag, k.flags.value))
|
||||
print(
|
||||
f" {', '.join(flag_names)}, pos:{int(k.pos)}, group:{int(k.group):1}, group mask:{gmask_fmt}"
|
||||
)
|
||||
report_fmt = list(common.flag_names(hidpp20.MappingFlag, k.mapping_flags.value))
|
||||
report_fmt = report_fmt if report_fmt else "default"
|
||||
print(f" reporting: {report_fmt}")
|
||||
if dev.online and dev.remap_keys:
|
||||
@@ -311,7 +320,7 @@ def run(devices, args, find_receiver, find_device):
|
||||
|
||||
if device_name == "all":
|
||||
for d in devices:
|
||||
if isinstance(d, _receiver.Receiver):
|
||||
if isinstance(d, receiver.Receiver):
|
||||
_print_receiver(d)
|
||||
count = d.count()
|
||||
if count:
|
||||
|
||||
@@ -26,8 +26,8 @@ def run(receivers, args, find_receiver, find_device):
|
||||
|
||||
if not dev.receiver.may_unpair:
|
||||
print(
|
||||
"Receiver with USB id %s for %s [%s:%s] does not unpair, but attempting anyway."
|
||||
% (dev.receiver.product_id, dev.name, dev.wpid, dev.serial)
|
||||
f"Receiver with USB id {dev.receiver.product_id} for {dev.name} [{dev.wpid}:{dev.serial}] does not unpair,",
|
||||
"but attempting anyway.",
|
||||
)
|
||||
try:
|
||||
# query these now, it's last chance to get them
|
||||
|
||||
@@ -15,22 +15,22 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
import json as _json
|
||||
import json
|
||||
import logging
|
||||
import os as _os
|
||||
import os
|
||||
import threading
|
||||
|
||||
import yaml as _yaml
|
||||
import yaml
|
||||
|
||||
from logitech_receiver.common import NamedInt as _NamedInt
|
||||
from logitech_receiver.common import NamedInt
|
||||
|
||||
from solaar import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_XDG_CONFIG_HOME = _os.environ.get("XDG_CONFIG_HOME") or _os.path.expanduser(_os.path.join("~", ".config"))
|
||||
_yaml_file_path = _os.path.join(_XDG_CONFIG_HOME, "solaar", "config.yaml")
|
||||
_json_file_path = _os.path.join(_XDG_CONFIG_HOME, "solaar", "config.json")
|
||||
_XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser(os.path.join("~", ".config"))
|
||||
_yaml_file_path = os.path.join(_XDG_CONFIG_HOME, "solaar", "config.yaml")
|
||||
_json_file_path = os.path.join(_XDG_CONFIG_HOME, "solaar", "config.json")
|
||||
|
||||
_KEY_VERSION = "_version"
|
||||
_KEY_NAME = "_NAME"
|
||||
@@ -45,25 +45,24 @@ _config = []
|
||||
|
||||
def _load():
|
||||
loaded_config = []
|
||||
if _os.path.isfile(_yaml_file_path):
|
||||
if os.path.isfile(_yaml_file_path):
|
||||
path = _yaml_file_path
|
||||
try:
|
||||
with open(_yaml_file_path) as config_file:
|
||||
loaded_config = _yaml.safe_load(config_file)
|
||||
loaded_config = yaml.safe_load(config_file)
|
||||
except Exception as e:
|
||||
logger.error("failed to load from %s: %s", _yaml_file_path, e)
|
||||
elif _os.path.isfile(_json_file_path):
|
||||
elif os.path.isfile(_json_file_path):
|
||||
path = _json_file_path
|
||||
try:
|
||||
with open(_json_file_path) as config_file:
|
||||
loaded_config = _json.load(config_file)
|
||||
loaded_config = json.load(config_file)
|
||||
except Exception as e:
|
||||
logger.error("failed to load from %s: %s", _json_file_path, e)
|
||||
loaded_config = _convert_json(loaded_config)
|
||||
else:
|
||||
path = None
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("load => %s", loaded_config)
|
||||
logger.debug("load => %s", loaded_config)
|
||||
global _config
|
||||
_config = _parse_config(loaded_config, path)
|
||||
|
||||
@@ -78,14 +77,13 @@ def _parse_config(loaded_config, config_path):
|
||||
loaded_version = loaded_config[0]
|
||||
discard_derived_properties = loaded_version != current_version
|
||||
if discard_derived_properties:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info(
|
||||
"config file '%s' was generated by another version of solaar "
|
||||
"(config: %s, current: %s). refreshing detected device capabilities",
|
||||
config_path,
|
||||
loaded_version,
|
||||
current_version,
|
||||
)
|
||||
logger.info(
|
||||
"config file '%s' was generated by another version of solaar "
|
||||
"(config: %s, current: %s). refreshing detected device capabilities",
|
||||
config_path,
|
||||
loaded_version,
|
||||
current_version,
|
||||
)
|
||||
|
||||
for device in loaded_config[1:]:
|
||||
assert isinstance(device, dict)
|
||||
@@ -129,10 +127,10 @@ def save(defer=False):
|
||||
global save_timer
|
||||
if not _config:
|
||||
return
|
||||
dirname = _os.path.dirname(_yaml_file_path)
|
||||
if not _os.path.isdir(dirname):
|
||||
dirname = os.path.dirname(_yaml_file_path)
|
||||
if not os.path.isdir(dirname):
|
||||
try:
|
||||
_os.makedirs(dirname)
|
||||
os.makedirs(dirname)
|
||||
except Exception:
|
||||
logger.error("failed to create %s", dirname)
|
||||
return
|
||||
@@ -153,9 +151,8 @@ def do_save():
|
||||
save_timer = None
|
||||
try:
|
||||
with open(_yaml_file_path, "w") as config_file:
|
||||
_yaml.dump(_config, config_file, default_flow_style=None, width=150)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("saved %s to %s", _config, _yaml_file_path)
|
||||
yaml.dump(_config, config_file, default_flow_style=None, width=150)
|
||||
logger.info("saved %s to %s", _config, _yaml_file_path)
|
||||
except Exception as e:
|
||||
logger.error("failed to save to %s: %s", _yaml_file_path, e)
|
||||
|
||||
@@ -216,14 +213,14 @@ def device_representer(dumper, data):
|
||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data)
|
||||
|
||||
|
||||
_yaml.add_representer(_DeviceEntry, device_representer)
|
||||
yaml.add_representer(_DeviceEntry, device_representer)
|
||||
|
||||
|
||||
def named_int_representer(dumper, data):
|
||||
return dumper.represent_scalar("tag:yaml.org,2002:int", str(int(data)))
|
||||
|
||||
|
||||
_yaml.add_representer(_NamedInt, named_int_representer)
|
||||
yaml.add_representer(NamedInt, named_int_representer)
|
||||
|
||||
|
||||
# A device can be identified by a combination of WPID and serial number (for receiver-connected devices)
|
||||
@@ -251,11 +248,9 @@ def persister(device):
|
||||
break
|
||||
if not entry:
|
||||
if not device.online: # don't create entry for offline devices
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("not setting up persister for offline device %s", device._name)
|
||||
logger.info("not setting up persister for offline device %s", device._name)
|
||||
return
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("setting up persister for device %s", device.name)
|
||||
logger.info("setting up persister for device %s", device.name)
|
||||
entry = _DeviceEntry()
|
||||
_config.append(entry)
|
||||
entry.update(device.name, device.wpid, device.serial, modelId, unitId)
|
||||
|
||||
28
lib/solaar/custom_logger.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import logging
|
||||
|
||||
|
||||
class CustomLogger(logging.Logger):
|
||||
"""Logger, that avoids unnecessary string computations.
|
||||
|
||||
Does not compute messages for disabled log levels.
|
||||
"""
|
||||
|
||||
def debug(self, msg, *args, **kwargs):
|
||||
if self.isEnabledFor(logging.DEBUG):
|
||||
super().debug(msg, *args, **kwargs)
|
||||
|
||||
def info(self, msg, *args, **kwargs):
|
||||
if self.isEnabledFor(logging.INFO):
|
||||
super().info(msg, *args, **kwargs)
|
||||
|
||||
def warning(self, msg, *args, **kwargs):
|
||||
if self.isEnabledFor(logging.WARNING):
|
||||
super().warning(msg, *args, **kwargs)
|
||||
|
||||
def error(self, msg, *args, **kwargs):
|
||||
if self.isEnabledFor(logging.ERROR):
|
||||
super().error(msg, *args, **kwargs)
|
||||
|
||||
def critical(self, msg, *args, **kwargs):
|
||||
if self.isEnabledFor(logging.CRITICAL):
|
||||
super().critical(msg, *args, **kwargs)
|
||||
@@ -14,9 +14,12 @@
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from typing import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -31,7 +34,7 @@ try:
|
||||
except Exception:
|
||||
# Either the dbus library is not available or the system dbus is not running
|
||||
logger.warning("failed to set up dbus")
|
||||
pass
|
||||
bus = None
|
||||
|
||||
|
||||
_suspend_callback = None
|
||||
@@ -49,16 +52,23 @@ _LOGIND_PATH = "/org/freedesktop/login1"
|
||||
_LOGIND_INTERFACE = "org.freedesktop.login1.Manager"
|
||||
|
||||
|
||||
def watch_suspend_resume(on_resume_callback=None, on_suspend_callback=None):
|
||||
def watch_suspend_resume(
|
||||
on_resume_callback: Callable[[], None] | None = None,
|
||||
on_suspend_callback: Callable[[], None] | None = None,
|
||||
):
|
||||
"""Register callback for suspend/resume events.
|
||||
They are called only if the system DBus is running, and the Login daemon is available."""
|
||||
global _resume_callback, _suspend_callback
|
||||
_suspend_callback = on_suspend_callback
|
||||
_resume_callback = on_resume_callback
|
||||
if on_resume_callback is not None or on_suspend_callback is not None:
|
||||
bus.add_signal_receiver(_suspend_or_resume, "PrepareForSleep", dbus_interface=_LOGIND_INTERFACE, path=_LOGIND_PATH)
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("connected to system dbus, watching for suspend/resume events")
|
||||
if bus is not None and on_resume_callback is not None or on_suspend_callback is not None:
|
||||
bus.add_signal_receiver(
|
||||
_suspend_or_resume,
|
||||
"PrepareForSleep",
|
||||
dbus_interface=_LOGIND_INTERFACE,
|
||||
path=_LOGIND_PATH,
|
||||
)
|
||||
logger.info("connected to system dbus, watching for suspend/resume events")
|
||||
|
||||
|
||||
_BLUETOOTH_PATH_PREFIX = "/org/bluez/hci0/dev_"
|
||||
@@ -71,7 +81,7 @@ def watch_bluez_connect(serial, callback=None):
|
||||
if _bluetooth_callbacks.get(serial):
|
||||
_bluetooth_callbacks.get(serial).remove()
|
||||
path = _BLUETOOTH_PATH_PREFIX + serial.replace(":", "_").upper()
|
||||
if callback is not None:
|
||||
if bus is not None and callback is not None:
|
||||
_bluetooth_callbacks[serial] = bus.add_signal_receiver(
|
||||
callback, "PropertiesChanged", path=path, dbus_interface=_BLUETOOTH_INTERFACE
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import argparse
|
||||
import faulthandler
|
||||
import importlib
|
||||
import locale
|
||||
import logging
|
||||
import os.path
|
||||
import platform
|
||||
@@ -28,23 +29,18 @@ import tempfile
|
||||
|
||||
from traceback import format_exc
|
||||
|
||||
import solaar.cli as _cli
|
||||
import solaar.configuration as _configuration
|
||||
import solaar.dbus as _dbus
|
||||
import solaar.i18n as _i18n
|
||||
import solaar.listener as _listener
|
||||
import solaar.ui as _ui
|
||||
import solaar.ui.common as _common
|
||||
|
||||
from solaar import NAME
|
||||
from solaar import __version__
|
||||
from solaar import cli
|
||||
from solaar import configuration
|
||||
from solaar import dbus
|
||||
from solaar import listener
|
||||
from solaar import ui
|
||||
from solaar.custom_logger import CustomLogger
|
||||
|
||||
logging.setLoggerClass(CustomLogger)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
def _require(module, os_package, gi=None, gi_package=None, gi_version=None):
|
||||
try:
|
||||
@@ -56,12 +52,16 @@ def _require(module, os_package, gi=None, gi_package=None, gi_version=None):
|
||||
|
||||
|
||||
battery_icons_style = "regular"
|
||||
tray_icon_size = None
|
||||
temp = tempfile.NamedTemporaryFile(prefix="Solaar_", mode="w", delete=True)
|
||||
|
||||
|
||||
def _parse_arguments():
|
||||
def create_parser():
|
||||
arg_parser = argparse.ArgumentParser(
|
||||
prog=NAME.lower(), epilog="For more information see https://pwr-solaar.github.io/Solaar"
|
||||
prog=NAME.lower(),
|
||||
description="Solaar is a program to manage many Logitech devices, "
|
||||
"changing how they operate and maintaining the changes whenever the device connects.",
|
||||
epilog="For more information see https://pwr-solaar.github.io/Solaar",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-d",
|
||||
@@ -76,11 +76,18 @@ def _parse_arguments():
|
||||
action="store",
|
||||
dest="hidraw_path",
|
||||
metavar="PATH",
|
||||
help="unifying receiver to use; the first detected receiver if unspecified. Example: /dev/hidraw2",
|
||||
help="device or receiver path to use if needed. Example: /dev/hidraw2",
|
||||
)
|
||||
arg_parser.add_argument("--restart-on-wake-up", action="store_true", help="restart Solaar on sleep wake-up (experimental)")
|
||||
arg_parser.add_argument(
|
||||
"-w", "--window", choices=("show", "hide", "only"), help="start with window showing / hidden / only (no tray icon)"
|
||||
"--restart-on-wake-up",
|
||||
action="store_true",
|
||||
help="restart Solaar on sleep wake-up (experimental)",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-w",
|
||||
"--window",
|
||||
choices=("show", "hide", "only"),
|
||||
help="start with window showing / hidden / only (no tray icon)",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-b",
|
||||
@@ -90,13 +97,22 @@ def _parse_arguments():
|
||||
)
|
||||
arg_parser.add_argument("--tray-icon-size", type=int, help="explicit size for tray icons")
|
||||
arg_parser.add_argument("-V", "--version", action="version", version="%(prog)s " + __version__)
|
||||
arg_parser.add_argument("--help-actions", action="store_true", help="print help for the optional actions")
|
||||
arg_parser.add_argument("action", nargs=argparse.REMAINDER, choices=_cli.actions, help="optional actions to perform")
|
||||
arg_parser.add_argument("--help-actions", action="store_true", help="describe the command-line actions")
|
||||
arg_parser.add_argument(
|
||||
"action",
|
||||
nargs=argparse.REMAINDER,
|
||||
choices=cli.actions,
|
||||
help="command-line action to perform (optional); append ' --help' to show args",
|
||||
)
|
||||
return arg_parser
|
||||
|
||||
|
||||
def _parse_arguments():
|
||||
arg_parser = create_parser()
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
if args.help_actions:
|
||||
_cli.print_help()
|
||||
cli.print_help()
|
||||
return
|
||||
|
||||
if args.window is None:
|
||||
@@ -121,8 +137,8 @@ def _parse_arguments():
|
||||
logging.getLogger("").addHandler(stream_handler)
|
||||
|
||||
if not args.action:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("version %s, language %s (%s)", __version__, _i18n.language, _i18n.encoding)
|
||||
language, encoding = locale.getlocale()
|
||||
logger.info("version %s, language %s (%s)", __version__, language, encoding)
|
||||
|
||||
return args
|
||||
|
||||
@@ -146,10 +162,15 @@ def main():
|
||||
|
||||
args = _parse_arguments()
|
||||
if not args:
|
||||
# explicit close before return
|
||||
temp.close()
|
||||
return
|
||||
if args.action:
|
||||
# if any argument, run comandline and exit
|
||||
return _cli.run(args.action, args.hidraw_path)
|
||||
result = cli.run(args.action, args.hidraw_path)
|
||||
# explicit close before return
|
||||
temp.close()
|
||||
return result
|
||||
|
||||
gi = _require("gi", "python3-gi (in Ubuntu) or python3-gobject (in Fedora)")
|
||||
_require("gi.repository.Gtk", "gir1.2-gtk-3.0", gi, "Gtk", "3.0")
|
||||
@@ -161,7 +182,8 @@ def main():
|
||||
|
||||
udev_file = "42-logitech-unify-permissions.rules"
|
||||
if (
|
||||
logger.isEnabledFor(logging.WARNING)
|
||||
platform.system() == "Linux"
|
||||
and logger.isEnabledFor(logging.WARNING)
|
||||
and not os.path.isfile("/etc/udev/rules.d/" + udev_file)
|
||||
and not os.path.isfile("/usr/lib/udev/rules.d/" + udev_file)
|
||||
and not os.path.isfile("/usr/local/lib/udev/rules.d/" + udev_file)
|
||||
@@ -169,17 +191,17 @@ def main():
|
||||
logger.warning("Solaar udev file not found in expected location")
|
||||
logger.warning("See https://pwr-solaar.github.io/Solaar/installation for more information")
|
||||
try:
|
||||
_listener.setup_scanner(_ui.status_changed, _ui.setting_changed, _common.error_dialog)
|
||||
listener.setup_scanner(ui.status_changed, ui.setting_changed, ui.common.error_dialog)
|
||||
|
||||
if args.restart_on_wake_up:
|
||||
_dbus.watch_suspend_resume(_listener.start_all, _listener.stop_all)
|
||||
dbus.watch_suspend_resume(listener.start_all, listener.stop_all)
|
||||
else:
|
||||
_dbus.watch_suspend_resume(lambda: _listener.ping_all(True))
|
||||
dbus.watch_suspend_resume(lambda: listener.ping_all(True))
|
||||
|
||||
_configuration.defer_saves = True # allow configuration saves to be deferred
|
||||
configuration.defer_saves = True # allow configuration saves to be deferred
|
||||
|
||||
# main UI event loop
|
||||
_ui.run_loop(_listener.start_all, _listener.stop_all, args.window != "only", args.window != "hide")
|
||||
ui.run_loop(listener.start_all, listener.stop_all, args.window != "only", args.window != "hide")
|
||||
except Exception:
|
||||
sys.exit(f"{NAME.lower()}: error: {format_exc()}")
|
||||
|
||||
|
||||
@@ -14,43 +14,60 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
import gettext as _gettext
|
||||
import gettext
|
||||
import locale
|
||||
import os.path as _path
|
||||
import sys as _sys
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from glob import glob as _glob
|
||||
from glob import glob
|
||||
|
||||
from solaar import NAME as _NAME
|
||||
from solaar import NAME
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
_LOCALE_DOMAIN = NAME.lower()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _find_locale_path(lc_domain):
|
||||
prefix_share = _path.normpath(_path.join(_path.realpath(_sys.path[0]), ".."))
|
||||
src_share = _path.normpath(_path.join(_path.realpath(_sys.path[0]), "..", "share"))
|
||||
def _find_locale_path(locale_domain: str) -> str:
|
||||
prefix_share = os.path.normpath(os.path.join(os.path.realpath(sys.path[0]), ".."))
|
||||
src_share = os.path.normpath(os.path.join(os.path.realpath(sys.path[0]), "..", "share"))
|
||||
|
||||
for location in prefix_share, src_share:
|
||||
mo_files = _glob(_path.join(location, "locale", "*", "LC_MESSAGES", lc_domain + ".mo"))
|
||||
mo_files = glob(os.path.join(location, "locale", "*", "LC_MESSAGES", f"{locale_domain}.mo"))
|
||||
if mo_files:
|
||||
return _path.join(location, "locale")
|
||||
return os.path.join(location, "locale")
|
||||
raise FileNotFoundError(f"Could not find locale path for {locale_domain}")
|
||||
|
||||
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
except Exception:
|
||||
pass
|
||||
def set_locale_to_system_default() -> None:
|
||||
"""Sets locale for translations to the system default.
|
||||
|
||||
language, encoding = locale.getlocale()
|
||||
If locale is unsupported, fallback to standard English without
|
||||
translation 'C'.
|
||||
|
||||
_LOCALE_DOMAIN = _NAME.lower()
|
||||
path = _find_locale_path(_LOCALE_DOMAIN)
|
||||
Set LC_ALL environment variable to enforce a locale setting e.g.
|
||||
'de_DE.UTF-8'. Run Solaar with your desired localization, for German
|
||||
use:
|
||||
'LC_ALL=de_DE.UTF-8 solaar'
|
||||
"""
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, "") # system default
|
||||
except locale.Error:
|
||||
logger.error("User locale not supported by system, using no translation.")
|
||||
locale.setlocale(locale.LC_ALL, "C") # untranslated (English)
|
||||
return
|
||||
|
||||
_gettext.bindtextdomain(_LOCALE_DOMAIN, path)
|
||||
_gettext.textdomain(_LOCALE_DOMAIN)
|
||||
_gettext.install(_LOCALE_DOMAIN)
|
||||
try:
|
||||
path = _find_locale_path(_LOCALE_DOMAIN)
|
||||
except FileNotFoundError:
|
||||
path = None
|
||||
gettext.bindtextdomain(_LOCALE_DOMAIN, path)
|
||||
gettext.textdomain(_LOCALE_DOMAIN)
|
||||
gettext.install(_LOCALE_DOMAIN)
|
||||
|
||||
_ = _gettext.gettext
|
||||
ngettext = _gettext.ngettext
|
||||
|
||||
set_locale_to_system_default()
|
||||
|
||||
_ = gettext.gettext
|
||||
ngettext = gettext.ngettext
|
||||
|
||||
@@ -15,47 +15,57 @@
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
import errno as _errno
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
import typing
|
||||
|
||||
from collections import namedtuple
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
|
||||
import gi
|
||||
import logitech_receiver.device as _device
|
||||
import logitech_receiver.receiver as _receiver
|
||||
import logitech_receiver
|
||||
|
||||
from logitech_receiver import base as _base
|
||||
from logitech_receiver import base
|
||||
from logitech_receiver import exceptions
|
||||
from logitech_receiver import hidpp10_constants as _hidpp10_constants
|
||||
from logitech_receiver import listener as _listener
|
||||
from logitech_receiver import notifications as _notifications
|
||||
from logitech_receiver import hidpp10_constants
|
||||
from logitech_receiver import listener
|
||||
from logitech_receiver import notifications
|
||||
|
||||
from . import configuration
|
||||
from . import dbus
|
||||
from . import i18n
|
||||
from .ui import common
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from hidapi.common import DeviceInfo
|
||||
|
||||
gi.require_version("Gtk", "3.0") # NOQA: E402
|
||||
from gi.repository import GLib # NOQA: E402 # isort:skip
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from logitech_receiver.device import Device
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_R = _hidpp10_constants.REGISTERS
|
||||
_IR = _hidpp10_constants.INFO_SUBREGISTERS
|
||||
ACTION_ADD = "add"
|
||||
|
||||
|
||||
_GHOST_DEVICE = namedtuple("_GHOST_DEVICE", ("receiver", "number", "name", "kind", "online"))
|
||||
_GHOST_DEVICE = namedtuple("_GHOST_DEVICE", ("receiver", "number", "name", "kind", "online", "path"))
|
||||
_GHOST_DEVICE.__bool__ = lambda self: False
|
||||
_GHOST_DEVICE.__nonzero__ = _GHOST_DEVICE.__bool__
|
||||
|
||||
|
||||
def _ghost(device):
|
||||
return _GHOST_DEVICE(receiver=device.receiver, number=device.number, name=device.name, kind=device.kind, online=False)
|
||||
return _GHOST_DEVICE(
|
||||
receiver=device.receiver, number=device.number, name=device.name, kind=device.kind, online=False, path=None
|
||||
)
|
||||
|
||||
|
||||
class SolaarListener(_listener.EventsListener):
|
||||
class SolaarListener(listener.EventsListener):
|
||||
"""Keeps the status of a Receiver or Device (member name is receiver but it can also be a device)."""
|
||||
|
||||
def __init__(self, receiver, status_changed_callback):
|
||||
@@ -65,15 +75,13 @@ class SolaarListener(_listener.EventsListener):
|
||||
receiver.status_callback = self._status_changed
|
||||
|
||||
def has_started(self):
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: notifications listener has started (%s)", self.receiver, self.receiver.handle)
|
||||
logger.info("%s: notifications listener has started (%s)", self.receiver, self.receiver.handle)
|
||||
nfs = self.receiver.enable_connection_notifications()
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
if not self.receiver.isDevice and not ((nfs if nfs else 0) & _hidpp10_constants.NOTIFICATION_FLAG.wireless):
|
||||
logger.warning(
|
||||
"Receiver on %s might not support connection notifications, GUI might not show its devices",
|
||||
self.receiver.path,
|
||||
)
|
||||
if not self.receiver.isDevice and (not nfs or not (nfs & hidpp10_constants.NotificationFlag.WIRELESS)):
|
||||
logger.warning(
|
||||
"Receiver on %s might not support connection notifications, GUI might not show its devices",
|
||||
self.receiver.path,
|
||||
)
|
||||
self.receiver.notification_flags = nfs
|
||||
self.receiver.notify_devices()
|
||||
self._status_changed(self.receiver)
|
||||
@@ -81,8 +89,7 @@ class SolaarListener(_listener.EventsListener):
|
||||
def has_stopped(self):
|
||||
r, self.receiver = self.receiver, None
|
||||
assert r is not None
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: notifications listener has stopped", r)
|
||||
logger.info("%s: notifications listener has stopped", r)
|
||||
|
||||
# because udev is not notifying us about device removal, make sure to clean up in _all_listeners
|
||||
_all_listeners.pop(r.path, None)
|
||||
@@ -130,8 +137,7 @@ class SolaarListener(_listener.EventsListener):
|
||||
if not device:
|
||||
# Device was unpaired, and isn't valid anymore.
|
||||
# We replace it with a ghost so that the UI has something to work with while cleaning up.
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("device %s was unpaired, ghosting", device)
|
||||
logger.info("device %s was unpaired, ghosting", device)
|
||||
device = _ghost(device)
|
||||
|
||||
self.status_changed_callback(device, alert, reason)
|
||||
@@ -142,35 +148,30 @@ class SolaarListener(_listener.EventsListener):
|
||||
|
||||
def _notifications_handler(self, n):
|
||||
assert self.receiver
|
||||
# if logger.isEnabledFor(logging.DEBUG):
|
||||
# logger.debug("%s: handling %s", self.receiver, n)
|
||||
if n.devnumber == 0xFF:
|
||||
# a receiver notification
|
||||
_notifications.process(self.receiver, n)
|
||||
notifications.process(self.receiver, n)
|
||||
return
|
||||
|
||||
# a notification that came in to the device listener - strange, but nothing needs to be done here
|
||||
if self.receiver.isDevice:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Notification %s via device %s being ignored.", n, self.receiver)
|
||||
logger.debug("Notification %s via device %s being ignored.", n, self.receiver)
|
||||
return
|
||||
|
||||
# DJ pairing notification - ignore - hid++ 1.0 pairing notification is all that is needed
|
||||
if n.sub_id == 0x41 and n.report_id == _base.DJ_MESSAGE_ID:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("ignoring DJ pairing notification %s", n)
|
||||
if n.sub_id == 0x41 and n.report_id == base.DJ_MESSAGE_ID:
|
||||
logger.info("ignoring DJ pairing notification %s", n)
|
||||
return
|
||||
|
||||
# a device notification
|
||||
if not (0 < n.devnumber <= 16): # some receivers have devices past their max # devices
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("Unexpected device number (%s) in notification %s.", n.devnumber, n)
|
||||
logger.warning("Unexpected device number (%s) in notification %s.", n.devnumber, n)
|
||||
return
|
||||
already_known = n.devnumber in self.receiver
|
||||
|
||||
# FIXME: hacky fix for kernel/hardware race condition
|
||||
# If the device was just turned on or woken up from sleep, it may not be ready to receive commands.
|
||||
# The "payload" bit of the wireless tatus notification seems to tell us this. If this is the case, we
|
||||
# The "payload" bit of the wireless status notification seems to tell us this. If this is the case, we
|
||||
# must wait a short amount of time to avoid causing a broken pipe error.
|
||||
device_ready = not bool(ord(n.data[0:1]) & 0x80) or n.sub_id != 0x41
|
||||
if not device_ready:
|
||||
@@ -183,7 +184,13 @@ class SolaarListener(_listener.EventsListener):
|
||||
if not already_known:
|
||||
if n.address == 0x0A and not self.receiver.receiver_kind == "bolt":
|
||||
# some Nanos send a notification even if no new pairing - check that there really is a device there
|
||||
if self.receiver.read_register(_R.receiver_info, _IR.pairing_information + n.devnumber - 1) is None:
|
||||
if (
|
||||
self.receiver.read_register(
|
||||
hidpp10_constants.Registers.RECEIVER_INFO,
|
||||
hidpp10_constants.InfoSubRegisters.PAIRING_INFORMATION + n.devnumber - 1,
|
||||
)
|
||||
is None
|
||||
):
|
||||
return
|
||||
dev = self.receiver.register_new_device(n.devnumber, n)
|
||||
elif self.receiver.pairing.lock_open and self.receiver.re_pairs and not ord(n.data[0:1]) & 0x40:
|
||||
@@ -203,8 +210,7 @@ class SolaarListener(_listener.EventsListener):
|
||||
|
||||
# Apply settings every time the device connects
|
||||
if n.sub_id == 0x41:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("connection %s for device wpid %s kind %s serial %s", n, dev.wpid, dev.kind, dev._serial)
|
||||
logger.info("connection %s for device wpid %s kind %s serial %s", n, dev.wpid, dev.kind, dev._serial)
|
||||
# If there are saved configs, bring the device's settings up-to-date.
|
||||
# They will be applied when the device is marked as online.
|
||||
configuration.attach_to(dev)
|
||||
@@ -212,14 +218,12 @@ class SolaarListener(_listener.EventsListener):
|
||||
# the receiver changed status as well
|
||||
self._status_changed(self.receiver)
|
||||
|
||||
_notifications.process(dev, n)
|
||||
notifications.process(dev, n)
|
||||
|
||||
if self.receiver.pairing.lock_open and not already_known:
|
||||
# this should be the first notification after a device was paired
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("first notification was not a connection notification")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("%s: pairing detected new device", self.receiver)
|
||||
logger.warning("first notification was not a connection notification")
|
||||
logger.info("%s: pairing detected new device", self.receiver)
|
||||
self.receiver.pairing.new_device = dev
|
||||
elif dev.online is None:
|
||||
dev.ping()
|
||||
@@ -228,45 +232,44 @@ class SolaarListener(_listener.EventsListener):
|
||||
return f"<SolaarListener({self.receiver.path},{self.receiver.handle})>"
|
||||
|
||||
|
||||
def _process_bluez_dbus(device, path, dictionary, signature):
|
||||
"""Process bluez dbus property changed signals for device status changes to discover disconnections and connections"""
|
||||
def _process_bluez_dbus(device: Device, path, dictionary: dict, signature):
|
||||
"""Process bluez dbus property changed signals for device status
|
||||
changes to discover disconnections and connections.
|
||||
"""
|
||||
if device:
|
||||
if dictionary.get("Connected") is not None:
|
||||
connected = dictionary.get("Connected")
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("bluez dbus for %s: %s", device, "CONNECTED" if connected else "DISCONNECTED")
|
||||
logger.info("bluez dbus for %s: %s", device, "CONNECTED" if connected else "DISCONNECTED")
|
||||
device.changed(connected, reason=i18n._("connected") if connected else i18n._("disconnected"))
|
||||
elif device is not None:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("bluez cleanup for %s", device)
|
||||
logger.info("bluez cleanup for %s", device)
|
||||
_cleanup_bluez_dbus(device)
|
||||
|
||||
|
||||
def _cleanup_bluez_dbus(device):
|
||||
def _cleanup_bluez_dbus(device: Device):
|
||||
"""Remove dbus signal receiver for device"""
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("bluez cleanup for %s", device)
|
||||
logger.info("bluez cleanup for %s", device)
|
||||
dbus.watch_bluez_connect(device.hid_serial, None)
|
||||
|
||||
|
||||
_all_listeners = {} # all known receiver listeners, listeners that stop on their own may remain here
|
||||
|
||||
|
||||
def _start(device_info):
|
||||
def _start(device_info: DeviceInfo):
|
||||
assert _status_callback and _setting_callback
|
||||
isDevice = device_info.isDevice
|
||||
if not isDevice:
|
||||
receiver = _receiver.ReceiverFactory.create_receiver(device_info, _setting_callback)
|
||||
else:
|
||||
receiver = _device.DeviceFactory.create_device(device_info, _setting_callback)
|
||||
if receiver:
|
||||
configuration.attach_to(receiver)
|
||||
if receiver.bluetooth and receiver.hid_serial:
|
||||
dbus.watch_bluez_connect(receiver.hid_serial, partial(_process_bluez_dbus, receiver))
|
||||
receiver.cleanups.append(_cleanup_bluez_dbus)
|
||||
|
||||
if receiver:
|
||||
rl = SolaarListener(receiver, _status_callback)
|
||||
if not device_info.isDevice:
|
||||
receiver_ = logitech_receiver.receiver.create_receiver(base, device_info, _setting_callback)
|
||||
else:
|
||||
receiver_ = logitech_receiver.device.create_device(base, device_info, _setting_callback)
|
||||
if receiver_:
|
||||
configuration.attach_to(receiver_)
|
||||
if receiver_.bluetooth and receiver_.hid_serial:
|
||||
dbus.watch_bluez_connect(receiver_.hid_serial, partial(_process_bluez_dbus, receiver_))
|
||||
receiver_.cleanups.append(_cleanup_bluez_dbus)
|
||||
|
||||
if receiver_:
|
||||
rl = SolaarListener(receiver_, _status_callback)
|
||||
rl.start()
|
||||
_all_listeners[device_info.path] = rl
|
||||
return rl
|
||||
@@ -276,18 +279,16 @@ def _start(device_info):
|
||||
|
||||
def start_all():
|
||||
stop_all() # just in case this it called twice in a row...
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("starting receiver listening threads")
|
||||
for device_info in _base.receivers_and_devices():
|
||||
_process_receiver_event("add", device_info)
|
||||
logger.info("starting receiver listening threads")
|
||||
for device_info in base.receivers_and_devices():
|
||||
_process_receiver_event(ACTION_ADD, device_info)
|
||||
|
||||
|
||||
def stop_all():
|
||||
listeners = list(_all_listeners.values())
|
||||
_all_listeners.clear()
|
||||
if listeners:
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("stopping receiver listening threads %s", listeners)
|
||||
logger.info("stopping receiver listening threads %s", listeners)
|
||||
for listener_thread in listeners:
|
||||
listener_thread.stop()
|
||||
configuration.save()
|
||||
@@ -299,8 +300,7 @@ def stop_all():
|
||||
# after a resume, the device may have been off so mark its saved status to ensure
|
||||
# that the status is pushed to the device when it comes back
|
||||
def ping_all(resuming=False):
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("ping all devices%s", " when resuming" if resuming else "")
|
||||
logger.info("ping all devices%s", " when resuming" if resuming else "")
|
||||
for listener_thread in _all_listeners.values():
|
||||
if listener_thread.receiver.isDevice:
|
||||
if resuming:
|
||||
@@ -327,34 +327,33 @@ _setting_callback = None # GUI callback to change UI in response to changes to
|
||||
_error_callback = None # GUI callback to report errors
|
||||
|
||||
|
||||
def setup_scanner(status_changed_callback, setting_changed_callback, error_callback):
|
||||
def setup_scanner(status_changed_callback: Callable, setting_changed_callback: Callable, error_callback: Callable):
|
||||
global _status_callback, _error_callback, _setting_callback
|
||||
assert _status_callback is None, "scanner was already set-up"
|
||||
_status_callback = status_changed_callback
|
||||
_setting_callback = setting_changed_callback
|
||||
_error_callback = error_callback
|
||||
_base.notify_on_receivers_glib(_process_receiver_event)
|
||||
base.notify_on_receivers_glib(GLib, _process_receiver_event)
|
||||
|
||||
|
||||
def _process_add(device_info, retry):
|
||||
def _process_add(device_info: DeviceInfo, retry):
|
||||
try:
|
||||
_start(device_info)
|
||||
except OSError as e:
|
||||
if e.errno == _errno.EACCES:
|
||||
if e.errno == errno.EACCES:
|
||||
try:
|
||||
output = subprocess.check_output(["/usr/bin/getfacl", "-p", device_info.path], text=True)
|
||||
if logger.isEnabledFor(logging.WARNING):
|
||||
logger.warning("Missing permissions on %s\n%s.", device_info.path, output)
|
||||
output = subprocess.check_output(["getfacl", "-p", device_info.path], text=True)
|
||||
logger.warning("Missing permissions on %s\n%s.", device_info.path, output)
|
||||
except Exception:
|
||||
pass
|
||||
if retry:
|
||||
GLib.timeout_add(2000.0, _process_add, device_info, retry - 1)
|
||||
else:
|
||||
_error_callback("permissions", device_info.path)
|
||||
_error_callback(common.ErrorReason.PERMISSIONS, device_info.path)
|
||||
else:
|
||||
_error_callback("nodevice", device_info.path)
|
||||
_error_callback(common.ErrorReason.NO_DEVICE, device_info.path)
|
||||
except exceptions.NoReceiver:
|
||||
_error_callback("nodevice", device_info.path)
|
||||
_error_callback(common.ErrorReason.NO_DEVICE, device_info.path)
|
||||
|
||||
|
||||
# receiver add/remove events will start/stop listener threads
|
||||
@@ -362,13 +361,12 @@ def _process_receiver_event(action, device_info):
|
||||
assert action is not None
|
||||
assert device_info is not None
|
||||
assert _error_callback
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("receiver event %s %s", action, device_info)
|
||||
logger.info("receiver event %s %s", action, device_info)
|
||||
# whatever the action, stop any previous receivers at this path
|
||||
listener_thread = _all_listeners.pop(device_info.path, None)
|
||||
if listener_thread is not None:
|
||||
assert isinstance(listener_thread, SolaarListener)
|
||||
listener_thread.stop()
|
||||
if action == "add":
|
||||
if action == ACTION_ADD:
|
||||
_process_add(device_info, 3)
|
||||
return False
|
||||
|
||||
@@ -18,25 +18,21 @@
|
||||
|
||||
import logging
|
||||
|
||||
from threading import Thread as _Thread
|
||||
from threading import Thread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from Queue import Queue as _Queue
|
||||
from Queue import Queue
|
||||
except ImportError:
|
||||
from queue import Queue as _Queue
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
from queue import Queue
|
||||
|
||||
|
||||
class TaskRunner(_Thread):
|
||||
class TaskRunner(Thread):
|
||||
def __init__(self, name):
|
||||
super().__init__(name=name)
|
||||
self.daemon = True
|
||||
self.queue = _Queue(16)
|
||||
self.queue = Queue(16)
|
||||
self.alive = False
|
||||
|
||||
def __call__(self, function, *args, **kwargs):
|
||||
@@ -50,8 +46,7 @@ class TaskRunner(_Thread):
|
||||
def run(self):
|
||||
self.alive = True
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("started")
|
||||
logger.debug("started")
|
||||
|
||||
while self.alive:
|
||||
task = self.queue.get()
|
||||
@@ -63,5 +58,4 @@ class TaskRunner(_Thread):
|
||||
except Exception:
|
||||
logger.exception("calling %s", function)
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("stopped")
|
||||
logger.debug("stopped")
|
||||
|
||||
@@ -17,10 +17,13 @@
|
||||
|
||||
import logging
|
||||
|
||||
import gi
|
||||
import yaml as _yaml
|
||||
from enum import Enum
|
||||
from typing import Callable
|
||||
|
||||
from logitech_receiver.common import ALERT
|
||||
import gi
|
||||
import yaml
|
||||
|
||||
from logitech_receiver.common import Alert
|
||||
|
||||
from solaar.i18n import _
|
||||
from solaar.ui.config_panel import change_setting
|
||||
@@ -28,8 +31,8 @@ from solaar.ui.config_panel import record_setting
|
||||
from solaar.ui.window import find_device
|
||||
|
||||
from . import common
|
||||
from . import desktop_notifications
|
||||
from . import diversion_rules
|
||||
from . import notify
|
||||
from . import tray
|
||||
from . import window
|
||||
|
||||
@@ -43,11 +46,19 @@ logger = logging.getLogger(__name__)
|
||||
assert Gtk.get_major_version() > 2, "Solaar requires Gtk 3 python bindings"
|
||||
|
||||
|
||||
APP_ID = "io.github.pwr_solaar.solaar"
|
||||
|
||||
|
||||
class GtkSignal(Enum):
|
||||
ACTIVATE = "activate"
|
||||
COMMAND_LINE = "command-line"
|
||||
SHUTDOWN = "shutdown"
|
||||
|
||||
|
||||
def _startup(app, startup_hook, use_tray, show_window):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("startup registered=%s, remote=%s", app.get_is_registered(), app.get_is_remote())
|
||||
logger.debug("startup registered=%s, remote=%s", app.get_is_registered(), app.get_is_remote())
|
||||
common.start_async()
|
||||
notify.init()
|
||||
desktop_notifications.init()
|
||||
if use_tray:
|
||||
tray.init(lambda _ignore: window.destroy())
|
||||
window.init(show_window, use_tray)
|
||||
@@ -55,8 +66,7 @@ def _startup(app, startup_hook, use_tray, show_window):
|
||||
|
||||
|
||||
def _activate(app):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("activate")
|
||||
logger.debug("activate")
|
||||
if app.get_windows():
|
||||
window.popup()
|
||||
else:
|
||||
@@ -65,12 +75,11 @@ def _activate(app):
|
||||
|
||||
def _command_line(app, command_line):
|
||||
args = command_line.get_arguments()
|
||||
args = _yaml.safe_load("".join(args)) if args else args
|
||||
args = yaml.safe_load("".join(args)) if args else args
|
||||
if not args:
|
||||
_activate(app)
|
||||
elif args[0] == "config": # config call from remote instance
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info("remote command line %s", args)
|
||||
logger.info("remote command line %s", args)
|
||||
dev = find_device(args[1])
|
||||
if dev:
|
||||
setting = next((s for s in dev.settings if s.name == args[2]), None)
|
||||
@@ -79,24 +88,32 @@ def _command_line(app, command_line):
|
||||
return 0
|
||||
|
||||
|
||||
def _shutdown(app, shutdown_hook):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("shutdown")
|
||||
def _shutdown(_app, shutdown_hook):
|
||||
logger.debug("shutdown")
|
||||
shutdown_hook()
|
||||
common.stop_async()
|
||||
tray.destroy()
|
||||
notify.uninit()
|
||||
desktop_notifications.uninit()
|
||||
|
||||
|
||||
def run_loop(startup_hook, shutdown_hook, use_tray, show_window):
|
||||
def run_loop(
|
||||
startup_hook: Callable[[], None],
|
||||
shutdown_hook: Callable[[], None],
|
||||
use_tray: bool,
|
||||
show_window: bool,
|
||||
):
|
||||
assert use_tray or show_window, "need either tray or visible window"
|
||||
APP_ID = "io.github.pwr_solaar.solaar"
|
||||
|
||||
application = Gtk.Application.new(APP_ID, Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
|
||||
|
||||
application.connect("startup", lambda app, startup_hook: _startup(app, startup_hook, use_tray, show_window), startup_hook)
|
||||
application.connect("command-line", _command_line)
|
||||
application.connect("activate", _activate)
|
||||
application.connect("shutdown", _shutdown, shutdown_hook)
|
||||
application.connect(
|
||||
"startup",
|
||||
lambda app, startup_hook: _startup(app, startup_hook, use_tray, show_window),
|
||||
startup_hook,
|
||||
)
|
||||
application.connect(GtkSignal.COMMAND_LINE.value, _command_line)
|
||||
application.connect(GtkSignal.ACTIVATE.value, _activate)
|
||||
application.connect(GtkSignal.SHUTDOWN.value, _shutdown, shutdown_hook)
|
||||
|
||||
application.register()
|
||||
if application.get_is_remote():
|
||||
@@ -106,24 +123,23 @@ def run_loop(startup_hook, shutdown_hook, use_tray, show_window):
|
||||
|
||||
def _status_changed(device, alert, reason, refresh=False):
|
||||
assert device is not None
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("status changed: %s (%s) %s", device, alert, reason)
|
||||
logger.debug("status changed: %s (%s) %s", device, alert, reason)
|
||||
if alert is None:
|
||||
alert = ALERT.NONE
|
||||
alert = Alert.NONE
|
||||
|
||||
tray.update(device)
|
||||
if alert & ALERT.ATTENTION:
|
||||
if alert & Alert.ATTENTION:
|
||||
tray.attention(reason)
|
||||
|
||||
need_popup = alert & ALERT.SHOW_WINDOW
|
||||
need_popup = alert & Alert.SHOW_WINDOW
|
||||
window.update(device, need_popup, refresh)
|
||||
diversion_rules.update_devices()
|
||||
|
||||
if alert & (ALERT.NOTIFICATION | ALERT.ATTENTION):
|
||||
notify.show(device, reason)
|
||||
if alert & (Alert.NOTIFICATION | Alert.ATTENTION):
|
||||
desktop_notifications.show(device, reason)
|
||||
|
||||
|
||||
def status_changed(device, alert=ALERT.NONE, reason=None, refresh=False):
|
||||
def status_changed(device, alert=Alert.NONE, reason=None, refresh=False):
|
||||
GLib.idle_add(_status_changed, device, alert, reason, refresh)
|
||||
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
## Copyright (C) 2012-2013 Daniel Pavel
|
||||
## Revisions Copyright (C) Contributors to the Solaar project.
|
||||
##
|
||||
## This program is free software; you can redistribute it and/or modify
|
||||
## it under the terms of the GNU General Public License as published by
|
||||
## the Free Software Foundation; either version 2 of the License, or
|
||||
## (at your option) any later version.
|
||||
##
|
||||
## This program is distributed in the hope that it will be useful,
|
||||
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
## GNU General Public License for more details.
|
||||
##
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
import logging
|
||||
|
||||
from gi.repository import Gtk
|
||||
|
||||
from solaar import NAME
|
||||
from solaar import __version__
|
||||
from solaar.i18n import _
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
_dialog = None
|
||||
|
||||
|
||||
def _create():
|
||||
about = Gtk.AboutDialog()
|
||||
|
||||
about.set_program_name(NAME)
|
||||
about.set_version(__version__)
|
||||
about.set_comments(_("Manages Logitech receivers,\nkeyboards, mice, and tablets."))
|
||||
about.set_icon_name(NAME.lower())
|
||||
about.set_logo_icon_name(NAME.lower())
|
||||
|
||||
about.set_copyright("© 2012-2023 Daniel Pavel and contributors to the Solaar project")
|
||||
about.set_license_type(Gtk.License.GPL_2_0)
|
||||
|
||||
about.set_authors(("Daniel Pavel http://github.com/pwr",))
|
||||
try:
|
||||
about.add_credit_section(_("Additional Programming"), ("Filipe Laíns", "Peter F. Patel-Schneider"))
|
||||
about.add_credit_section(_("GUI design"), ("Julien Gascard", "Daniel Pavel"))
|
||||
about.add_credit_section(
|
||||
_("Testing"),
|
||||
(
|
||||
"Douglas Wagner",
|
||||
"Julien Gascard",
|
||||
"Peter Wu http://www.lekensteyn.nl/logitech-unifying.html",
|
||||
),
|
||||
)
|
||||
about.add_credit_section(
|
||||
_("Logitech documentation"),
|
||||
(
|
||||
"Julien Danjou http://julien.danjou.info/blog/2012/logitech-unifying-upower",
|
||||
"Nestor Lopez Casado http://drive.google.com/folderview?id=0BxbRzx7vEV7eWmgwazJ3NUFfQ28",
|
||||
),
|
||||
)
|
||||
except TypeError:
|
||||
# gtk3 < ~3.6.4 has incorrect gi bindings
|
||||
logging.exception("failed to fully create the about dialog")
|
||||
except Exception:
|
||||
# the Gtk3 version may be too old, and the function does not exist
|
||||
logging.exception("failed to fully create the about dialog")
|
||||
|
||||
about.set_translator_credits(
|
||||
"\n".join(
|
||||
(
|
||||
"gogo (croatian)",
|
||||
"Papoteur, David Geiger, Damien Lallement (français)",
|
||||
"Michele Olivo (italiano)",
|
||||
"Adrian Piotrowicz (polski)",
|
||||
"Drovetto, JrBenito (Portuguese-BR)",
|
||||
"Daniel Pavel (română)",
|
||||
"Daniel Zippert, Emelie Snecker (svensk)",
|
||||
"Dimitriy Ryazantcev (Russian)",
|
||||
"El Jinete Sin Cabeza (Español)",
|
||||
"Ferdina Kusumah (Indonesia)",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
about.set_website("https://pwr-solaar.github.io/Solaar")
|
||||
about.set_website_label(NAME)
|
||||
|
||||
about.connect("response", lambda x, y: x.hide())
|
||||
|
||||
def _hide(dialog, event):
|
||||
dialog.hide()
|
||||
return True
|
||||
|
||||
about.connect("delete-event", _hide)
|
||||
|
||||
return about
|
||||
|
||||
|
||||
def show_window(trigger=None):
|
||||
global _dialog
|
||||
if _dialog is None:
|
||||
_dialog = _create()
|
||||
_dialog.present()
|
||||
0
lib/solaar/ui/about/__init__.py
Normal file
36
lib/solaar/ui/about/about.py
Normal file
@@ -0,0 +1,36 @@
|
||||
## Copyright (C) Solaar Contributors
|
||||
##
|
||||
## This program is free software; you can redistribute it and/or modify
|
||||
## it under the terms of the GNU General Public License as published by
|
||||
## the Free Software Foundation; either version 2 of the License, or
|
||||
## (at your option) any later version.
|
||||
##
|
||||
## This program is distributed in the hope that it will be useful,
|
||||
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
## GNU General Public License for more details.
|
||||
##
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
from solaar.ui.about.model import AboutModel
|
||||
from solaar.ui.about.presenter import Presenter
|
||||
from solaar.ui.about.view import AboutView
|
||||
|
||||
|
||||
def show(_=None, model=None, view=None):
|
||||
"""Opens the About dialog."""
|
||||
if model is None:
|
||||
model = AboutModel()
|
||||
if view is None:
|
||||
view = AboutView()
|
||||
presenter = Presenter(model, view)
|
||||
presenter.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from gi.repository import Gtk
|
||||
|
||||
show(None)
|
||||
Gtk.main()
|
||||
83
lib/solaar/ui/about/model.py
Normal file
@@ -0,0 +1,83 @@
|
||||
## Copyright (C) Solaar Contributors
|
||||
##
|
||||
## This program is free software; you can redistribute it and/or modify
|
||||
## it under the terms of the GNU General Public License as published by
|
||||
## the Free Software Foundation; either version 2 of the License, or
|
||||
## (at your option) any later version.
|
||||
##
|
||||
## This program is distributed in the hope that it will be useful,
|
||||
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
## GNU General Public License for more details.
|
||||
##
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from typing import Tuple
|
||||
|
||||
from solaar import __version__
|
||||
from solaar.i18n import _
|
||||
|
||||
|
||||
def _get_current_year() -> int:
|
||||
return datetime.now().year
|
||||
|
||||
|
||||
class AboutModel:
|
||||
def get_version(self) -> str:
|
||||
return __version__
|
||||
|
||||
def get_description(self) -> str:
|
||||
return _("Manages Logitech receivers,\nkeyboards, mice, and tablets.")
|
||||
|
||||
def get_copyright(self) -> str:
|
||||
return f"© 2012-{_get_current_year()} Daniel Pavel and contributors to the Solaar project"
|
||||
|
||||
def get_authors(self) -> List[str]:
|
||||
return [
|
||||
"Daniel Pavel http://github.com/pwr",
|
||||
]
|
||||
|
||||
def get_translators(self) -> List[str]:
|
||||
return [
|
||||
"gogo (croatian)",
|
||||
"Papoteur, David Geiger, Damien Lallement (français)",
|
||||
"Michele Olivo (italiano)",
|
||||
"Adrian Piotrowicz (polski)",
|
||||
"Drovetto, JrBenito (Portuguese-BR)",
|
||||
"Daniel Pavel (română)",
|
||||
"Daniel Zippert, Emelie Snecker (svensk)",
|
||||
"Dimitriy Ryazantcev (Russian)",
|
||||
"El Jinete Sin Cabeza (Español)",
|
||||
"Ferdina Kusumah (Indonesia)",
|
||||
"John Erling Blad (Norwegian Bokmål, Norwegian Nynorsk)",
|
||||
]
|
||||
|
||||
def get_credit_sections(self) -> List[Tuple[str, List[str]]]:
|
||||
return [
|
||||
(_("Additional Programming"), ["Filipe Laíns", "Peter F. Patel-Schneider"]),
|
||||
(_("GUI design"), ["Julien Gascard", "Daniel Pavel"]),
|
||||
(
|
||||
_("Testing"),
|
||||
[
|
||||
"Douglas Wagner",
|
||||
"Julien Gascard",
|
||||
"Peter Wu http://www.lekensteyn.nl/logitech-unifying.html",
|
||||
],
|
||||
),
|
||||
(
|
||||
_("Logitech documentation"),
|
||||
[
|
||||
"Julien Danjou http://julien.danjou.info/blog/2012/logitech-unifying-upower",
|
||||
"Nestor Lopez Casado http://drive.google.com/folderview?id=0BxbRzx7vEV7eWmgwazJ3NUFfQ28",
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
def get_website(self):
|
||||
return "https://pwr-solaar.github.io/Solaar"
|
||||
95
lib/solaar/ui/about/presenter.py
Normal file
@@ -0,0 +1,95 @@
|
||||
## Copyright (C) Solaar Contributors
|
||||
##
|
||||
## This program is free software; you can redistribute it and/or modify
|
||||
## it under the terms of the GNU General Public License as published by
|
||||
## the Free Software Foundation; either version 2 of the License, or
|
||||
## (at your option) any later version.
|
||||
##
|
||||
## This program is distributed in the hope that it will be useful,
|
||||
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
## GNU General Public License for more details.
|
||||
##
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing_extensions import Protocol
|
||||
|
||||
from solaar.ui.about.model import AboutModel
|
||||
|
||||
|
||||
class AboutViewProtocol(Protocol):
|
||||
def init_ui(self) -> None:
|
||||
...
|
||||
|
||||
def update_version_info(self, version: str) -> None:
|
||||
...
|
||||
|
||||
def update_description(self, comments: str) -> None:
|
||||
...
|
||||
|
||||
def update_copyright(self, copyright_text: str) -> None:
|
||||
...
|
||||
|
||||
def update_authors(self, authors: list[str]) -> None:
|
||||
...
|
||||
|
||||
def update_translators(self, translators: list[str]) -> None:
|
||||
...
|
||||
|
||||
def update_website(self, website):
|
||||
...
|
||||
|
||||
def update_credits(self, credit_sections: list[tuple[str, list[str]]]) -> None:
|
||||
...
|
||||
|
||||
def show(self) -> None:
|
||||
...
|
||||
|
||||
|
||||
class Presenter:
|
||||
def __init__(self, model: AboutModel, view: AboutViewProtocol) -> None:
|
||||
self.model = model
|
||||
self.view = view
|
||||
|
||||
def update_version_info(self) -> None:
|
||||
version = self.model.get_version()
|
||||
self.view.update_version_info(version)
|
||||
|
||||
def update_credits(self) -> None:
|
||||
credit_sections = self.model.get_credit_sections()
|
||||
self.view.update_credits(credit_sections)
|
||||
|
||||
def update_description(self) -> None:
|
||||
comments = self.model.get_description()
|
||||
self.view.update_description(comments)
|
||||
|
||||
def update_copyright(self) -> None:
|
||||
copyright_text = self.model.get_copyright()
|
||||
self.view.update_copyright(copyright_text)
|
||||
|
||||
def update_authors(self) -> None:
|
||||
authors = self.model.get_authors()
|
||||
self.view.update_authors(authors)
|
||||
|
||||
def update_translators(self) -> None:
|
||||
translators = self.model.get_translators()
|
||||
self.view.update_translators(translators)
|
||||
|
||||
def update_website(self) -> None:
|
||||
website = self.model.get_website()
|
||||
self.view.update_website(website)
|
||||
|
||||
def run(self) -> None:
|
||||
self.view.init_ui()
|
||||
self.update_version_info()
|
||||
self.update_description()
|
||||
self.update_website()
|
||||
self.update_copyright()
|
||||
self.update_authors()
|
||||
self.update_credits()
|
||||
self.update_translators()
|
||||
self.view.show()
|
||||
70
lib/solaar/ui/about/view.py
Normal file
@@ -0,0 +1,70 @@
|
||||
## Copyright (C) Solaar Contributors
|
||||
##
|
||||
## This program is free software; you can redistribute it and/or modify
|
||||
## it under the terms of the GNU General Public License as published by
|
||||
## the Free Software Foundation; either version 2 of the License, or
|
||||
## (at your option) any later version.
|
||||
##
|
||||
## This program is distributed in the hope that it will be useful,
|
||||
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
## GNU General Public License for more details.
|
||||
##
|
||||
## You should have received a copy of the GNU General Public License along
|
||||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
from typing import Tuple
|
||||
from typing import Union
|
||||
|
||||
from gi.repository import Gtk
|
||||
|
||||
from solaar import NAME
|
||||
|
||||
|
||||
class GtkSignal(Enum):
|
||||
RESPONSE = "response"
|
||||
|
||||
|
||||
class AboutView:
|
||||
def __init__(self) -> None:
|
||||
self.view: Union[Gtk.AboutDialog, None] = None
|
||||
|
||||
def init_ui(self) -> None:
|
||||
self.view = Gtk.AboutDialog()
|
||||
self.view.set_program_name(NAME)
|
||||
self.view.set_logo_icon_name(NAME.lower())
|
||||
self.view.set_license_type(Gtk.License.GPL_2_0)
|
||||
|
||||
self.view.connect(GtkSignal.RESPONSE.value, lambda x, y: self.handle_close(x))
|
||||
|
||||
def update_version_info(self, version: str) -> None:
|
||||
self.view.set_version(version)
|
||||
|
||||
def update_description(self, comments: str) -> None:
|
||||
self.view.set_comments(comments)
|
||||
|
||||
def update_copyright(self, copyright_text: str):
|
||||
self.view.set_copyright(copyright_text)
|
||||
|
||||
def update_authors(self, authors: List[str]) -> None:
|
||||
self.view.set_authors(authors)
|
||||
|
||||
def update_credits(self, credit_sections: List[Tuple[str, List[str]]]) -> None:
|
||||
for section_name, people in credit_sections:
|
||||
self.view.add_credit_section(section_name, people)
|
||||
|
||||
def update_translators(self, translators: List[str]) -> None:
|
||||
translator_credits = "\n".join(translators)
|
||||
self.view.set_translator_credits(translator_credits)
|
||||
|
||||
def update_website(self, website):
|
||||
self.view.set_website_label(NAME)
|
||||
self.view.set_website(website)
|
||||
|
||||
def show(self) -> None:
|
||||
self.view.present()
|
||||
|
||||
def handle_close(self, event) -> None:
|
||||
event.hide()
|
||||