Compare commits

...

141 Commits
0.7.2 ... 0.8.7

Author SHA1 Message Date
Daniel Pavel
6ff30f2a0e release 0.8.7 2013-01-18 18:37:06 +02:00
Daniel Pavel
7707c5e558 don't notify on device disconnection 2013-01-18 17:41:45 +02:00
Daniel Pavel
9b7a920e0d fix for systray visibility (gihub #14) 2013-01-18 17:18:35 +02:00
Daniel Pavel
2e51380be5 log value of register 0x07 when register 0x0D not available 2013-01-18 12:41:01 +02:00
Daniel Pavel
445f508ea5 readme updates 2013-01-14 20:00:52 +02:00
Daniel Pavel
b82c89c582 release 0.8.6.2 2013-01-09 21:47:59 +02:00
Daniel Pavel
85a47a8049 fixed icon name in desktop file 2013-01-09 21:41:50 +02:00
Daniel Pavel
f8e9798038 gentoo ebuild updated 2013-01-09 21:31:47 +02:00
Daniel Pavel
581d6747ad Merge branch 'nano' into 0.9
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2013-01-09 21:10:39 +02:00
Daniel Pavel
00a1aa7628 release 0.8.6.1 2013-01-09 15:36:15 +02:00
Daniel Pavel
79a9048db5 fixed names for NamedInts numerical values 2013-01-09 15:31:19 +02:00
Daniel Pavel
2bfba2e399 fixed application quit icon 2013-01-09 13:47:30 +02:00
Daniel Pavel
484419e526 release 0.8.6 2013-01-08 00:59:30 +02:00
Daniel Pavel
8c18830c97 added source changelog file 2013-01-08 00:55:47 +02:00
Daniel Pavel
653d370a85 fixed locating application icons when running in a custom prefix 2013-01-08 00:40:54 +02:00
Daniel Pavel
316e91cfcf fixed some icon names 2013-01-08 00:39:13 +02:00
Daniel Pavel
f31632c8c8 debian: make sure the plugdev group exists after installation 2013-01-07 22:09:43 +02:00
Daniel Pavel
bb52c13f9a only allow a single instance of solaar to run at a time 2013-01-07 21:34:47 +02:00
Daniel Pavel
738d43fd83 fix for gihub issue #10
http://github.com/pwr/Solaar/issues/10
2013-01-07 20:14:31 +02:00
Daniel Pavel
1c6c8588d9 release 0.8.5.3, only packaging changes 2013-01-07 13:01:59 +02:00
Daniel Pavel
7a97cb2e02 gentoo ebuild update 2013-01-07 12:43:11 +02:00
Daniel Pavel
5e0d2992c9 small fixes for ubuntu packaging 2013-01-07 12:40:44 +02:00
Daniel Pavel
210859a5ef ignore some development-side directories 2013-01-07 12:05:40 +02:00
Daniel Pavel
25f6d229dd advanced version to account for latest changes in packaging 2013-01-07 12:04:22 +02:00
Daniel Pavel
60405abf58 dropped unnecessary #! in lib/ python files 2013-01-07 11:54:52 +02:00
Daniel Pavel
8070b11c27 customized debian/ and debian packaging script to support ubuntu ppas 2013-01-07 11:46:34 +02:00
Daniel Pavel
7d76ce77c9 packaging updates for debian/ubunutu 2013-01-05 17:40:06 +02:00
Daniel Pavel
3d48cbc111 updated debian packaging script, added ppa build script 2013-01-05 15:50:37 +02:00
Daniel Pavel
36f34da227 proper debian packaging, dropper stdeb 2013-01-05 11:48:35 +02:00
Daniel Pavel
d06e07542e dependency fix for debian package 2013-01-04 12:43:16 +02:00
Daniel Pavel
a0c8646923 Merge branch 'master' into packaging 2013-01-04 11:55:31 +02:00
Daniel Pavel
6c924de209 small readme update 2013-01-04 11:52:17 +02:00
Daniel Pavel
500503c069 release 0.8.5 2013-01-04 11:38:15 +02:00
Daniel Pavel
5dd8cd66dd added smooth scroll setting for the anywhere mx mouse 2013-01-04 11:23:41 +02:00
Daniel Pavel
41e84e55f1 fix hidconsole input 2013-01-04 10:59:00 +02:00
Daniel Pavel
7f8888d7dd added an ebuild script 2013-01-04 10:51:22 +02:00
Daniel Pavel
216928f904 small logging fixes for repr() 2013-01-04 08:25:47 +02:00
Daniel Pavel
345bab3a99 fix code looking for icons 2013-01-04 08:18:22 +02:00
Daniel Pavel
064a7a113c added setup.py for python and debian packaging 2012-12-18 05:03:43 +02:00
Daniel Pavel
115d5c7db1 release 0.8.4 2012-12-14 22:29:44 +02:00
Daniel Pavel
8a86ecc38d smaller icons in toolbars 2012-12-14 21:50:45 +02:00
Daniel Pavel
430a2d71e3 read all device features as soon as the application is idle 2012-12-14 19:51:18 +02:00
Daniel Pavel
187c0d2a52 fixes in the shell scripts 2012-12-14 19:33:01 +02:00
Daniel Pavel
8fbe77afb2 readme updates 2012-12-14 16:57:21 +02:00
Daniel Pavel
e43e92f2b0 commented-out testing register for M705 2012-12-14 16:57:10 +02:00
Daniel Pavel
cc6c0ee7df fix for python3 2012-12-14 16:25:46 +02:00
Daniel Pavel
3cd0665166 fixed list of settings in config panel 2012-12-14 15:48:04 +02:00
Daniel Pavel
a42e696695 hide the confix box when the device goes inactive 2012-12-14 06:45:51 +02:00
Daniel Pavel
83886fbcf1 fix device settings being read on every confix box display 2012-12-14 06:45:31 +02:00
Daniel Pavel
f0c5046ccf re-worked the settings classes 2012-12-14 06:44:44 +02:00
Daniel Pavel
9db2a65b31 solaar-cli: return on the first match when searching for device name
results may be occasionally ambiguous, but the command runs faster
2012-12-13 15:12:51 +02:00
Daniel Pavel
59c5619b44 cleaner output on solaar-cli config 2012-12-13 14:58:32 +02:00
Daniel Pavel
b39016df7c small clean-ups in device status handling 2012-12-13 14:54:31 +02:00
Daniel Pavel
c22fe6320d properly slice NamedInts 2012-12-13 05:26:56 +02:00
Daniel Pavel
b99ccdf612 support slices in FeaturesArray and KeysArray 2012-12-13 03:34:39 +02:00
Daniel Pavel
2d338ffbfb better __str__ of Setting instances 2012-12-13 03:33:19 +02:00
Daniel Pavel
739cb9306a drop MethodType uglyness 2012-12-13 00:30:20 +02:00
Daniel Pavel
954fc29613 fix event storm when toggling a device's property 2012-12-12 22:55:04 +02:00
Daniel Pavel
630f71b349 logging clean-up 2012-12-12 21:45:56 +02:00
Daniel Pavel
2b3f274aae new UI mock-up from Julien Gascard 2012-12-12 21:45:37 +02:00
Daniel Pavel
e834e46ef6 added register scan for m510
nothing interesting there, so far
2012-12-12 21:45:06 +02:00
Daniel Pavel
27f10cd10e small clean-up in FeaturesArray, should be slightly faster 2012-12-12 21:44:37 +02:00
Daniel Pavel
f4b92ee690 print () clean-ups 2012-12-12 21:43:55 +02:00
Daniel Pavel
1c4d3d5f13 simpler NamedInt, more logical NamedInts 2012-12-12 21:42:43 +02:00
Daniel Pavel
7bb7a092a4 descriptors update (new device K230)
also assume by default all devices have battery info in register 0x0D,
and blacklist them when that's not the case
2012-12-12 21:41:29 +02:00
Daniel Pavel
0ed623caf9 made notifications handling clearer in status.py 2012-12-12 21:39:04 +02:00
Daniel Pavel
19cd40cfdd renamed 'events' to 'notifications'
in order to match the name in Logitech's documentation
2012-12-12 21:03:07 +02:00
Daniel Pavel
7617a1ef8e don't look at a device's settings if they aren'y being displayed
avoids unnecessary hid++ calls from the main thread
2012-12-12 20:56:13 +02:00
Daniel Pavel
a370afe94b faster tooltip text composition 2012-12-12 20:55:09 +02:00
Daniel Pavel
ff5a1ac7cb fast mapping of receiver status to app icon name 2012-12-12 20:54:14 +02:00
Daniel Pavel
893c7e3ab2 use direct vars instead of widget names to identify widgets 2012-12-12 20:52:33 +02:00
Daniel Pavel
17698bfeae minor clean-ups in texts 2012-12-12 20:44:29 +02:00
Daniel Pavel
8b90e99658 bin/ scripts updated 2012-12-10 15:36:10 +02:00
Daniel Pavel
fa72b89b3a release 0.8.3 2012-12-08 05:29:50 +02:00
Daniel Pavel
fd3c88cb67 added a few documentation files 2012-12-08 05:27:22 +02:00
Daniel Pavel
8b44ca913f detect when the systray icon is not available and change window state accordingly 2012-12-08 03:11:45 +02:00
Daniel Pavel
7fe79a703e fixed creation of device settings controls 2012-12-08 01:54:08 +02:00
Daniel Pavel
80c36a02a9 improved notifications detection 2012-12-08 01:49:59 +02:00
Daniel Pavel
4bdfe9b9b8 readme update 2012-12-08 00:51:51 +02:00
Daniel Pavel
767e8a0db4 extra description on configurable settings in solaar-cli 2012-12-08 00:51:34 +02:00
Daniel Pavel
d8a2ffa835 better handling of window pop-up and toggling 2012-12-08 00:51:10 +02:00
Daniel Pavel
d38bec39b6 improved hid++ support 2012-12-08 00:41:43 +02:00
Daniel Pavel
33a9ca060d made hidconsole more user-friendly 2012-12-08 00:41:10 +02:00
Daniel Pavel
30fedf418c re-read device settings when they come back online 2012-12-07 21:00:36 +02:00
Daniel Pavel
5bdacb377c cleaner text when no status is known 2012-12-07 20:54:05 +02:00
Daniel Pavel
ee16892481 fixed registers access 2012-12-07 20:38:24 +02:00
Daniel Pavel
e2909f6165 fixed event detection 2012-12-07 20:37:13 +02:00
Daniel Pavel
205d25e341 special support for configuring dpi 2012-12-07 19:40:32 +02:00
Daniel Pavel
f49ced2d92 readme updates 2012-12-07 19:39:40 +02:00
Daniel Pavel
b86dcce381 I come from the __future__, come with me if you want to live. 2012-12-07 17:10:22 +02:00
Daniel Pavel
c4be58f074 dropped bin/scan as deprecated, bin/solaar-cli completely replaces it 2012-12-07 15:31:19 +02:00
Daniel Pavel
b3f0bfa4fb fixed obsolete import 2012-12-07 14:41:00 +02:00
Daniel Pavel
37daf3a192 better handling of terminal in hidconsole 2012-12-07 14:40:48 +02:00
Daniel Pavel
7ada4af31b hidconsole has to be run in unbuffered mode 2012-12-07 14:29:30 +02:00
Daniel Pavel
67db483b0b dropped the unittests, they've been obsolete and nonfunctional for a long time now 2012-12-07 14:00:28 +02:00
Daniel Pavel
357e118ace added configuration support to solaar-cli 2012-12-07 13:56:22 +02:00
Daniel Pavel
f2cdbe26b6 added configuration items to the UI
the window is getting very cramped now... will most likely have to re-
work the entire UI
2012-12-07 13:56:07 +02:00
Daniel Pavel
3569489ce7 added registers and settings to device descriptors 2012-12-07 13:54:03 +02:00
Daniel Pavel
6c3fa224e0 small ui fixes 2012-12-07 13:52:09 +02:00
Daniel Pavel
9066003240 named ints act like proper sequences now 2012-12-07 13:50:44 +02:00
Daniel Pavel
f0007d0a13 updates to the command lines 2012-12-07 13:41:07 +02:00
Daniel Pavel
ff6db1d00a fix for python 3 2012-12-06 14:15:28 +02:00
Daniel Pavel
27403a08d2 improved hid++ 1.0 support 2012-12-05 21:41:02 +02:00
Daniel Pavel
6d70d2aada improved support for hid++ 1.0 devices 2012-12-05 15:10:41 +02:00
Daniel Pavel
0e551383ba added script for udev rule installation 2012-12-05 12:08:45 +02:00
Daniel Pavel
b5b86ab8b8 more reliable pairing window 2012-12-04 15:59:35 +02:00
Daniel Pavel
61d0159e8a release 0.8.2 2012-12-03 15:17:33 +02:00
Daniel Pavel
c41859816b renamed README 2012-12-03 15:13:03 +02:00
Daniel Pavel
5a99e55309 readme updates 2012-12-03 15:07:35 +02:00
Daniel Pavel
1b6e6692c0 maintain notification flags when pairing in command-line 2012-12-03 15:07:07 +02:00
Daniel Pavel
116ba72f37 fixed possible dangling weakrefs on start-up 2012-12-03 12:51:22 +02:00
Daniel Pavel
3fe9caf0e6 added solaar-cli for command-line operations 2012-12-03 11:34:35 +02:00
Daniel Pavel
a403c3b596 release 0.8.1 2012-12-01 23:32:51 +02:00
Daniel Pavel
2a44b0bb5b fixed scan not seeing the devices 2012-12-01 22:34:52 +02:00
Daniel Pavel
130a23dd4f optimized appicon mask 2012-12-01 19:16:52 +02:00
Daniel Pavel
db0d6e8bbc release 0.8.0 2012-12-01 19:14:06 +02:00
Daniel Pavel
1cc532d600 fixed orphaned weakrefs when unpairing a device 2012-12-01 19:12:53 +02:00
Daniel Pavel
8f5fa0cf9a code clean-ups, the app starts faster now 2012-12-01 15:49:52 +02:00
Daniel Pavel
89c6904d69 fixed pairing (again), this time also tested it 2012-11-30 20:28:22 +02:00
Daniel Pavel
14663ca204 re-wrote loading of icons for devices 2012-11-30 15:23:16 +02:00
Daniel Pavel
64d2b35ace some clean-ups 2012-11-30 15:20:41 +02:00
Daniel Pavel
ab5e09db93 pairing fixes 2012-11-29 21:26:03 +02:00
Daniel Pavel
932a015e49 better battery icon in the systray 2012-11-29 20:13:53 +02:00
Daniel Pavel
d6b18cd426 python 3 fixes 2012-11-29 12:34:20 +02:00
Daniel Pavel
84540fb087 re-wrote most of the app, based on latest HID++ docs from Logitech 2012-11-29 04:10:16 +02:00
Daniel Pavel
5b8c983ab3 some speed tweaks to hidconsole batch mode 2012-11-24 22:49:15 +02:00
Daniel Pavel
13a11e78f0 added more known device names and kinds 2012-11-13 09:48:52 +02:00
Daniel Pavel
fb8cf26c51 release 0.7.4 2012-11-12 18:34:27 +02:00
Daniel Pavel
41db725e15 fixed property updates from events 2012-11-12 18:34:11 +02:00
Daniel Pavel
f25d2ba183 small tweaks on how the devices info is displayed 2012-11-12 18:15:29 +02:00
Daniel Pavel
66531635bc scripts clean-up 2012-11-12 15:33:21 +02:00
Daniel Pavel
4c5cf85091 re-worked the UI a bit to give better info on devices status 2012-11-12 15:28:38 +02:00
Daniel Pavel
6db4deafee python 3 fixes 2012-11-11 22:37:42 +02:00
Daniel Pavel
2c312c1a5b show battery icon in tray if any available 2012-11-11 21:59:50 +02:00
Daniel Pavel
bcc2bf123e fixed initialization sequence for newly detected devices 2012-11-11 20:11:30 +02:00
Daniel Pavel
50fedab19e re-worked how fd handles are used in multi-threading 2012-11-11 17:03:13 +02:00
Daniel Pavel
d0ccd3e9c2 ui tweak 2012-11-09 09:20:44 +02:00
Daniel Pavel
4b2d8a8d5a addded custom swids to feature calls 2012-11-09 09:20:28 +02:00
Daniel Pavel
c12364a7c7 fix receiver status when more than 1 device is connected 2012-11-09 01:03:37 +02:00
Daniel Pavel
560400e786 small ui tweaks 2012-11-08 22:32:19 +02:00
Daniel Pavel
f7a4d89467 fixed display of receiver details 2012-11-08 22:05:35 +02:00
111 changed files with 5656 additions and 3360 deletions

12
.gitignore vendored
View File

@@ -1,4 +1,14 @@
*.so
*.pyc
*.pyo
__pycache__/
*.log
/lib/Solaar.egg-info/
/build/
/sdist/
/dist/
/deb_dist/
/MANIFEST
/docs/captures/
/share/logitech_icons/

2
COPYRIGHT Normal file
View File

@@ -0,0 +1,2 @@
Copyright 2012, 2013
Daniel Pavel <daniel.pavel@gmail.com>

13
ChangeLog Normal file
View File

@@ -0,0 +1,13 @@
0.8.7:
* Don't show the "device disconnected" notification, it can be annoying and
not very useful.
* More robust detection of systray icon visibility.
0.8.6:
* Ensure the Gtk application is single-instance.
* Fix identifying available dpi values.
* Fixed locating application icons when installed in a custom prefix.
* Fixed some icon names for the oxygen theme.
* Python 3 fixes.

2
MANIFEST.in Normal file
View File

@@ -0,0 +1,2 @@
include COPYRIGHT COPYING README.md ChangeLog
recursive-include rules.d *

60
README
View File

@@ -1,60 +0,0 @@
Solaar
------
This application connects to a Logitech Unifying Receiver
(http://www.logitech.com/en-us/66/6079) and listens for events from devices
attached to it.
Currently the K750 solar keyboard is also queried for its solar charge status.
Support for other devices could be added in the future, but the K750 keyboard is
the only device I have and can test on.
Requirements
------------
- Python (2.7 or 3.2). Either version should work well.
- Gtk 3; Gtk 2 should partially work with some problems.
- Python GI (GObject Introspection), for Gtk bindings.
- pyudev for enumerating udev devices.
- Optional libnotify GI bindings, for desktop notifications.
The necessary packages for Debian/Ubuntu are `python-pyudev`/`python3-pyudev`,
`python-gi`/`python3-gi`, `gir1.2-gtk-3.0`, and optionally `gir1.2-notify-0.7`.
Installation
------------
Normally USB devices are not accessible for r/w by regular users, so you will
need to install a udev rule to allow access to the Logitech Unifying Receiver.
In rules.d/ you'll find a udev rule file, to be copied in /etc/udev/rules.d/ (as
root).
In its current form it makes the UR device available for r/w by all users
belonging to the 'plugdev' system group (standard Debian/Ubuntu group for
pluggable devices). It may need changes, specific to your particular system's
configuration.
If in doubt, replacing GROUP="plugdev" with GROUP="<your username>" should just
work.
After you copy the file to /etc/udev/rules.d (and possibly modify it for your
system), run 'udevadm control --reload-rules' as root for it to apply. Then
physically remove the Unifying Receiver, wait 30 seconds and re-insert it.
Thanks
------
This project began as a third-hand clone of Noah K. Tilton's logitech-solar-k750
project on GitHub (no longer available). It was developed further thanks to the
diggings in Logitech's HID protocol done, among others, by Julien Danjou
(http://julien.danjou.info/blog/2012/logitech-k750-linux-support) and
Lars-Dominik Braun (http://6xq.net/git/lars/lshidpp.git).
Cheers,
-pwr

141
README.md Normal file
View File

@@ -0,0 +1,141 @@
**Solaar** is a Linux device manager for Logitech's [Unifying Receiver][unifying]
peripherals. It is able to pair/unpair devices to the receiver, and for most
devices read battery status.
It comes in two flavors, command-line and GUI. Both are able to list the
devices paired to a Unifying Receiver, show detailed info for each device, and
also pair/unpair supported devices with the receiver.
## Supported Devices
**Solaar** will detect all devices paired with your Unifying Receiver, and at
the very least display some basic information about them.
For some devices, extra settings (usually not available through the standard
Linux system configuration) are supported:
* The [K750 Solar Keyboard][K750] is also queried for its solar charge status.
Pressing the `Solar` key on the keyboard will pop-up the application window
and display the current lighting value (Lux) as reported by the keyboard,
similar to Logitech's *Solar.app* for Windows.
* The state of the `FN` key can be toggled on some keyboards ([K750][K750],
[K800][K800] and [K360][K360]). It changes the way the function keys
(`F1`..`F12`) work, i.e. whether holding `FN` while pressing the function keys
will generate the standard `Fx` keycodes or the special function (yellow
icons) keycodes.
* The DPI can be changed on the [Performance MX Mouse][P_MX].
* Smooth scrolling (higher sensitivity on vertical scrolling with the wheel) can
be toggled on the [M705 Marathon Mouse][M705] and [Anywhere MX Mouse][A_MX].
Extended support for other devices may be added in the future, depending on the
documentation available, but the K750 keyboard and M705 mouse are the only
devices I have and can directly test on right now.
## Pre-built packages
* Ubuntu 12.04+ packages are available in my PPA: [ppa:daniel.pavel/Solaar][ppa]
* A downloadable Debian package for sid/unstable: [.deb][debian]
* A [Gentoo overlay][gentoo] is available courtesy of Carlos Silva
[ppa]: http://launchpad.net/~daniel.pavel/+archive/solaar
[debian]: http://pwr.github.com/Solaar/packages/solaar_0.8.6.2-1_all.deb
[gentoo]: http://code.r3pek.org/gentoo-overlay/src
## Manual instalation
### Requirements
You should have a reasonably new kernel (3.2+), with the `logitech-djreceiver`
driver enabled and loaded; also, the `udev` package must be installed and the
daemon running. If you have a modern Linux distribution (2011+), you're most
likely good to go.
The command-line application (`bin/solaar-cli`) requires Python 2.7.3 or 3.2+
(either version should work), and the `python-pyudev`/`python3-pyudev` package.
The GUI application (`bin/solaar`) also requires Gtk3, and its GObject
Introspection bindings. The Debian/Ubuntu package names are
`python-gi`/`python3-gi` and `gir1.2-gtk-3.0`; if you're using another
distribution the required packages are most likely named something similar.
If the desktop notifications bindings are also installed (`gir1.2-notify-0.7`),
you will also get desktop notifications when devices come online/go offline.
### Installation
Normally USB devices are not accessible for r/w by regular users, so you will
need to do a one-time udev rule installation to allow access to the Logitech
Unifying Receiver.
You can run the `rules.d/install.sh` script from Solaar to do this installation
automatically (it will switch to root when necessary), or you can do all the
required steps by hand, as the root user:
1. copy `rules.d/99-logitech-unfiying-receiver.rules` from Solaar to
`/etc/udev/rules.d/`
By default, the rule makes the Unifying Receiver device available for r/w by
all users belonging to the `plugdev` system group (standard Debian/Ubuntu
group for pluggable devices). It may need changes, specific to your
particular system's configuration. If in doubt, replacing `GROUP="plugdev"`
with `GROUP="<your username>"` should just work.
2. run `udevadm control --reload-rules` to let the udev daemon know about the
new rule
3. physically remove the Unifying Receiver, wait 10 seconds and re-insert it
## Known Issues
- Ubuntu's Unity indicators are not supported at this time. However, if you
whitelist 'Solaar' in the systray, you will get an icon (see
[Enable more icons to be in the system tray?][ubuntu_systray] for details).
[ubuntu_systray]: http://askubuntu.com/questions/30742
- The application only looks at the first Unifying Receiver it finds, even if
there's more than one plugged in. Support for multiple receivers is in
progress.
- Devices connected throught a [Nano Receiver][nano] (which is very similar to
the Unifying Receiver) are not supported at this time.
- Running the command-line application (`bin/solaar-cli`) while the GUI
application is also running *may* occasionally cause either of them to become
confused about the state of the devices. I haven't encountered this often
enough to be able to be able to diagnose it properly yet.
## Thanks
This project began as a third-hand clone of [Noah K. Tilton](https://github.com/noah)'s
logitech-solar-k750 project on GitHub (no longer available). It was developed
further thanks to the diggings in Logitech's HID++ protocol done by many other
people:
- [Julien Danjou](http://julien.danjou.info/blog/2012/logitech-k750-linux-support),
who also provided some internal
[Logitech documentation](http://julien.danjou.info/blog/2012/logitech-unifying-upower)
- [Lars-Dominik Braun](http://6xq.net/git/lars/lshidpp.git)
- [Alexander Hofbauer](http://derhofbauer.at/blog/blog/2012/08/28/logitech-performance-mx)
- [Clach04](http://bitbucket.org/clach04/logitech-unifying-receiver-tools)
Also thanks to Douglas Wagner and Julien Gascard for helping with application
testing and supporting new devices.
--
[unifying]: http://logitech.com/en-us/66/6079
[nano]: http://logitech.com/mice-pointers/articles/5926
[K750]: http://logitech.com/product/k750-keyboard
[K800]: http://logitech.com/product/wireless-illuminated-keyboard-k800
[K360]: http://logitech.com/product/keyboard-k360
[M705]: http://logitech.com/product/marathon-mouse-m705
[P_MX]: http://logitech.com/product/performance-mouse-mx
[A_MX]: http://logitech.com/product/anywhere-mouse-mx

View File

@@ -1,82 +0,0 @@
#
#
#
from logging import getLogger as _Logger
_l = _Logger('pairing')
from logitech.unifying_receiver import base as _base
state = None
class State(object):
TICK = 400
PAIR_TIMEOUT = 60 * 1000 / TICK
def __init__(self, listener):
self.listener = listener
self.reset()
def device(self, number):
return self.listener.devices.get(number)
def reset(self):
self.success = None
self.detected_device = None
self._countdown = self.PAIR_TIMEOUT
def countdown(self, assistant):
if self._countdown < 0 or not self.listener:
return False
if self._countdown == self.PAIR_TIMEOUT:
self.start_scan()
self._countdown -= 1
return True
self._countdown -= 1
if self._countdown > 0 and self.success is None:
return True
self.stop_scan()
assistant.scan_complete(assistant, self.detected_device)
return False
def start_scan(self):
self.reset()
self.listener.events_filter = self.filter_events
reply = _base.request(self.listener.handle, 0xFF, b'\x80\xB2', b'\x01')
_l.debug("start scan reply %s", repr(reply))
def stop_scan(self):
if self._countdown >= 0:
self._countdown = -1
reply = _base.request(self.listener.handle, 0xFF, b'\x80\xB2', b'\x02')
_l.debug("stop scan reply %s", repr(reply))
self.listener.events_filter = None
def filter_events(self, event):
if event.devnumber == 0xFF:
if event.code == 0x10:
if event.data == b'\x4A\x01\x00\x00\x00':
_l.debug("receiver listening for device wakeup")
return True
if event.data == b'\x4A\x00\x01\x00\x00':
_l.debug("receiver gave up")
self.success = False
return True
return False
if event.devnumber in self.listener.receiver.devices:
return False
_l.debug("event for new device? %s", event)
if event.code == 0x10 and event.data[0:2] == b'\x41\x04':
self.detected_device = self.listener.make_device(event)
return True
return True
def unpair(self, device):
_l.debug("unpair %s", device)
self.listener.unpair_device(device)

View File

@@ -1,340 +0,0 @@
#
#
#
from logging import getLogger as _Logger
from struct import pack as _pack
from time import sleep as _sleep
from logitech.unifying_receiver import base as _base
from logitech.unifying_receiver import api as _api
from logitech.unifying_receiver.listener import EventsListener as _EventsListener
from logitech.unifying_receiver.common import FallbackDict as _FallbackDict
from logitech import devices as _devices
from logitech.devices.constants import (STATUS, STATUS_NAME, PROPS)
#
#
#
class _FeaturesArray(object):
__slots__ = ('device', 'features', 'supported')
def __init__(self, device):
self.device = device
self.features = None
self.supported = True
def _check(self):
if self.supported:
if self.features is not None:
return True
if self.device.status >= STATUS.CONNECTED:
handle = self.device.handle
try:
index = _api.get_feature_index(handle, self.device.number, _api.FEATURE.FEATURE_SET)
except _api._FeatureNotSupported:
self.supported = False
else:
count = None if index is None else _base.request(handle, self.device.number, _pack('!BB', index, 0x00))
if count is None:
self.supported = False
else:
count = ord(count[:1])
self.features = [None] * (1 + count)
self.features[0] = _api.FEATURE.ROOT
self.features[index] = _api.FEATURE.FEATURE_SET
return True
return False
__bool__ = __nonzero__ = _check
def __getitem__(self, index):
if not self._check():
return None
if index < 0 or index >= len(self.features):
raise IndexError
if self.features[index] is None:
fs_index = self.features.index(_api.FEATURE.FEATURE_SET)
feature = _base.request(self.device.handle, self.device.number, _pack('!BB', fs_index, 0x10), _pack('!B', index))
if feature is not None:
self.features[index] = feature[:2]
return self.features[index]
def __contains__(self, value):
if self._check():
if value in self.features:
return True
for index in range(0, len(self.features)):
f = self.features[index] or self.__getitem__(index)
assert f is not None
if f == value:
return True
if f > value:
break
return False
def index(self, value):
if self._check():
if self.features is not None and value in self.features:
return self.features.index(value)
raise ValueError("%s not in list" % repr(value))
def __iter__(self):
if self._check():
yield _api.FEATURE.ROOT
index = 1
last_index = len(self.features)
while index < last_index:
yield self.__getitem__(index)
index += 1
def __len__(self):
return len(self.features) if self._check() else 0
#
#
#
class DeviceInfo(_api.PairedDevice):
"""A device attached to the receiver.
"""
def __init__(self, listener, number, status=STATUS.UNKNOWN):
super(DeviceInfo, self).__init__(listener.handle, number)
self._features = _FeaturesArray(self)
self.LOG = _Logger("Device[%d]" % number)
self._listener = listener
self._status = status
self.props = {}
# read them now, otherwise it it temporarily hang the UI
# if status >= STATUS.CONNECTED:
# n, k, s, f = self.name, self.kind, self.serial, self.firmware
@property
def receiver(self):
return self._listener.receiver
@property
def status(self):
return self._status
@status.setter
def status(self, new_status):
if new_status != self._status and not (new_status == STATUS.CONNECTED and self._status > new_status):
self.LOG.debug("status %d => %d", self._status, new_status)
urgent = new_status < STATUS.CONNECTED or self._status < STATUS.CONNECTED
self._status = new_status
self._listener.status_changed(self, urgent)
if new_status < STATUS.CONNECTED:
self.props.clear()
@property
def status_text(self):
if self._status < STATUS.CONNECTED:
return STATUS_NAME[self._status]
t = []
if self.props.get(PROPS.BATTERY_LEVEL):
t.append('Battery: %d%%' % self.props[PROPS.BATTERY_LEVEL])
if self.props.get(PROPS.BATTERY_STATUS):
t.append(self.props[PROPS.BATTERY_STATUS])
if self.props.get(PROPS.LIGHT_LEVEL):
t.append('Light: %d lux' % self.props[PROPS.LIGHT_LEVEL])
return ', '.join(t) if t else STATUS_NAME[STATUS.CONNECTED]
def process_event(self, code, data):
if code == 0x10 and data[:1] == b'\x8F':
self.status = STATUS.UNAVAILABLE
return True
if code == 0x11:
status = _devices.process_event(self, data)
if status:
if type(status) == int:
self.status = status
return True
if type(status) == tuple:
p = dict(self.props)
self.props.update(status[1])
if self.status == status[0]:
if p != self.props:
self._listener.status_changed(self)
else:
self.status = status[0]
return True
self.LOG.warn("don't know how to handle processed event status %s", status)
return False
def __str__(self):
return '<DeviceInfo(%d,%s,%d)>' % (self.number, self._name or '?', self._status)
#
#
#
_RECEIVER_STATUS_NAME = _FallbackDict(
lambda x:
'1 device found' if x == STATUS.CONNECTED + 1 else
'%d devices found' if x > STATUS.CONNECTED else
'?',
{
STATUS.UNKNOWN: 'Initializing...',
STATUS.UNAVAILABLE: 'Receiver not found.',
STATUS.BOOTING: 'Scanning...',
STATUS.CONNECTED: 'No devices found.',
}
)
class ReceiverListener(_EventsListener):
"""Keeps the status of a Unifying Receiver.
"""
def __init__(self, receiver, status_changed_callback=None):
super(ReceiverListener, self).__init__(receiver.handle, self._events_handler)
self.receiver = receiver
self.LOG = _Logger("ReceiverListener(%s)" % receiver.path)
self.events_filter = None
self.events_handler = None
self.status_changed_callback = status_changed_callback
receiver.kind = receiver.name
receiver.devices = {}
receiver.status = STATUS.BOOTING
receiver.status_text = _RECEIVER_STATUS_NAME[STATUS.BOOTING]
if _base.request(receiver.handle, 0xFF, b'\x80\x00', b'\x00\x01'):
self.LOG.info("initialized")
else:
self.LOG.warn("initialization failed")
if _base.request(receiver.handle, 0xFF, b'\x80\x02', b'\x02'):
self.LOG.info("triggered device events")
else:
self.LOG.warn("failed to trigger device events")
def change_status(self, new_status):
if new_status != self.receiver.status:
self.LOG.debug("status %d => %d", self.receiver.status, new_status)
self.receiver.status = new_status
self.receiver.status_text = _RECEIVER_STATUS_NAME[new_status]
self.status_changed(None, True)
def status_changed(self, device=None, urgent=False):
if self.status_changed_callback:
self.status_changed_callback(self.receiver, device, urgent)
def _device_status_from(self, event):
state_code = ord(event.data[2:3]) & 0xC0
state = STATUS.UNAVAILABLE if state_code == 0x40 else \
STATUS.CONNECTED if state_code == 0x80 else \
STATUS.CONNECTED if state_code == 0x00 else \
None
if state is None:
self.LOG.warn("don't know how to handle state code 0x%02X: %s", state_code, event)
return state
def _events_handler(self, event):
if self.events_filter and self.events_filter(event):
return
if event.code == 0x10 and event.data[0:2] == b'\x41\x04':
if event.devnumber in self.receiver.devices:
status = self._device_status_from(event)
if status is not None:
self.receiver.devices[event.devnumber].status = status
else:
dev = self.make_device(event)
if dev is None:
self.LOG.warn("failed to make new device from %s", event)
else:
self.receiver.devices[event.devnumber] = dev
self.change_status(STATUS.CONNECTED + len(self.receiver.devices))
return
if event.devnumber == 0xFF:
if event.code == 0xFF and event.data is None:
# receiver disconnected
self.LOG.warn("disconnected")
self.receiver.devices = {}
self.change_status(STATUS.UNAVAILABLE)
return
elif event.devnumber in self.receiver.devices:
dev = self.receiver.devices[event.devnumber]
if dev.process_event(event.code, event.data):
return
if self.events_handler and self.events_handler(event):
return
self.LOG.warn("don't know how to handle event %s", event)
def make_device(self, event):
if event.devnumber < 1 or event.devnumber > self.receiver.max_devices:
self.LOG.warn("got event for invalid device number %d: %s", event.devnumber, event)
return None
status = self._device_status_from(event)
if status is not None:
dev = DeviceInfo(self, event.devnumber, status)
self.LOG.info("new device %s", dev)
self.status_changed(dev, True)
return dev
self.LOG.error("failed to identify status of device %d from %s", event.devnumber, event)
def unpair_device(self, device):
try:
del self.receiver[device.number]
except IndexError:
self.LOG.error("failed to unpair device %s", device)
return False
del self.receiver.devices[device.number]
self.LOG.info("unpaired device %s", device)
self.change_status(STATUS.CONNECTED + len(self.receiver.devices))
device.status = STATUS.UNPAIRED
return True
def __str__(self):
return '<ReceiverListener(%s,%d)>' % (self.receiver.path, self.receiver.status)
@classmethod
def open(self, status_changed_callback=None):
receiver = _api.Receiver.open()
if receiver:
rl = ReceiverListener(receiver, status_changed_callback)
rl.start()
while not rl._active:
_sleep(0.1)
return rl
#
#
#
class _DUMMY_RECEIVER(object):
__slots__ = ['name', 'max_devices', 'status', 'status_text', 'devices']
name = kind = _api.Receiver.name
max_devices = _api.Receiver.max_devices
status = STATUS.UNAVAILABLE
status_text = _RECEIVER_STATUS_NAME[STATUS.UNAVAILABLE]
devices = {}
__bool__ = __nonzero__ = lambda self: False
DUMMY = _DUMMY_RECEIVER()

View File

@@ -1,127 +0,0 @@
#!/usr/bin/env python
NAME = 'Solaar'
VERSION = '0.7.2'
__author__ = "Daniel Pavel <daniel.pavel@gmail.com>"
__version__ = VERSION
__license__ = "GPL"
#
#
#
def _parse_arguments():
import argparse
arg_parser = argparse.ArgumentParser(prog=NAME.lower())
arg_parser.add_argument('-v', '--verbose',
action='count', default=0,
help='increase the logger verbosity (may be repeated)')
arg_parser.add_argument('-S', '--no-systray',
action='store_false',
dest='systray',
help='don\'t embed the application window into the systray')
arg_parser.add_argument('-N', '--no-notifications',
action='store_false',
dest='notifications',
help='disable desktop notifications (shown only when in systray)')
arg_parser.add_argument('-V', '--version',
action='version',
version='%(prog)s ' + __version__)
args = arg_parser.parse_args()
import logging
log_level = logging.WARNING - 10 * args.verbose
log_format='%(asctime)s %(levelname)8s [%(threadName)s] %(name)s: %(message)s'
logging.basicConfig(level=max(log_level, logging.DEBUG), format=log_format)
return args
def _check_requirements():
try:
import pyudev
except ImportError:
return 'python-pyudev'
try:
import gi.repository
except ImportError:
return 'python-gi'
try:
from gi.repository import Gtk
except ImportError:
return 'gir1.2-gtk-3.0'
if __name__ == '__main__':
args = _parse_arguments()
req_fail = _check_requirements()
if req_fail:
raise ImportError('missing required package: %s' % req_fail)
import ui
# check if the notifications are available and enabled
args.notifications &= args.systray
if ui.notify.available and ui.notify.init(NAME):
ui.action.toggle_notifications.set_active(args.notifications)
else:
ui.action.toggle_notifications = None
from receiver import DUMMY
window = ui.main_window.create(NAME, DUMMY.name, DUMMY.max_devices, args.systray)
if args.systray:
menu_actions = (ui.action.toggle_notifications,
ui.action.about)
icon = ui.status_icon.create(window, menu_actions)
else:
icon = None
window.present()
import pairing
from gi.repository import Gtk, GObject
listener = None
notify_missing = True
def status_changed(receiver, device=None, urgent=False):
ui.update(receiver, icon, window, device)
if ui.notify.available and urgent:
GObject.idle_add(ui.notify.show, device or receiver)
global listener
if not listener:
GObject.timeout_add(5000, check_for_listener)
listener = None
from receiver import ReceiverListener
def check_for_listener(retry=True):
global listener, notify_missing
if listener is None:
try:
listener = ReceiverListener.open(status_changed)
except OSError:
ui.show_permissions_warning(window)
if listener is None:
pairing.state = None
if notify_missing:
status_changed(DUMMY, None, True)
notify_missing = False
return retry
# print ("opened receiver", listener, listener.receiver)
notify_missing = True
pairing.state = pairing.State(listener)
status_changed(listener.receiver, None, True)
GObject.timeout_add(100, check_for_listener, False)
Gtk.main()
if listener is not None:
listener.stop()
ui.notify.uninit()

View File

@@ -1,70 +0,0 @@
# pass
from . import (notify, status_icon, main_window, pair_window, action)
from gi.repository import (GObject, Gtk)
GObject.threads_init()
from solaar import NAME as _NAME
_APP_ICONS = (_NAME + '-fail', _NAME + '-init', _NAME)
def appicon(receiver_status):
return (_APP_ICONS[0] if receiver_status < 0 else
_APP_ICONS[1] if receiver_status < 1 else
_APP_ICONS[2])
_ICON_THEME = Gtk.IconTheme.get_default()
def get_icon(name, fallback):
return name if name and _ICON_THEME.has_icon(name) else fallback
def icon_file(name):
if name and _ICON_THEME.has_icon(name):
return _ICON_THEME.lookup_icon(name, 0, 0).get_filename()
return None
def show_permissions_warning(window):
text = ('Found a possible Unifying Receiver device,\n'
'but did not have permission to open it.')
m = Gtk.MessageDialog(window, Gtk.DialogFlags.MODAL, Gtk.MessageType.ERROR, Gtk.ButtonsType.CLOSE, text)
m.set_title('Permissions error')
m.run()
m.destroy()
def find_children(container, *child_names):
assert container is not None
def _iterate_children(widget, names, result, count):
wname = widget.get_name()
if wname in names:
index = names.index(wname)
names[index] = None
result[index] = widget
count -= 1
if count > 0 and isinstance(widget, Gtk.Container):
for w in widget:
count = _iterate_children(w, names, result, count)
if count == 0:
break
return count
names = list(child_names)
count = len(names)
result = [None] * count
_iterate_children(container, names, result, count)
return tuple(result) if count > 1 else result[0]
def update(receiver, icon, window, reason):
assert receiver is not None
assert reason is not None
if window:
GObject.idle_add(main_window.update, window, receiver, reason)
if icon:
GObject.idle_add(status_icon.update, icon, receiver)

View File

@@ -1,91 +0,0 @@
#
#
#
# from sys import version as PYTTHON_VERSION
from gi.repository import Gtk
import ui.notify
import ui.pair_window
from solaar import NAME as _NAME
from solaar import VERSION as _VERSION
def _action(name, label, function, *args):
action = Gtk.Action(name, label, label, None)
action.set_icon_name(name)
if function:
action.connect('activate', function, *args)
return action
def _toggle_action(name, label, function, *args):
action = Gtk.ToggleAction(name, label, label, None)
action.set_icon_name(name)
action.connect('activate', function, *args)
return action
#
#
#
def _toggle_notifications(action):
if action.get_active():
ui.notify.init(_NAME)
else:
ui.notify.uninit()
action.set_sensitive(ui.notify.available)
toggle_notifications = _toggle_action('notifications', 'Notifications', _toggle_notifications)
def _show_about_window(action):
about = Gtk.AboutDialog()
about.set_icon_name(_NAME)
about.set_program_name(_NAME)
about.set_logo_icon_name(_NAME)
about.set_version(_VERSION)
about.set_license_type(Gtk.License.GPL_2_0)
about.set_authors(('Daniel Pavel http://github.com/pwr', ))
about.set_website('http://github.com/pwr/Solaar/wiki')
about.set_website_label('Solaar Wiki')
# about.set_comments('Using Python %s\n' % PYTTHON_VERSION.split(' ')[0])
about.run()
about.destroy()
about = _action('help-about', 'About ' + _NAME, _show_about_window)
quit = _action('exit', 'Quit', Gtk.main_quit)
#
#
#
import pairing
def _pair_device(action, frame):
window = frame.get_toplevel()
pair_dialog = ui.pair_window.create( action, pairing.state)
pair_dialog.set_transient_for(window)
pair_dialog.set_modal(True)
window.present()
pair_dialog.present()
def pair(frame):
return _action('add', 'Pair new device', _pair_device, frame)
def _unpair_device(action, frame):
window = frame.get_toplevel()
window.present()
device = frame._device
qdialog = Gtk.MessageDialog(window, 0,
Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
"Unpair device\n%s ?" % device.name)
choice = qdialog.run()
qdialog.destroy()
if choice == Gtk.ResponseType.YES:
pairing.state.unpair(device)
def unpair(frame):
return _action('remove', 'Unpair', _unpair_device, frame)

View File

@@ -1,304 +0,0 @@
#
#
#
from gi.repository import (Gtk, Gdk)
import ui
from logitech.devices.constants import (STATUS, PROPS)
_SMALL_DEVICE_ICON_SIZE = Gtk.IconSize.BUTTON
_DEVICE_ICON_SIZE = Gtk.IconSize.DIALOG
_STATUS_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR
_PLACEHOLDER = '~'
#
#
#
def _info_text(dev):
fw_text = '\n'.join(['%-12s\t<tt>%s%s%s</tt>' %
(f.kind, f.name, ' ' if f.name else '', f.version) for f in dev.firmware])
return ('<small>'
'Serial \t\t<tt>%s</tt>\n'
'HID protocol\t<tt>%1.1f</tt>\n'
'%s'
'</small>' % (dev.serial, dev.protocol, fw_text))
def _toggle_info(action, label_widget, box_widget, frame):
if action.get_active():
box_widget.set_visible(True)
if not label_widget.get_text():
label_widget.set_markup(_info_text(frame._device))
else:
box_widget.set_visible(False)
def _make_receiver_box(name):
frame = Gtk.Frame()
frame._device = None
frame.set_name(name)
icon_name = ui.get_icon(name, 'preferences-desktop-peripherals')
icon = Gtk.Image.new_from_icon_name(icon_name, _SMALL_DEVICE_ICON_SIZE)
label = Gtk.Label('Scanning...')
label.set_name('label')
label.set_alignment(0, 0.5)
toolbar = Gtk.Toolbar()
toolbar.set_name('toolbar')
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
toolbar.set_icon_size(Gtk.IconSize.MENU)
toolbar.set_show_arrow(False)
hbox = Gtk.HBox(homogeneous=False, spacing=8)
hbox.pack_start(icon, False, False, 0)
hbox.pack_start(label, True, True, 0)
hbox.pack_end(toolbar, False, False, 0)
info_label = Gtk.Label()
info_label.set_name('info-label')
info_label.set_alignment(0, 0.5)
info_label.set_padding(8, 2)
info_label.set_selectable(True)
info_box = Gtk.Frame()
info_box.add(info_label)
info_box.set_shadow_type(Gtk.ShadowType.ETCHED_IN)
toggle_info_action = ui.action._toggle_action('info', 'Receiver info', _toggle_info, info_label, info_box, frame)
toolbar.insert(toggle_info_action.create_tool_item(), 0)
toolbar.insert(ui.action.pair(frame).create_tool_item(), -1)
vbox = Gtk.VBox(homogeneous=False, spacing=2)
vbox.set_border_width(4)
vbox.pack_start(hbox, True, True, 0)
vbox.pack_start(info_box, True, True, 0)
frame.add(vbox)
frame.show_all()
info_box.set_visible(False)
return frame
def _make_device_box(index):
frame = Gtk.Frame()
frame._device = None
frame.set_name(_PLACEHOLDER)
icon_name = 'preferences-desktop-peripherals'
icon = Gtk.Image.new_from_icon_name(icon_name, _DEVICE_ICON_SIZE)
icon.set_name('icon')
icon.set_alignment(0.5, 0)
label = Gtk.Label('Initializing...')
label.set_name('label')
label.set_alignment(0, 0.5)
label.set_padding(4, 4)
battery_icon = Gtk.Image.new_from_icon_name('battery_unknown', _STATUS_ICON_SIZE)
battery_label = Gtk.Label()
battery_label.set_width_chars(6)
battery_label.set_alignment(0, 0.5)
light_icon = Gtk.Image.new_from_icon_name('light_unknown', _STATUS_ICON_SIZE)
light_label = Gtk.Label()
light_label.set_alignment(0, 0.5)
light_label.set_width_chars(8)
toolbar = Gtk.Toolbar()
toolbar.set_name('toolbar')
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
toolbar.set_icon_size(Gtk.IconSize.MENU)
toolbar.set_show_arrow(False)
status_box = Gtk.HBox(homogeneous=False, spacing=0)
status_box.set_name('status')
status_box.pack_start(battery_icon, False, True, 0)
status_box.pack_start(battery_label, False, True, 0)
status_box.pack_start(light_icon, False, True, 0)
status_box.pack_start(light_label, False, True, 0)
status_box.pack_end(toolbar, False, False, 0)
info_label = Gtk.Label()
info_label.set_name('info-label')
info_label.set_alignment(0, 0.5)
info_label.set_padding(8, 2)
info_label.set_selectable(True)
info_box = Gtk.Frame()
info_box.add(info_label)
toggle_info_action = ui.action._toggle_action('info', 'Device info', _toggle_info, info_label, info_box, frame)
toolbar.insert(toggle_info_action.create_tool_item(), 0)
toolbar.insert(ui.action.unpair(frame).create_tool_item(), -1)
vbox = Gtk.VBox(homogeneous=False, spacing=4)
vbox.pack_start(label, True, True, 0)
vbox.pack_start(status_box, True, True, 0)
vbox.pack_start(info_box, True, True, 0)
box = Gtk.HBox(homogeneous=False, spacing=4)
box.set_border_width(4)
box.pack_start(icon, False, False, 0)
box.pack_start(vbox, True, True, 0)
box.show_all()
frame.add(box)
info_box.set_visible(False)
return frame
def toggle(window, trigger):
if window.get_visible():
position = window.get_position()
window.hide()
window.move(*position)
else:
if trigger and type(trigger) == Gtk.StatusIcon:
x, y = window.get_position()
if x == 0 and y == 0:
x, y, _ = Gtk.StatusIcon.position_menu(Gtk.Menu(), trigger)
window.move(x, y)
window.present()
return True
def create(title, name, max_devices, systray=False):
window = Gtk.Window()
window.set_title(title)
window.set_icon_name(ui.appicon(0))
window.set_role('status-window')
vbox = Gtk.VBox(homogeneous=False, spacing=4)
vbox.set_border_width(4)
rbox = _make_receiver_box(name)
vbox.add(rbox)
for i in range(1, 1 + max_devices):
dbox = _make_device_box(i)
vbox.add(dbox)
vbox.set_visible(True)
window.add(vbox)
geometry = Gdk.Geometry()
geometry.min_width = 320
geometry.min_height = 32
window.set_geometry_hints(vbox, geometry, Gdk.WindowHints.MIN_SIZE)
window.set_resizable(False)
window.toggle_visible = lambda i: toggle(window, i)
if systray:
window.set_keep_above(True)
window.connect('delete-event', toggle)
else:
window.connect('delete-event', Gtk.main_quit)
return window
#
#
#
def _update_receiver_box(frame, receiver):
label, toolbar, info_label = ui.find_children(frame, 'label', 'toolbar', 'info-label')
label.set_text(receiver.status_text or '')
if receiver.status < STATUS.CONNECTED:
toolbar.set_sensitive(False)
toolbar.get_children()[0].set_active(False)
info_label.set_text('')
frame._device = None
else:
toolbar.set_sensitive(True)
frame._device = receiver
def _update_device_box(frame, dev):
frame._device = dev
icon, label, info_label = ui.find_children(frame, 'icon', 'label', 'info-label')
if frame.get_name() != dev.name:
frame.set_name(dev.name)
icon_name = ui.get_icon(dev.name, dev.kind)
icon.set_from_icon_name(icon_name, _DEVICE_ICON_SIZE)
label.set_markup('<b>' + dev.name + '</b>')
status = ui.find_children(frame, 'status')
status_icons = status.get_children()
toolbar = status_icons[-1]
if dev.status < STATUS.CONNECTED:
icon.set_sensitive(False)
label.set_sensitive(False)
status.set_sensitive(False)
for c in status_icons[1:-1]:
c.set_visible(False)
toolbar.get_children()[0].set_active(False)
else:
icon.set_sensitive(True)
label.set_sensitive(True)
status.set_sensitive(True)
battery_icon, battery_label = status_icons[0:2]
battery_level = dev.props.get(PROPS.BATTERY_LEVEL)
if battery_level is None:
battery_icon.set_from_icon_name('battery_unknown', _STATUS_ICON_SIZE)
battery_icon.set_sensitive(False)
battery_label.set_visible(False)
else:
icon_name = 'battery_%03d' % (20 * ((battery_level + 10) // 20))
battery_icon.set_from_icon_name(icon_name, _STATUS_ICON_SIZE)
battery_icon.set_sensitive(True)
battery_label.set_text('%d%%' % battery_level)
battery_label.set_visible(True)
battery_status = dev.props.get(PROPS.BATTERY_STATUS)
battery_icon.set_tooltip_text(battery_status or '')
light_icon, light_label = status_icons[2:4]
light_level = dev.props.get(PROPS.LIGHT_LEVEL)
if light_level is None:
light_icon.set_visible(False)
light_label.set_visible(False)
else:
icon_name = 'light_%03d' % (20 * ((light_level + 50) // 100))
light_icon.set_from_icon_name(icon_name, _STATUS_ICON_SIZE)
light_icon.set_visible(True)
light_label.set_text('%d lux' % light_level)
light_label.set_visible(True)
for b in toolbar.get_children()[:-1]:
b.set_sensitive(True)
frame.set_visible(True)
def update(window, receiver, device=None):
# print ("update", receiver, receiver.status, device)
window.set_icon_name(ui.appicon(receiver.status))
vbox = window.get_child()
frames = list(vbox.get_children())
if device is None:
_update_receiver_box(frames[0], receiver)
if receiver.status < STATUS.CONNECTED:
for frame in frames[1:]:
frame.set_visible(False)
frame.set_name(_PLACEHOLDER)
frame._device = None
else:
frame = frames[device.number]
if device.status == STATUS.UNPAIRED:
frame.set_visible(False)
frame.set_name(_PLACEHOLDER)
frame._device = None
else:
_update_device_box(frame, device)

View File

@@ -1,125 +0,0 @@
#
#
#
# import logging
from gi.repository import (Gtk, GObject)
import ui
def _create_page(assistant, text, kind):
p = Gtk.VBox(False, 12)
p.set_border_width(8)
if text:
label = Gtk.Label(text)
label.set_alignment(0, 0)
p.pack_start(label, False, True, 0)
assistant.append_page(p)
assistant.set_page_type(p, kind)
p.show_all()
return p
def _device_confirmed(entry, _2, trigger, assistant, page):
assistant.commit()
assistant.set_page_complete(page, True)
return True
def _finish(assistant):
# logging.debug("finish %s", assistant)
assistant.destroy()
def _cancel(assistant, state):
# logging.debug("cancel %s", assistant)
state.stop_scan()
_finish(assistant)
def _prepare(assistant, page, state):
index = assistant.get_current_page()
# logging.debug("prepare %s %d %s", assistant, index, page)
if index == 0:
state.reset()
GObject.timeout_add(state.TICK, state.countdown, assistant)
spinner = page.get_children()[-1]
spinner.start()
return
assistant.remove_page(0)
state.stop_scan()
def _scan_complete_ui(assistant, device):
if device is None:
page = _create_page(assistant,
'No new device detected.\n'
'\n'
'Make sure your device is within range of the receiver,\nand it has a decent battery charge.\n',
Gtk.AssistantPageType.CONFIRM)
else:
page = _create_page(assistant,
None,
Gtk.AssistantPageType.CONFIRM)
hbox = Gtk.HBox(False, 16)
device_icon = Gtk.Image()
device_icon.set_from_icon_name(ui.get_icon(device.name, device.kind), Gtk.IconSize.DIALOG)
hbox.pack_start(device_icon, False, False, 0)
device_label = Gtk.Label(device.kind + '\n' + device.name)
hbox.pack_start(device_label, False, False, 0)
halign = Gtk.Alignment.new(0.5, 0.5, 0, 1)
halign.add(hbox)
page.pack_start(halign, False, True, 0)
hbox = Gtk.HBox(False, 16)
hbox.pack_start(Gtk.Entry(), False, False, 0)
hbox.pack_start(Gtk.ToggleButton('Test'), False, False, 0)
halign = Gtk.Alignment.new(0.5, 0.5, 0, 1)
halign.add(hbox)
page.pack_start(halign, False, False, 0)
entry_info = Gtk.Label('Use the controls above to confirm\n'
'this is the device you want to pair.')
entry_info.set_sensitive(False)
page.pack_start(entry_info, False, False, 0)
page.show_all()
assistant.set_page_complete(page, True)
assistant.next_page()
def _scan_complete(assistant, device):
GObject.idle_add(_scan_complete_ui, assistant, device)
def create(action, state):
assistant = Gtk.Assistant()
assistant.set_title(action.get_label())
assistant.set_icon_name(action.get_icon_name())
assistant.set_size_request(440, 240)
assistant.set_resizable(False)
assistant.set_role('pair-device')
page_intro = _create_page(assistant,
'Turn on the device you want to pair.\n'
'\n'
'If the device is already turned on,\nturn if off and on again.',
Gtk.AssistantPageType.INTRO)
spinner = Gtk.Spinner()
spinner.set_visible(True)
page_intro.pack_end(spinner, True, True, 16)
assistant.scan_complete = _scan_complete
assistant.connect('prepare', _prepare, state)
assistant.connect('cancel', _cancel, state)
assistant.connect('close', _finish)
assistant.connect('apply', _finish)
return assistant

View File

@@ -1,55 +0,0 @@
#
#
#
from gi.repository import Gtk
import ui
def create(window, menu_actions=None):
icon = Gtk.StatusIcon()
icon.set_title(window.get_title())
icon.set_name(window.get_title())
icon.set_from_icon_name(ui.appicon(0))
icon.connect('activate', window.toggle_visible)
menu = Gtk.Menu()
for action in menu_actions or ():
if action:
menu.append(action.create_menu_item())
menu.append(ui.action.quit.create_menu_item())
menu.show_all()
icon.connect('popup_menu',
lambda icon, button, time, menu:
menu.popup(None, None, icon.position_menu, icon, button, time),
menu)
return icon
def update(icon, receiver):
icon.set_from_icon_name(ui.appicon(receiver.status))
if receiver.devices:
lines = []
if receiver.status < 1:
lines += (receiver.status_text, '')
devlist = [receiver.devices[d] for d in range(1, 1 + receiver.max_devices) if d in receiver.devices]
for dev in devlist:
name = '<b>' + dev.name + '</b>'
if dev.status < 1:
lines.append(name + ' (' + dev.status_text + ')')
else:
lines.append(name)
if dev.status > 1:
lines.append(' ' + dev.status_text)
lines.append('')
text = '\n'.join(lines).rstrip('\n')
icon.set_tooltip_markup(text)
else:
icon.set_tooltip_text(receiver.status_text)

View File

@@ -1,9 +0,0 @@
#!/bin/sh
Z=`readlink -f "$0"`
LIB=`readlink -f $(dirname "$Z")/../lib`
#export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$LIB/native/`arch`
export PYTHONPATH=$LIB
PYTHON=`which python python2 python3 | head -n 1`
exec $PYTHON -OOu -m hidapi.hidconsole "$@"

View File

@@ -1,9 +0,0 @@
#!/bin/sh
Z=`readlink -f "$0"`
LIB=`readlink -f $(dirname "$Z")/../lib`
#export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$LIB/native/`arch`
export PYTHONPATH=$LIB
PYTHON=`which python python2 python3 | head -n 1`
exec $PYTHON -OOu -m logitech.scanner "$@"

View File

@@ -1,13 +1,26 @@
#!/bin/sh
#!/usr/bin/env python
# -*- python-mode -*-
"""Takes care of starting the main function."""
Z=`readlink -f "$0"`
APP=`readlink -f $(dirname "$Z")/../app`
LIB=`readlink -f $(dirname "$Z")/../lib`
SHARE=`readlink -f $(dirname "$Z")/../share`
from __future__ import absolute_import
#export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$LIB/native/`arch`
export PYTHONPATH=$APP:$LIB
export XDG_DATA_DIRS=$SHARE:$XDG_DATA_DIRS
PYTHON=`which python python2 python3 | head -n 1`
exec $PYTHON -OOu -m solaar "$@"
def init_paths():
"""Make the app work in the source tree."""
import sys
import os.path as _path
prefix = _path.normpath(_path.join(_path.realpath(sys.path[0]), '..'))
src_lib = _path.join(prefix, 'lib')
share_lib = _path.join(prefix, 'share', 'solaar', 'lib')
for location in src_lib, share_lib:
init_py = _path.join(location, 'solaar', '__init__.py')
if _path.exists(init_py):
sys.path[0] = location
break
if __name__ == '__main__':
init_paths()
import solaar.gtk
solaar.gtk.main()

26
bin/solaar-cli Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env python
# -*- python-mode -*-
"""Takes care of starting the main function."""
from __future__ import absolute_import
def init_paths():
"""Make the app work in the source tree."""
import sys
import os.path as _path
prefix = _path.normpath(_path.join(_path.realpath(sys.path[0]), '..'))
src_lib = _path.join(prefix, 'lib')
share_lib = _path.join(prefix, 'share', 'solaar', 'lib')
for location in src_lib, share_lib:
init_py = _path.join(location, 'solaar', '__init__.py')
if _path.exists(init_py):
sys.path[0] = location
break
if __name__ == '__main__':
init_paths()
import solaar.cli
solaar.cli.main()

BIN
docs/20121210110342697.pdf Normal file

Binary file not shown.

35
docs/devices/m510.txt Normal file
View File

@@ -0,0 +1,35 @@
# ?
<< ( 0.001) [10 01 8100 000000] '\x10\x01\x81\x00\x00\x00\x00'
>> ( 0.062) [10 01 8100 000000] '\x10\x01\x81\x00\x00\x00\x00'
# ?
<< ( 1.063) [10 01 8101 000000] '\x10\x01\x81\x01\x00\x00\x00'
>> ( 1.078) [10 01 8101 820000] '\x10\x01\x81\x01\x82\x00\x00'
# ?
<< ( 2.079) [10 01 8102 000000] '\x10\x01\x81\x02\x00\x00\x00'
>> ( 2.094) [10 01 8102 000080] '\x10\x01\x81\x02\x00\x00\x80'
# ?
<< ( 7.263) [10 01 8107 000000] '\x10\x01\x81\x07\x00\x00\x00'
>> ( 7.278) [10 01 8107 050000] '\x10\x01\x81\x07\x05\x00\x00'
# ?
<< ( 41.121) [10 01 8128 000000] '\x10\x01\x81(\x00\x00\x00'
>> ( 41.136) [10 01 8128 000200] '\x10\x01\x81(\x00\x02\x00'
# ?
<< ( 215.788) [10 01 81D0 000000] '\x10\x01\x81\xd0\x00\x00\x00'
>> ( 215.802) [10 01 81D0 000000] '\x10\x01\x81\xd0\x00\x00\x00'
# read-only, 01-04 firmware info
<< ( 250.779) [10 01 81F1 000000] '\x10\x01\x81\xf1\x00\x00\x00'
>> ( 250.794) [10 01 8F81 F10300] '\x10\x01\x8f\x81\xf1\x03\x00'
# ?
<< ( 252.809) [10 01 81F3 000000] '\x10\x01\x81\xf3\x00\x00\x00'
>> ( 252.824) [10 01 81F3 000000] '\x10\x01\x81\xf3\x00\x00\x00'
# ?
<< ( 253.825) [10 01 81F4 000000] '\x10\x01\x81\xf4\x00\x00\x00'
>> ( 253.838) [10 01 81F4 800000] '\x10\x01\x81\xf4\x80\x00\x00'

42
docs/devices/m705.txt Normal file
View File

@@ -0,0 +1,42 @@
registers:
# writing 0x10 in this register will generate an event
# 10 02 0Dxx yyzz00
# where 0D happens to be the battery register number
# xx is the battery charge
# yy, zz ?
<< ( 0.001) [10 02 8100 000000] '\x10\x02\x81\x00\x00\x00\x00'
>> ( 1.132) [10 02 8100 100000] '\x10\x02\x81\x00\x10\x00\x00'
# smooth scroll - possible values
# - 00 (off)
# - 02 ?, apparently off as well, default value at power-on
# - 0x40 (on)
<< ( 2.005) [10 02 8101 000000] '\x10\x02\x81\x01\x00\x00\x00'
>> ( 2.052) [10 02 8101 020000] '\x10\x02\x81\x01\x02\x00\x00'
# battery status: percentage full, ?, ?
<< ( 14.835) [10 02 810D 000000] '\x10\x02\x81\r\x00\x00\x00'
>> ( 14.847) [10 02 810D 644734] '\x10\x02\x81\rdG4'
# accepts mask 0xF1
# setting 0x10 turns off the movement events (but buttons still work)
<< ( 221.495) [10 02 81D0 000000] '\x10\x02\x81\xd0\x00\x00\x00'
>> ( 221.509) [10 02 81D0 000000] '\x10\x02\x81\xd0\x00\x00\x00'
# appears to be read-only?
<< ( 223.527) [10 02 81D2 000000] '\x10\x02\x81\xd2\x00\x00\x00'
>> ( 223.540) [10 02 81D2 000003] '\x10\x02\x81\xd2\x00\x00\x03'
# appears to be read-only?
<< ( 225.557) [10 02 81D4 000000] '\x10\x02\x81\xd4\x00\x00\x00'
>> ( 225.571) [10 02 81D4 000004] '\x10\x02\x81\xd4\x00\x00\x04'
# read-only, 01-04 firmware info
<< ( 259.270) [10 02 81F1 000000] '\x10\x02\x81\xf1\x00\x00\x00'
>> ( 259.283) [10 02 8F81 F10300] '\x10\x02\x8f\x81\xf1\x03\x00'
# writing 01 here will trigger an avalance of events, most likely
# raw input from the mouse; disable by writing 00
<< ( 261.300) [10 02 81F3 000000] '\x10\x02\x81\xf3\x00\x00\x00'
>> ( 261.315) [10 02 81F3 000000] '\x10\x02\x81\xf3\x00\x00\x00'

View File

@@ -0,0 +1,12 @@
short register 0x63: values 0x81 .. 0x8F
set DPI as 100 .. 1500
short register 0x51: set leds
value: ab cd 00
where a/b/c/d values are 1=off, 2=on, 3=flash
a = lower led
b = red led
c = upper led
d = middle led

Binary file not shown.

278
docs/logitech/hid10.txt Normal file
View File

@@ -0,0 +1,278 @@
*Read short register command*
10 ix 81 02 00 00 00
ix
Index 0x0n: Device #n
0xFF: Transceiver
*Response to Read command (success)*
10 ix 81 02 00 r1 r2
ix
Index (same as command)
r1
Number of Connected Devices
bit 0..7: Number of connected devices (receivers only)
r2
Number of Remaining Pairing Slots
bit 0..7: Number of remaining pairing slots
*Read long register command*
10 ix 83 B5 nn 00 00
ix
Index 0xFF: Transceiver
nn
0x20 Device 1
0x21 Device 2
0x22 Device 3
0x23 Device 4
0x24 Device 5
0x25 Device 6
0x26..0x2F Reserved for future extensions
*Response to Read command (success)*
11 ix 83 B5 nn r1 r2 r3 r4 r5 r6 r7 r8 r9 ra rb rc rd 00 00
ix
Index (same as command)
nn
(same format as above)
r1
Destination ID
r2
Reserved
r3
Wireless PID MSB
r4
Wireless PID LSB
r5
Reserved
r6
Reserved
r7
Device type
0 undefined
1 keyboard
2 mouse
3 numpad
4 presenter
5 reserved
6 reserved
7 remote control
8 trackball
9 touchpad
a tablet
b gamepad
c joystick
r8
Reserved
r9
Reserved
Alternatively, if enabled, you can also receive a notification when a new
device is paired:
This message is sent by a receiver to the host SW to report a freshly
connected device. Enable the HID++ connection reporting by setting the
corresponding bit in register 0x00 via HID++ Set Register command.
*Notification*
10 ix 41 r0 r1 r2 r3
ix
Index
r0
bits [0..2] Protocol type
0x03 = eQUAD
0x04 = eQuad step 4 DJ
bits [3..7] Reserved
r1
Device Info
bit0..3 = Device Type
0x00 = Unknown
0x01 = Keyboard
0x02 = Mouse
0x03 = Numpad
0x04 = Presenter
r2
Wireless PID LSB
r3
Wireless PID MSB
To enable the notifications:
Enable HID++ Notifications:
This register defines a number of flags that allow the SW to turn on or off
individual spontaneous HID++ reports. Not setting a flag means default
reporting. See the table below for more details on each flag.
For all bits: *0 = disabled* (default value at power-up), 1 = enabled.
*Read short register command*
10 ix 81 00 00 00 00
ix
Index 0x0n: Device #n
0xFF: Transceiver
*Response to Read command (success)*
10 ix 81 00 r0 r1 r2
ix
Index (same as command)
r0
HID++ Reporting Flags (Devices)
bit 0..3. reserved
bit 4: Battery Status
bit 5..7 reserved
r1
HID++ Reporting Flags (Receiver)
bit 0: Wireless notifications
bit 1..7 reserved
r2
*Write short register command*
10 ix 80 00 p0 p1 p2
ix
Index 0x0n: Device #n
0xFF: Transceiver
p0
HID++ Reporting Flags (Devices)
(same format as above)
p1
HID++ Reporting Flags (Receiver)
(same format as above)
p2
*Response to Write command (success)*
10 ix 80 00 zz zz zz
ix
Index (same as command)
zz
(don't care, recommended to return 0)

View File

@@ -1,10 +1,7 @@
"""Generic Human Interface Device API."""
__author__ = "Daniel Pavel"
__license__ = "GPL"
__version__ = "0.4"
from __future__ import absolute_import, division, print_function, unicode_literals
try:
from hidapi.udev import *
except ImportError:
from hidapi.native import *
__version__ = "0.5"
from hidapi.udev import *

View File

@@ -1,109 +1,220 @@
#!/usr/bin/env python
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
import os
import sys
from select import select as _select
import time
from binascii import hexlify, unhexlify
_hex = lambda d: hexlify(d).decode('ascii').upper()
import hidapi
#
#
#
# no Python 3 support :(
read_packet = raw_input
interactive = os.isatty(0)
start_time = 0
try:
read_packet = raw_input
except:
read_packet = input
prompt = '?? Input: ' if interactive else ''
strhex = lambda d: hexlify(d).decode('ascii').upper()
start_time = time.time()
#
#
#
from threading import Lock
print_lock = Lock()
del Lock
def _print(marker, data, scroll=False):
t = time.time() - start_time
if type(data) == unicode:
s = marker + ' ' + data
else:
hexs = strhex(data)
s = '%s (% 8.3f) [%s %s %s %s] %s' % (marker, t, hexs[0:2], hexs[2:4], hexs[4:8], hexs[8:], repr(data))
print_lock.acquire()
if interactive and scroll:
sys.stdout.write('\033[s')
sys.stdout.write('\033[S') # scroll up
sys.stdout.write('\033[A\033[L\033[G') # insert new line above the current one, position on first column
hexs = _hex(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))
# scroll the entire screen above the current line up by 1 line
sys.stdout.write('\033[s' # save cursor position
'\033[S' # scroll up
'\033[A' # cursor up
'\033[L' # insert 1 line
'\033[G') # move cursor to column 1
sys.stdout.write(s)
if interactive and scroll:
# restore cursor position
sys.stdout.write('\033[u')
else:
sys.stdout.write('\n')
print_lock.release()
def _continuous_read(handle, timeout=1000):
def _error(text, scroll=False):
_print("!!", text, scroll)
def _continuous_read(handle, timeout=2000):
while True:
reply = hidapi.read(handle, 128, timeout)
if reply is None:
print ("!! Read failed, aborting")
try:
reply = hidapi.read(handle, 128, timeout)
except OSError as e:
_error("Read failed, aborting: " + str(e), True)
break
elif reply:
_print('>>', reply, True)
assert reply is not None
if reply:
_print(">>", reply, True)
def _validate_input(line, hidpp=False):
try:
data = unhexlify(line.encode('ascii'))
except Exception as e:
_error("Invalid input: " + str(e))
return None
if hidpp:
if len(data) < 4:
_error("Invalid HID++ request: need at least 4 bytes")
return None
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\x01\x02\x03\x04\x05\x06':
_error("Invalid HID++ request: second byte must be 0xFF or one of 0x01..0x06")
return None
if data[:1] == b'\x10':
if len(data) > 7:
_error("Invalid HID++ request: maximum length of a 0x10 request is 7 bytes")
return None
while len(data) < 7:
data = (data + b'\x00' * 7)[:7]
elif data[:1] == b'\x11':
if len(data) > 20:
_error("Invalid HID++ request: maximum length of a 0x11 request is 20 bytes")
return None
while len(data) < 20:
data = (data + b'\x00' * 20)[:20]
return data
def _open(device, hidpp):
if hidpp and not device:
for d in hidapi.enumerate(vendor_id=0x046d):
if d.driver == 'logitech-djreceiver':
device = d.path
break
if not device:
sys.exit("!! No HID++ receiver found.")
if not device:
sys.exit("!! Device path required.")
print (".. Opening device", device)
handle = hidapi.open_path(device)
if not handle:
sys.exit("!! Failed to open %s, aborting." % device)
print (".. Opened handle %r, vendor %r product %r serial %r." % (
handle,
hidapi.get_manufacturer(handle),
hidapi.get_product(handle),
hidapi.get_serial(handle)))
if hidpp:
if hidapi.get_manufacturer(handle) != b'Logitech':
sys.exit("!! Only Logitech devices support the HID++ protocol.")
print (".. HID++ validation enabled.")
return handle
#
#
#
def _parse_arguments():
import argparse
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')
return arg_parser.parse_args()
def main():
args = _parse_arguments()
handle = _open(args.device, args.hidpp)
if interactive:
print (".. Press ^C/^D to exit, or type hex bytes to write to the device.")
import readline
if args.history is None:
import os.path
args.history = os.path.join(os.path.expanduser("~"), ".hidconsole-history")
try:
readline.read_history_file(args.history)
except:
# file may not exist yet
pass
# re-open stdout unbuffered
try:
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 0)
except:
# will fail in python3
pass
try:
from threading import Thread
t = Thread(target=_continuous_read, args=(handle,))
t.daemon = True
t.start()
if interactive:
# move the cursor at the bottom of the screen
sys.stdout.write('\033[300B') # move cusor at most 300 lines down, don't scroll
while t.is_alive():
line = read_packet(prompt)
line = line.strip().replace(' ', '')
if not line:
continue
data = _validate_input(line, args.hidpp)
if data is None:
continue
_print("<<", data)
hidapi.write(handle, data)
# wait for some kind of reply
if args.hidpp and not interactive:
if data[1:2] == b'\xFF':
# the receiver will reply very fast, in a few milliseconds
time.sleep(0.010)
else:
# the devices might reply quite slow
rlist, wlist, xlist = _select([handle], [], [], 1)
time.sleep(1)
except EOFError:
if interactive:
print ("")
except Exception as e:
print ('%s: %s' % (type(e).__name__, e))
print (".. Closing handle %r" % handle)
hidapi.close(handle)
if interactive:
readline.write_history_file(args.history)
if __name__ == '__main__':
import argparse
arg_parser = argparse.ArgumentParser()
arg_parser.add_argument('--history', help='history file')
arg_parser.add_argument('device', default=None, help='linux device to connect to')
args = arg_parser.parse_args()
import hidapi
print (".. Opening device %s" % args.device)
handle = hidapi.open_path(args.device.encode('utf-8'))
if handle:
print (".. Opened handle %X, vendor %s product %s serial %s" % (handle,
repr(hidapi.get_manufacturer(handle)),
repr(hidapi.get_product(handle)),
repr(hidapi.get_serial(handle))))
if interactive:
print (".. Press ^C/^D to exit, or type hex bytes to write to the device.")
import readline
if args.history is None:
import os.path
args.history = os.path.join(os.path.expanduser("~"), ".hidconsole-history")
try:
readline.read_history_file(args.history)
except:
# file may not exist yet
pass
start_time = time.time()
try:
from threading import Thread
t = Thread(target=_continuous_read, args=(handle,))
t.daemon = True
t.start()
prompt = '?? Input: ' if interactive else ''
while t.is_alive():
line = read_packet(prompt).strip().replace(' ', '')
if line:
try:
data = unhexlify(line.encode('ascii'))
except Exception as e:
print ("!! Invalid input.")
else:
_print('<<', data)
hidapi.write(handle, data)
# wait for some kind of reply
if not interactive:
rlist, wlist, xlist = _select([handle], [], [], 1)
time.sleep(0.1)
except EOFError:
pass
except Exception as e:
print ('%s: %s' % (type(e).__name__, e))
print (".. Closing handle %X" % handle)
hidapi.close(handle)
if interactive:
readline.write_history_file(args.history)
else:
print ("!! Failed to open %s, aborting" % args.device)
main()

View File

@@ -1,380 +1,384 @@
"""Generic Human Interface Device API.
It is little more than a thin ctypes layer over a native hidapi implementation.
The docstrings are mostly copied from the hidapi API header, with changes where
necessary.
The native HID API implemenation is available at
https://github.com/signal11/hidapi.
The native implementation comes in two flavors, hidraw (``libhidapi-hidraw.so``)
and libusb (``libhidapi-libusb.so``). For this API to work, at least one of them
must be in ``LD_LIBRARY_PATH``; otherwise an ImportError will be raised.
Using the native hidraw implementation is recommended.
Currently the native libusb implementation (temporarily) detaches the device's
USB driver from the kernel, and it may cause the device to become unresponsive.
"""
__version__ = '0.3-hidapi-0.7.0'
# """Generic Human Interface Device API.
# It is little more than a thin ctypes layer over a native hidapi implementation.
# The docstrings are mostly copied from the hidapi API header, with changes where
# necessary.
# The native HID API implemenation is available at
# https://github.com/signal11/hidapi.
# The native implementation comes in two flavors, hidraw (``libhidapi-hidraw.so``)
# and libusb (``libhidapi-libusb.so``). For this API to work, at least one of them
# must be in ``LD_LIBRARY_PATH``; otherwise an ImportError will be raised.
# Using the native hidraw implementation is recommended.
# Currently the native libusb implementation (temporarily) detaches the device's
# USB driver from the kernel, and it may cause the device to become unresponsive.
# """
# #
# # LEGACY, no longer supported
# #
# __version__ = '0.3-hidapi-0.7.0'
import ctypes as _C
from struct import pack as _pack
# import ctypes as _C
# from struct import pack as _pack
#
# look for a native implementation in the same directory as this file
#
# #
# # look for a native implementation in the same directory as this file
# #
# The CDLL native library object.
_native = None
# # The CDLL native library object.
# _native = None
for native_implementation in ('hidraw', 'libusb'):
try:
_native = _C.cdll.LoadLibrary('libhidapi-' + native_implementation + '.so')
break
except OSError:
pass
# for native_implementation in ('hidraw', 'libusb'):
# try:
# _native = _C.cdll.LoadLibrary('libhidapi-' + native_implementation + '.so')
# break
# except OSError:
# pass
if _native is None:
raise ImportError('hidapi: failed to load any HID API native implementation')
# if _native is None:
# raise ImportError('hidapi: failed to load any HID API native implementation')
#
# Structures used by this API.
#
# #
# # Structures used by this API.
# #
# used by the native implementation when enumerating, no need to expose it
class _NativeDeviceInfo(_C.Structure):
pass
_NativeDeviceInfo._fields_ = [
('path', _C.c_char_p),
('vendor_id', _C.c_ushort),
('product_id', _C.c_ushort),
('serial', _C.c_wchar_p),
('release', _C.c_ushort),
('manufacturer', _C.c_wchar_p),
('product', _C.c_wchar_p),
('usage_page', _C.c_ushort),
('usage', _C.c_ushort),
('interface', _C.c_int),
('next_device', _C.POINTER(_NativeDeviceInfo))
]
# # used by the native implementation when enumerating, no need to expose it
# class _NativeDeviceInfo(_C.Structure):
# pass
# _NativeDeviceInfo._fields_ = [
# ('path', _C.c_char_p),
# ('vendor_id', _C.c_ushort),
# ('product_id', _C.c_ushort),
# ('serial', _C.c_wchar_p),
# ('release', _C.c_ushort),
# ('manufacturer', _C.c_wchar_p),
# ('product', _C.c_wchar_p),
# ('usage_page', _C.c_ushort),
# ('usage', _C.c_ushort),
# ('interface', _C.c_int),
# ('next_device', _C.POINTER(_NativeDeviceInfo))
# ]
# the tuple object we'll expose when enumerating devices
from collections import namedtuple
DeviceInfo = namedtuple('DeviceInfo', [
'path',
'vendor_id',
'product_id',
'serial',
'release',
'manufacturer',
'product',
'interface',
'driver',
])
del namedtuple
# # the tuple object we'll expose when enumerating devices
# from collections import namedtuple
# DeviceInfo = namedtuple('DeviceInfo', [
# 'path',
# 'vendor_id',
# 'product_id',
# 'serial',
# 'release',
# 'manufacturer',
# 'product',
# 'interface',
# 'driver',
# ])
# del namedtuple
# create a DeviceInfo tuple from a hid_device object
def _makeDeviceInfo(native_device_info):
return DeviceInfo(
path=native_device_info.path.decode('ascii'),
vendor_id=hex(native_device_info.vendor_id)[2:].zfill(4),
product_id=hex(native_device_info.product_id)[2:].zfill(4),
serial=native_device_info.serial if native_device_info.serial else None,
release=hex(native_device_info.release)[2:],
manufacturer=native_device_info.manufacturer,
product=native_device_info.product,
interface=native_device_info.interface,
driver=None)
# # create a DeviceInfo tuple from a hid_device object
# def _makeDeviceInfo(native_device_info):
# return DeviceInfo(
# path=native_device_info.path.decode('ascii'),
# vendor_id=hex(native_device_info.vendor_id)[2:].zfill(4),
# product_id=hex(native_device_info.product_id)[2:].zfill(4),
# serial=native_device_info.serial if native_device_info.serial else None,
# release=hex(native_device_info.release)[2:],
# manufacturer=native_device_info.manufacturer,
# product=native_device_info.product,
# interface=native_device_info.interface,
# driver=None)
#
# set-up arguments and return types for each hidapi function
#
# #
# # set-up arguments and return types for each hidapi function
# #
_native.hid_init.argtypes = None
_native.hid_init.restype = _C.c_int
# _native.hid_init.argtypes = None
# _native.hid_init.restype = _C.c_int
_native.hid_exit.argtypes = None
_native.hid_exit.restype = _C.c_int
# _native.hid_exit.argtypes = None
# _native.hid_exit.restype = _C.c_int
_native.hid_enumerate.argtypes = [_C.c_ushort, _C.c_ushort]
_native.hid_enumerate.restype = _C.POINTER(_NativeDeviceInfo)
# _native.hid_enumerate.argtypes = [_C.c_ushort, _C.c_ushort]
# _native.hid_enumerate.restype = _C.POINTER(_NativeDeviceInfo)
_native.hid_free_enumeration.argtypes = [_C.POINTER(_NativeDeviceInfo)]
_native.hid_free_enumeration.restype = None
# _native.hid_free_enumeration.argtypes = [_C.POINTER(_NativeDeviceInfo)]
# _native.hid_free_enumeration.restype = None
_native.hid_open.argtypes = [_C.c_ushort, _C.c_ushort, _C.c_wchar_p]
_native.hid_open.restype = _C.c_void_p
# _native.hid_open.argtypes = [_C.c_ushort, _C.c_ushort, _C.c_wchar_p]
# _native.hid_open.restype = _C.c_void_p
_native.hid_open_path.argtypes = [_C.c_char_p]
_native.hid_open_path.restype = _C.c_void_p
# _native.hid_open_path.argtypes = [_C.c_char_p]
# _native.hid_open_path.restype = _C.c_void_p
_native.hid_close.argtypes = [_C.c_void_p]
_native.hid_close.restype = None
# _native.hid_close.argtypes = [_C.c_void_p]
# _native.hid_close.restype = None
_native.hid_write.argtypes = [_C.c_void_p, _C.c_char_p, _C.c_size_t]
_native.hid_write.restype = _C.c_int
# _native.hid_write.argtypes = [_C.c_void_p, _C.c_char_p, _C.c_size_t]
# _native.hid_write.restype = _C.c_int
_native.hid_read.argtypes = [_C.c_void_p, _C.c_char_p, _C.c_size_t]
_native.hid_read.restype = _C.c_int
# _native.hid_read.argtypes = [_C.c_void_p, _C.c_char_p, _C.c_size_t]
# _native.hid_read.restype = _C.c_int
_native.hid_read_timeout.argtypes = [_C.c_void_p, _C.c_char_p, _C.c_size_t, _C.c_int]
_native.hid_read_timeout.restype = _C.c_int
# _native.hid_read_timeout.argtypes = [_C.c_void_p, _C.c_char_p, _C.c_size_t, _C.c_int]
# _native.hid_read_timeout.restype = _C.c_int
_native.hid_set_nonblocking.argtypes = [_C.c_void_p, _C.c_int]
_native.hid_set_nonblocking.restype = _C.c_int
# _native.hid_set_nonblocking.argtypes = [_C.c_void_p, _C.c_int]
# _native.hid_set_nonblocking.restype = _C.c_int
_native.hid_send_feature_report.argtypes = [_C.c_void_p, _C.c_char_p, _C.c_size_t]
_native.hid_send_feature_report.restype = _C.c_int
# _native.hid_send_feature_report.argtypes = [_C.c_void_p, _C.c_char_p, _C.c_size_t]
# _native.hid_send_feature_report.restype = _C.c_int
_native.hid_get_feature_report.argtypes = [_C.c_void_p, _C.c_char_p, _C.c_size_t]
_native.hid_get_feature_report.restype = _C.c_int
# _native.hid_get_feature_report.argtypes = [_C.c_void_p, _C.c_char_p, _C.c_size_t]
# _native.hid_get_feature_report.restype = _C.c_int
_native.hid_get_manufacturer_string.argtypes = [_C.c_void_p, _C.c_wchar_p, _C.c_size_t]
_native.hid_get_manufacturer_string.restype = _C.c_int
# _native.hid_get_manufacturer_string.argtypes = [_C.c_void_p, _C.c_wchar_p, _C.c_size_t]
# _native.hid_get_manufacturer_string.restype = _C.c_int
_native.hid_get_product_string.argtypes = [_C.c_void_p, _C.c_wchar_p, _C.c_size_t]
_native.hid_get_product_string.restype = _C.c_int
# _native.hid_get_product_string.argtypes = [_C.c_void_p, _C.c_wchar_p, _C.c_size_t]
# _native.hid_get_product_string.restype = _C.c_int
_native.hid_get_serial_number_string.argtypes = [_C.c_void_p, _C.c_wchar_p, _C.c_size_t]
_native.hid_get_serial_number_string.restype = _C.c_int
# _native.hid_get_serial_number_string.argtypes = [_C.c_void_p, _C.c_wchar_p, _C.c_size_t]
# _native.hid_get_serial_number_string.restype = _C.c_int
_native.hid_get_indexed_string.argtypes = [_C.c_void_p, _C.c_int, _C.c_wchar_p, _C.c_size_t]
_native.hid_get_indexed_string.restype = _C.c_int
# _native.hid_get_indexed_string.argtypes = [_C.c_void_p, _C.c_int, _C.c_wchar_p, _C.c_size_t]
# _native.hid_get_indexed_string.restype = _C.c_int
_native.hid_error.argtypes = [_C.c_void_p]
_native.hid_error.restype = _C.c_wchar_p
# _native.hid_error.argtypes = [_C.c_void_p]
# _native.hid_error.restype = _C.c_wchar_p
#
# exposed API
# docstrings mostly copied from hidapi.h
#
# #
# # exposed API
# # docstrings mostly copied from hidapi.h
# #
def init():
"""Initialize the HIDAPI library.
# def init():
# """Initialize the HIDAPI library.
This function initializes the HIDAPI library. Calling it is not strictly
necessary, as it will be called automatically by enumerate() and any of the
open_*() functions if it is needed. This function should be called at the
beginning of execution however, if there is a chance of HIDAPI handles
being opened by different threads simultaneously.
# This function initializes the HIDAPI library. Calling it is not strictly
# necessary, as it will be called automatically by enumerate() and any of the
# open_*() functions if it is needed. This function should be called at the
# beginning of execution however, if there is a chance of HIDAPI handles
# being opened by different threads simultaneously.
:returns: ``True`` if successful.
"""
return _native.hid_init() == 0
# :returns: ``True`` if successful.
# """
# return _native.hid_init() == 0
def exit():
"""Finalize the HIDAPI library.
# def exit():
# """Finalize the HIDAPI library.
This function frees all of the static data associated with HIDAPI. It should
be called at the end of execution to avoid memory leaks.
# This function frees all of the static data associated with HIDAPI. It should
# be called at the end of execution to avoid memory leaks.
:returns: ``True`` if successful.
"""
return _native.hid_exit() == 0
# :returns: ``True`` if successful.
# """
# return _native.hid_exit() == 0
def enumerate(vendor_id=None, product_id=None, interface_number=None):
"""Enumerate the HID Devices.
# def enumerate(vendor_id=None, product_id=None, interface_number=None):
# """Enumerate the HID Devices.
List all the HID devices attached to the system, optionally filtering by
vendor_id, product_id, and/or interface_number.
# List all the HID devices attached to the system, optionally filtering by
# vendor_id, product_id, and/or interface_number.
:returns: an iterable of matching ``DeviceInfo`` tuples.
"""
# :returns: an iterable of matching ``DeviceInfo`` tuples.
# """
devices = _native.hid_enumerate(vendor_id, product_id)
d = devices
while d:
if interface_number is None or interface_number == d.contents.interface:
yield _makeDeviceInfo(d.contents)
d = d.contents.next_device
# devices = _native.hid_enumerate(vendor_id, product_id)
# d = devices
# while d:
# if interface_number is None or interface_number == d.contents.interface:
# yield _makeDeviceInfo(d.contents)
# d = d.contents.next_device
if devices:
_native.hid_free_enumeration(devices)
# if devices:
# _native.hid_free_enumeration(devices)
def open(vendor_id, product_id, serial=None):
"""Open a HID device by its Vendor ID, Product ID and optional serial number.
# def open(vendor_id, product_id, serial=None):
# """Open a HID device by its Vendor ID, Product ID and optional serial number.
If no serial is provided, the first device with the specified IDs is opened.
# If no serial is provided, the first device with the specified IDs is opened.
:returns: an opaque device handle, or ``None``.
"""
return _native.hid_open(vendor_id, product_id, serial) or None
# :returns: an opaque device handle, or ``None``.
# """
# return _native.hid_open(vendor_id, product_id, serial) or None
def open_path(device_path):
"""Open a HID device by its path name.
# def open_path(device_path):
# """Open a HID device by its path name.
:param device_path: the path of a ``DeviceInfo`` tuple returned by
enumerate().
# :param device_path: the path of a ``DeviceInfo`` tuple returned by
# enumerate().
:returns: an opaque device handle, or ``None``.
"""
if type(device_path) == str:
device_path = device_path.encode('ascii')
return _native.hid_open_path(device_path) or None
# :returns: an opaque device handle, or ``None``.
# """
# if type(device_path) == str:
# device_path = device_path.encode('ascii')
# return _native.hid_open_path(device_path) or None
def close(device_handle):
"""Close a HID device.
# def close(device_handle):
# """Close a HID device.
:param device_handle: a device handle returned by open() or open_path().
"""
_native.hid_close(device_handle)
# :param device_handle: a device handle returned by open() or open_path().
# """
# _native.hid_close(device_handle)
def write(device_handle, data):
"""Write an Output report to a HID device.
# def write(device_handle, data):
# """Write an Output report to a HID device.
:param device_handle: a device handle returned by open() or open_path().
:param data: the data bytes to send including the report number as the
first byte.
# :param device_handle: a device handle returned by open() or open_path().
# :param data: the data bytes to send including the report number as the
# first byte.
The first byte of data[] must contain the Report ID. For
devices which only support a single report, this must be set
to 0x0. The remaining bytes contain the report data. Since
the Report ID is mandatory, calls to hid_write() will always
contain one more byte than the report contains. For example,
if a hid report is 16 bytes long, 17 bytes must be passed to
hid_write(), the Report ID (or 0x0, for devices with a
single report), followed by the report data (16 bytes). In
this example, the length passed in would be 17.
# The first byte of data[] must contain the Report ID. For
# devices which only support a single report, this must be set
# to 0x0. The remaining bytes contain the report data. Since
# the Report ID is mandatory, calls to hid_write() will always
# contain one more byte than the report contains. For example,
# if a hid report is 16 bytes long, 17 bytes must be passed to
# hid_write(), the Report ID (or 0x0, for devices with a
# single report), followed by the report data (16 bytes). In
# this example, the length passed in would be 17.
write() will send the data on the first OUT endpoint, if
one exists. If it does not, it will send the data through
the Control Endpoint (Endpoint 0).
# write() will send the data on the first OUT endpoint, if
# one exists. If it does not, it will send the data through
# the Control Endpoint (Endpoint 0).
:returns: ``True`` if the write was successful.
"""
bytes_written = _native.hid_write(device_handle, _C.c_char_p(data), len(data))
return bytes_written > -1
# :returns: ``True`` if the write was successful.
# """
# bytes_written = _native.hid_write(device_handle, _C.c_char_p(data), len(data))
# return bytes_written > -1
def read(device_handle, bytes_count, timeout_ms=-1):
"""Read an Input report from a HID device.
# def read(device_handle, bytes_count, timeout_ms=-1):
# """Read an Input report from a HID device.
:param device_handle: a device handle returned by open() or open_path().
:param bytes_count: maximum number of bytes to read.
:param timeout_ms: can be -1 (default) to wait for data indefinitely, 0 to
read whatever is in the device's input buffer, or a positive integer to
wait that many milliseconds.
# :param device_handle: a device handle returned by open() or open_path().
# :param bytes_count: maximum number of bytes to read.
# :param timeout_ms: can be -1 (default) to wait for data indefinitely, 0 to
# read whatever is in the device's input buffer, or a positive integer to
# wait that many milliseconds.
Input reports are returned to the host through the INTERRUPT IN endpoint.
The first byte will contain the Report number if the device uses numbered
reports.
# Input reports are returned to the host through the INTERRUPT IN endpoint.
# The first byte will contain the Report number if the device uses numbered
# reports.
:returns: the data packet read, an empty bytes string if a timeout was
reached, or None if there was an error while reading.
"""
out_buffer = _C.create_string_buffer(b'\x00' * (bytes_count + 1))
bytes_read = _native.hid_read_timeout(device_handle, out_buffer, bytes_count, timeout_ms)
if bytes_read == -1:
return None
if bytes_read == 0:
return b''
return out_buffer[:bytes_read]
# :returns: the data packet read, an empty bytes string if a timeout was
# reached, or None if there was an error while reading.
# """
# out_buffer = _C.create_string_buffer(b'\x00' * (bytes_count + 1))
# bytes_read = _native.hid_read_timeout(device_handle, out_buffer, bytes_count, timeout_ms)
# if bytes_read == -1:
# return None
# if bytes_read == 0:
# return b''
# return out_buffer[:bytes_read]
def send_feature_report(device_handle, data, report_number=None):
"""Send a Feature report to the device.
# def send_feature_report(device_handle, data, report_number=None):
# """Send a Feature report to the device.
:param device_handle: a device handle returned by open() or open_path().
:param data: the data bytes to send including the report number as the
first byte.
:param report_number: if set, it is sent as the first byte with the data.
# :param device_handle: a device handle returned by open() or open_path().
# :param data: the data bytes to send including the report number as the
# first byte.
# :param report_number: if set, it is sent as the first byte with the data.
Feature reports are sent over the Control endpoint as a
Set_Report transfer. The first byte of data[] must
contain the Report ID. For devices which only support a
single report, this must be set to 0x0. The remaining bytes
contain the report data. Since the Report ID is mandatory,
calls to send_feature_report() will always contain one
more byte than the report contains. For example, if a hid
report is 16 bytes long, 17 bytes must be passed to
send_feature_report(): the Report ID (or 0x0, for
devices which do not use numbered reports), followed by the
report data (16 bytes).
# Feature reports are sent over the Control endpoint as a
# Set_Report transfer. The first byte of data[] must
# contain the Report ID. For devices which only support a
# single report, this must be set to 0x0. The remaining bytes
# contain the report data. Since the Report ID is mandatory,
# calls to send_feature_report() will always contain one
# more byte than the report contains. For example, if a hid
# report is 16 bytes long, 17 bytes must be passed to
# send_feature_report(): the Report ID (or 0x0, for
# devices which do not use numbered reports), followed by the
# report data (16 bytes).
:returns: ``True`` if the report was successfully written to the device.
"""
if report_number is not None:
data = _pack('!B', report_number) + data
bytes_written = _native.hid_send_feature_report(device_handle, _C.c_char_p(data), len(data))
return bytes_written > -1
# :returns: ``True`` if the report was successfully written to the device.
# """
# if report_number is not None:
# data = _pack(b'!B', report_number) + data
# bytes_written = _native.hid_send_feature_report(device_handle, _C.c_char_p(data), len(data))
# return bytes_written > -1
def get_feature_report(device_handle, bytes_count, report_number=None):
"""Get a feature report from a HID device.
:param device_handle: a device handle returned by open() or open_path().
:param bytes_count: how many bytes to read.
:param report_number: if set, it is sent as the report number.
:returns: the feature report data.
"""
out_buffer = _C.create_string_buffer('\x00' * (bytes_count + 2))
if report_number is not None:
out_buffer[0] = _pack('!B', report_number)
bytes_read = _native.hid_get_feature_report(device_handle, out_buffer, bytes_count)
if bytes_read > -1:
return out_buffer[:bytes_read]
def _read_wchar(func, device_handle, index=None):
_BUFFER_SIZE = 64
buf = _C.create_unicode_buffer('\x00' * _BUFFER_SIZE)
if index is None:
ok = func(device_handle, buf, _BUFFER_SIZE)
else:
ok = func(device_handle, index, buf, _BUFFER_SIZE)
if ok == 0:
return buf.value
def get_manufacturer(device_handle):
"""Get the Manufacturer String from a HID device.
:param device_handle: a device handle returned by open() or open_path().
"""
return _read_wchar(_native.hid_get_manufacturer_string, device_handle)
def get_product(device_handle):
"""Get the Product String from a HID device.
:param device_handle: a device handle returned by open() or open_path().
"""
return _read_wchar(_native.hid_get_product_string, device_handle)
def get_serial(device_handle):
"""Get the serial number from a HID device.
:param device_handle: a device handle returned by open() or open_path().
"""
serial = _read_wchar(_native.hid_get_serial_number_string, device_handle)
if serial is not None:
return ''.join(hex(ord(c)) for c in serial)
def get_indexed_string(device_handle, index):
"""Get a string from a HID device, based on its string index.
Note: currently not working in the ``hidraw`` native implementation.
:param device_handle: a device handle returned by open() or open_path().
:param index: the index of the string to get.
"""
return _read_wchar(_native.hid_get_indexed_string, device_handle, index)
# def get_feature_report(device_handle, bytes_count, report_number=None):
# """Get a feature report from a HID device.
# :param device_handle: a device handle returned by open() or open_path().
# :param bytes_count: how many bytes to read.
# :param report_number: if set, it is sent as the report number.
# :returns: the feature report data.
# """
# out_buffer = _C.create_string_buffer('\x00' * (bytes_count + 2))
# if report_number is not None:
# out_buffer[0] = _pack(b'!B', report_number)
# bytes_read = _native.hid_get_feature_report(device_handle, out_buffer, bytes_count)
# if bytes_read > -1:
# return out_buffer[:bytes_read]
# def _read_wchar(func, device_handle, index=None):
# _BUFFER_SIZE = 64
# buf = _C.create_unicode_buffer('\x00' * _BUFFER_SIZE)
# if index is None:
# ok = func(device_handle, buf, _BUFFER_SIZE)
# else:
# ok = func(device_handle, index, buf, _BUFFER_SIZE)
# if ok == 0:
# return buf.value
# def get_manufacturer(device_handle):
# """Get the Manufacturer String from a HID device.
# :param device_handle: a device handle returned by open() or open_path().
# """
# return _read_wchar(_native.hid_get_manufacturer_string, device_handle)
# def get_product(device_handle):
# """Get the Product String from a HID device.
# :param device_handle: a device handle returned by open() or open_path().
# """
# return _read_wchar(_native.hid_get_product_string, device_handle)
# def get_serial(device_handle):
# """Get the serial number from a HID device.
# :param device_handle: a device handle returned by open() or open_path().
# """
# serial = _read_wchar(_native.hid_get_serial_number_string, device_handle)
# if serial is not None:
# return ''.join(hex(ord(c)) for c in serial)
# def get_indexed_string(device_handle, index):
# """Get a string from a HID device, based on its string index.
# Note: currently not working in the ``hidraw`` native implementation.
# :param device_handle: a device handle returned by open() or open_path().
# :param index: the index of the string to get.
# """
# return _read_wchar(_native.hid_get_indexed_string, device_handle, index)

View File

@@ -7,10 +7,12 @@ The docstrings are mostly copied from the hidapi API header, with changes where
necessary.
"""
from __future__ import absolute_import, division, print_function, unicode_literals
import os as _os
import errno as _errno
from select import select as _select
from pyudev import Context as _Context
from pyudev import Device as _Device
from pyudev import Context as _Context, Device as _Device
native_implementation = 'udev'
@@ -31,6 +33,7 @@ DeviceInfo = namedtuple('DeviceInfo', [
])
del namedtuple
#
# exposed API
# docstrings mostly copied from hidapi.h
@@ -124,6 +127,8 @@ def open_path(device_path):
:returns: an opaque device handle, or ``None``.
"""
assert device_path
assert device_path.startswith('/dev/hidraw')
return _os.open(device_path, _os.O_RDWR | _os.O_SYNC)
@@ -132,6 +137,7 @@ def close(device_handle):
:param device_handle: a device handle returned by open() or open_path().
"""
assert device_handle
_os.close(device_handle)
@@ -155,14 +161,11 @@ def write(device_handle, data):
write() will send the data on the first OUT endpoint, if
one exists. If it does not, it will send the data through
the Control Endpoint (Endpoint 0).
:returns: ``True`` if the write was successful.
"""
try:
bytes_written = _os.write(device_handle, data)
return bytes_written == len(data)
except:
pass
assert device_handle
bytes_written = _os.write(device_handle, data)
if bytes_written != len(data):
raise OSError(errno=_errno.EIO, strerror='written %d bytes out of expected %d' % (bytes_written, len(data)))
def read(device_handle, bytes_count, timeout_ms=-1):
@@ -181,15 +184,21 @@ def read(device_handle, bytes_count, timeout_ms=-1):
:returns: the data packet read, an empty bytes string if a timeout was
reached, or None if there was an error while reading.
"""
try:
timeout = None if timeout_ms < 0 else timeout_ms / 1000.0
rlist, wlist, xlist = _select([device_handle], [], [], timeout)
if rlist:
assert rlist == [device_handle]
return _os.read(device_handle, bytes_count)
assert device_handle
timeout = None if timeout_ms < 0 else timeout_ms / 1000.0
rlist, wlist, xlist = _select([device_handle], [], [device_handle], timeout)
if xlist:
assert xlist == [device_handle]
raise OSError(errno=_errno.EIO, strerror='exception on file descriptor %d' % device_handle)
if rlist:
assert rlist == [device_handle]
data = _os.read(device_handle, bytes_count)
assert data is not None
return data
else:
return b''
except OSError:
pass
_DEVICE_STRINGS = {
@@ -236,6 +245,7 @@ def get_indexed_string(device_handle, index):
if index not in _DEVICE_STRINGS:
return None
assert device_handle
stat = _os.fstat(device_handle)
dev = _Device.from_device_number(_Context(), 'char', stat.st_rdev)
if dev:

View File

@@ -1,5 +1,7 @@
#
#
#
__author__ = "Daniel Pavel"
__license__ = "GPL"
__version__ = "0.5"
from __future__ import absolute_import, division, print_function, unicode_literals
__version__ = "0.8"

View File

@@ -1,100 +0,0 @@
#
#
#
import logging
from .constants import (STATUS, PROPS)
from ..unifying_receiver.constants import (FEATURE, BATTERY_STATUS)
from ..unifying_receiver import api as _api
#
#
#
_DEVICE_MODULES = {}
def _module(device_name):
if device_name not in _DEVICE_MODULES:
shortname = device_name.split(' ')[-1].lower()
try:
m = __import__(shortname, globals(), level=1)
_DEVICE_MODULES[device_name] = m
except:
# logging.exception(shortname)
_DEVICE_MODULES[device_name] = None
return _DEVICE_MODULES[device_name]
#
#
#
def default_request_status(devinfo):
if FEATURE.BATTERY in devinfo.features:
reply = _api.get_device_battery_level(devinfo.handle, devinfo.number, features=devinfo.features)
if reply:
discharge, dischargeNext, status = reply
return STATUS.CONNECTED, {PROPS.BATTERY_LEVEL: discharge, PROPS.BATTERY_STATUS: status}
reply = _api.ping(devinfo.handle, devinfo.number)
return STATUS.CONNECTED if reply else STATUS.UNAVAILABLE
def default_process_event(devinfo, data):
feature_index = ord(data[0:1])
if feature_index >= len(devinfo.features):
logging.warn("mistery event %s for %s", repr(data), devinfo)
return None
feature = devinfo.features[feature_index]
feature_function = ord(data[1:2]) & 0xF0
if feature == FEATURE.BATTERY:
if feature_function == 0:
discharge = ord(data[2:3])
status = BATTERY_STATUS[ord(data[3:4])]
return STATUS.CONNECTED, {PROPS.BATTERY_LEVEL: discharge, PROPS.BATTERY_STATUS: status}
# ?
elif feature == FEATURE.REPROGRAMMABLE_KEYS:
if feature_function == 0:
logging.debug('reprogrammable key: %s', repr(data))
# TODO
pass
# ?
elif feature == FEATURE.WIRELESS:
if feature_function == 0:
logging.debug("wireless status: %s", repr(data))
if data[2:5] == b'\x01\x01\x01':
return STATUS.CONNECTED
# TODO
pass
# ?
def request_status(devinfo):
"""Trigger a status request for a device.
:param devinfo: the device info tuple.
:param listener: the EventsListener that will be used to send the request,
and which will receive the status events from the device.
"""
m = _module(devinfo.name)
if m and 'request_status' in m.__dict__:
return m.request_status(devinfo)
return default_request_status(devinfo)
def process_event(devinfo, data):
"""Process an event received for a device.
:param devinfo: the device info tuple.
:param data: the event data (event packet sans the first two bytes: reply code and device number)
"""
default_result = default_process_event(devinfo, data)
if default_result is not None:
return default_result
m = _module(devinfo.name)
if m and 'process_event' in m.__dict__:
return m.process_event(devinfo, data)

View File

@@ -1,47 +0,0 @@
#
#
#
STATUS = type('STATUS', (),
dict(
UNKNOWN=-9999,
UNPAIRED=-1000,
UNAVAILABLE=-1,
BOOTING=0,
CONNECTED=1,
))
STATUS_NAME = {
STATUS.UNKNOWN: '...',
STATUS.UNPAIRED: 'unpaired',
STATUS.UNAVAILABLE: 'inactive',
STATUS.BOOTING: 'initializing',
STATUS.CONNECTED: 'connected',
}
# device properties that may be reported
PROPS = type('PROPS', (),
dict(
BATTERY_LEVEL='battery_level',
BATTERY_STATUS='battery_status',
LIGHT_LEVEL='light_level',
))
# when the receiver reports a device that is not connected
# (and thus cannot be queried), guess the name and type
# based on this table
NAMES = {
'M315': ('Wireless Mouse M315', 'mouse'),
'M325': ('Wireless Mouse M325', 'mouse'),
'M510': ('Wireless Mouse M510', 'mouse'),
'M515': ('Couch Mouse M515', 'mouse'),
'M525': ('Wireless Mouse M525', 'mouse'),
'M570': ('Wireless Trackball M570', 'trackball'),
'K270': ('Wireless Keyboard K270', 'keyboard'),
'K350': ('Wireless Keyboard K350', 'keyboard'),
'K750': ('Wireless Solar Keyboard K750', 'keyboard'),
'K800': ('Wireless Illuminated Keyboard K800', 'keyboard'),
'T650': ('Wireless Rechargeable Touchpad T650', 'touchpad'),
'Performance MX': ('Performance Mouse MX', 'mouse'),
}

View File

@@ -1,50 +0,0 @@
#
# Functions specific to the K750 solar keyboard.
#
import logging
from struct import unpack as _unpack
from .constants import (STATUS, PROPS)
from ..unifying_receiver.constants import FEATURE
from ..unifying_receiver import api as _api
#
#
#
_CHARGE_LEVELS = (10, 25, 256)
def _charge_status(data, hasLux=False):
charge, lux = _unpack('!BH', data[2:5])
for i in range(0, len(_CHARGE_LEVELS)):
if charge < _CHARGE_LEVELS[i]:
charge_index = i
break
return 0x10 << charge_index, {
PROPS.BATTERY_LEVEL: charge,
PROPS.LIGHT_LEVEL: lux if hasLux else None,
}
def request_status(devinfo):
reply = _api.request(devinfo.handle, devinfo.number,
feature=FEATURE.SOLAR_CHARGE, function=b'\x03', params=b'\x78\x01',
features=devinfo.features)
if reply is None:
return STATUS.UNAVAILABLE
def process_event(devinfo, data):
if data[:2] == b'\x09\x00' and data[7:11] == b'GOOD':
# usually sent after the keyboard is turned on or just connected
return _charge_status(data)
if data[:2] == b'\x09\x10' and data[7:11] == b'GOOD':
# regular solar charge events
return _charge_status(data, True)
if data[:2] == b'\x09\x20' and data[7:11] == b'GOOD':
logging.debug("Solar key pressed")
return request_status(devinfo) or _charge_status(data)

View File

@@ -1,73 +0,0 @@
#!/usr/bin/env python
def print_receiver(receiver):
print (str(receiver))
print (" Serial : %s" % receiver.serial)
for f in receiver.firmware:
print (" %-10s: %s" % (f.kind, f.version))
def scan_devices(receiver):
for dev in receiver:
print ("--------")
print (str(dev))
print ("Name : %s" % dev.name)
print ("Kind : %s" % dev.kind)
print ("Serial number: %s" % dev.serial)
if not dev.protocol:
print ("HID protocol : UNKNOWN")
continue
print ("HID protocol : HID %01.1f" % dev.protocol)
if dev.protocol < 2.0:
print ("Features query not supported by this device")
continue
firmware = dev.firmware
for fw in firmware:
print (" %-10s: %s %s" % (fw.kind, fw.name, fw.version))
all_features = api.get_device_features(dev.handle, dev.number)
for index in range(0, len(all_features)):
feature = all_features[index]
if feature:
print (" ~ Feature %-20s (%s) at index %02X" % (FEATURE_NAME[feature], api._hex(feature), index))
if FEATURE.BATTERY in all_features:
discharge, dischargeNext, status = api.get_device_battery_level(dev.handle, dev.number, features=all_features)
print (" Battery %d charged (next level %d%), status %s" % (discharge, dischargeNext, status))
if FEATURE.REPROGRAMMABLE_KEYS in all_features:
keys = api.get_device_keys(dev.handle, dev.number, features=all_features)
if keys is not None and keys:
print (" %d reprogrammable keys found" % len(keys))
for k in keys:
flags = ','.join(KEY_FLAG_NAME[f] for f in KEY_FLAG_NAME if k.flags & f)
print (" %2d: %-12s => %-12s :%s" % (k.index, KEY_NAME[k.id], KEY_NAME[k.task], flags))
print ("--------")
if __name__ == '__main__':
import argparse
arg_parser = argparse.ArgumentParser(prog='scan')
arg_parser.add_argument('-v', '--verbose', action='store_true', default=False,
help='log the HID data traffic')
args = arg_parser.parse_args()
import logging
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING)
from .unifying_receiver import api
from .unifying_receiver.constants import *
receiver = api.Receiver.open()
if receiver is None:
print ("!! Logitech Unifying Receiver not found.")
else:
print ("!! Found Logitech Unifying Receiver: %s" % receiver)
print_receiver(receiver)
scan_devices(receiver)
receiver.close()

View File

@@ -6,30 +6,29 @@ implementation.
Incomplete. Based on a bit of documentation, trial-and-error, and guesswork.
Strongly recommended to use these functions from a single thread; calling
multiple functions from different threads has a high chance of mixing the
replies and causing apparent failures.
Basic order of operations is:
- open() to obtain a UR handle
- request() to make a feature call to one of the devices attached to the UR
- close() to close the UR handle
References:
http://julien.danjou.info/blog/2012/logitech-k750-linux-support
http://6xq.net/git/lars/lshidpp.git/plain/doc/
"""
from __future__ import absolute_import, division, print_function, unicode_literals
import logging
if logging.root.level > logging.DEBUG:
log = logging.getLogger('LUR')
log.addHandler(logging.NullHandler())
log.propagate = 0
_DEBUG = logging.DEBUG
_log = logging.getLogger('LUR')
_log.setLevel(logging.root.level)
# if logging.root.level > logging.DEBUG:
# _log.addHandler(logging.NullHandler())
# _log.propagate = 0
del logging
from .constants import *
from .exceptions import *
from .api import *
from .common import strhex
from .base import NoReceiver, NoSuchDevice, DeviceUnreachable
from .receiver import Receiver, PairedDevice, MAX_PAIRED_DEVICES
from .hidpp20 import FeatureNotSupported, FeatureCallError
from . import listener
from . import status

View File

@@ -1,501 +0,0 @@
#
# Logitech Unifying Receiver API.
#
from struct import pack as _pack
from struct import unpack as _unpack
import errno as _errno
from . import base as _base
from .common import (FirmwareInfo as _FirmwareInfo,
ReprogrammableKeyInfo as _ReprogrammableKeyInfo)
from .constants import (FEATURE, FEATURE_NAME, FEATURE_FLAGS,
FIRMWARE_KIND, DEVICE_KIND,
BATTERY_STATUS, KEY_NAME,
MAX_ATTACHED_DEVICES)
from .exceptions import FeatureNotSupported as _FeatureNotSupported
_hex = _base._hex
from logging import getLogger
_log = getLogger('LUR').getChild('api')
del getLogger
#
#
#
class PairedDevice(object):
def __init__(self, handle, number):
self.handle = handle
self.number = number
self._protocol = None
self._features = None
self._codename = None
self._name = None
self._kind = None
self._serial = None
self._firmware = None
@property
def protocol(self):
if self._protocol is None:
self._protocol = _base.ping(self.handle, self.number)
return 0 if self._protocol is None else self._protocol
@property
def features(self):
if self._features is None:
if self.protocol >= 2.0:
self._features = [FEATURE.ROOT]
return self._features
@property
def codename(self):
if self._codename is None:
codename = _base.request(self.handle, 0xFF, b'\x83\xB5', 0x40 + self.number - 1)
if codename:
self._codename = codename[2:].rstrip(b'\x00').decode('ascii')
return self._codename or '?'
@property
def name(self):
if self._name is None:
if self.protocol < 2.0:
from ..devices.constants import NAMES as _DEVICE_NAMES
if self.codename in _DEVICE_NAMES:
self._name, self._kind = _DEVICE_NAMES[self._codename]
else:
self._name = get_device_name(self.handle, self.number, self.features)
return self._name or self.codename
@property
def kind(self):
if self._kind is None:
if self.protocol < 2.0:
from ..devices.constants import NAMES as _DEVICE_NAMES
if self.codename in _DEVICE_NAMES:
self._name, self._kind = _DEVICE_NAMES[self._codename]
else:
self._kind = get_device_kind(self.handle, self.number, self.features)
return self._kind or '?'
@property
def firmware(self):
if self._firmware is None and self.protocol >= 2.0:
self._firmware = get_device_firmware(self.handle, self.number, self.features)
return self._firmware or ()
@property
def serial(self):
if self._serial is None:
prefix = _base.request(self.handle, 0xFF, b'\x83\xB5', 0x20 + self.number - 1)
serial = _base.request(self.handle, 0xFF, b'\x83\xB5', 0x30 + self.number - 1)
if prefix and serial:
self._serial = _base._hex(prefix[3:5]) + '-' + _base._hex(serial[1:5])
return self._serial or '?'
def ping(self):
return _base.ping(self.handle, self.number) is not None
def __str__(self):
return '<PairedDevice(%X,%d,%s)>' % (self.handle, self.number, self._name or '?')
def __hash__(self):
return self.number
class Receiver(object):
name = 'Unifying Receiver'
max_devices = MAX_ATTACHED_DEVICES
def __init__(self, handle, path=None):
self.handle = handle
self.path = path
self._serial = None
self._firmware = None
def close(self):
handle, self.handle = self.handle, 0
return (handle and _base.close(handle))
@property
def serial(self):
if self._serial is None and self.handle:
serial = _base.request(self.handle, 0xFF, b'\x83\xB5', b'\x03')
if serial:
self._serial = _hex(serial[1:5])
return self._serial
@property
def firmware(self):
if self._firmware is None and self.handle:
firmware = []
reply = _base.request(self.handle, 0xFF, b'\x83\xB5', b'\x02')
if reply and reply[0:1] == b'\x02':
fw_version = _hex(reply[1:5])
fw_version = '%s.%s.B%s' % (fw_version[0:2], fw_version[2:4], fw_version[4:8])
firmware.append(_FirmwareInfo(0, FIRMWARE_KIND[0], '', fw_version, None))
reply = _base.request(self.handle, 0xFF, b'\x81\xF1', b'\x04')
if reply and reply[0:1] == b'\x04':
bl_version = _hex(reply[1:3])
bl_version = '%s.%s' % (bl_version[0:2], bl_version[2:4])
firmware.append(_FirmwareInfo(1, FIRMWARE_KIND[1], '', bl_version, None))
self._firmware = tuple(firmware)
return self._firmware
def __iter__(self):
if self.handle == 0:
return
for number in range(1, 1 + MAX_ATTACHED_DEVICES):
dev = get_device(self.handle, number)
if dev is not None:
yield dev
def __getitem__(self, key):
if type(key) != int:
raise TypeError('key must be an integer')
if self.handle == 0 or key < 0 or key > MAX_ATTACHED_DEVICES:
raise IndexError(key)
return get_device(self.handle, key) if key > 0 else None
def __delitem__(self, key):
if type(key) != int:
raise TypeError('key must be an integer')
if self.handle == 0 or key < 0 or key > MAX_ATTACHED_DEVICES:
raise IndexError(key)
if key > 0:
_log.debug("unpairing device %d", key)
reply = _base.request(self.handle, 0xFF, b'\x80\xB2', _pack('!BB', 0x03, key))
if reply is None or reply[1:2] == b'\x8F':
raise IndexError(key)
def __len__(self):
if self.handle == 0:
return 0
# not really sure about this one...
count = _base.request(self.handle, 0xFF, b'\x81\x00')
return 0 if count is None else ord(count[1:2])
def __contains__(self, dev):
if self.handle == 0:
return False
if type(dev) == int:
return dev > 0 and dev <= MAX_ATTACHED_DEVICES and _base.ping(self.handle, dev) is not None
return dev.ping()
def __str__(self):
return '<Receiver(%X,%s)>' % (self.handle, self.path)
def __hash__(self):
return self.handle
__bool__ = __nonzero__ = lambda self: self.handle != 0
@classmethod
def open(self):
"""Opens the first Logitech Unifying Receiver found attached to the machine.
:returns: An open file handle for the found receiver, or ``None``.
"""
exception = None
for rawdevice in _base.list_receiver_devices():
exception = None
try:
handle = _base.try_open(rawdevice.path)
if handle:
return Receiver(handle, rawdevice.path)
except OSError as e:
_log.exception("open %s", rawdevice.path)
if e.errno == _errno.EACCES:
exception = e
if exception:
# only keep the last exception
raise exception
#
#
#
def request(handle, devnumber, feature, function=b'\x00', params=b'', features=None):
"""Makes a feature call to the device, and returns the reply data.
Basically a write() followed by (possibly multiple) reads, until a reply
matching the called feature is received. In theory the UR will always reply
to feature call; otherwise this function will wait indefinitely.
Incoming data packets not matching the feature and function will be
delivered to the unhandled hook (if any), and ignored.
:param function: the function to call on that feature, may be an byte value
or a bytes string of length 1.
:param params: optional bytes string to send as function parameters to the
feature; may also be an integer if the function only takes a single byte as
parameter.
The optional ``features`` parameter is a cached result of the
get_device_features function for this device, necessary to find the feature
index. If the ``features_arrary`` is not provided, one will be obtained by
manually calling get_device_features before making the request call proper.
:raises FeatureNotSupported: if the device does not support the feature.
"""
feature_index = None
if feature == FEATURE.ROOT:
feature_index = b'\x00'
else:
feature_index = _get_feature_index(handle, devnumber, feature, features)
if feature_index is None:
# i/o read error
return None
feature_index = _pack('!B', feature_index)
if type(function) == int:
function = _pack('!B', function)
if type(params) == int:
params = _pack('!B', params)
return _base.request(handle, devnumber, feature_index + function, params)
def get_device(handle, devnumber, features=None):
"""Gets the complete info for a device (type, features).
:returns: a PairedDevice or ``None``.
"""
if _base.ping(handle, devnumber):
devinfo = PairedDevice(handle, devnumber)
# _log.debug("found device %s", devinfo)
return devinfo
def get_feature_index(handle, devnumber, feature):
"""Reads the index of a device's feature.
:returns: An int, or ``None`` if the feature is not available.
"""
# _log.debug("device %d get feature index <%s:%s>", devnumber, _hex(feature), FEATURE_NAME[feature])
if len(feature) != 2:
raise ValueError("invalid feature <%s>: it must be a two-byte string" % feature)
# FEATURE.ROOT should always be available for any attached devices
reply = _base.request(handle, devnumber, FEATURE.ROOT, feature)
if reply:
feature_index = ord(reply[0:1])
if feature_index:
# feature_flags = ord(reply[1:2]) & 0xE0
# if feature_flags:
# _log.debug("device %d feature <%s:%s> has index %d: %s",
# devnumber, _hex(feature), FEATURE_NAME[feature], feature_index,
# ','.join([FEATURE_FLAGS[k] for k in FEATURE_FLAGS if feature_flags & k]))
# else:
# _log.debug("device %d feature <%s:%s> has index %d", devnumber, _hex(feature), FEATURE_NAME[feature], feature_index)
# only consider active and supported features?
# if feature_flags:
# raise E.FeatureNotSupported(devnumber, feature)
return feature_index
_log.warn("device %d feature <%s:%s> not supported by the device", devnumber, _hex(feature), FEATURE_NAME[feature])
raise _FeatureNotSupported(devnumber, feature)
def _get_feature_index(handle, devnumber, feature, features=None):
if features is None:
return get_feature_index(handle, devnumber, feature)
if feature in features:
return features.index(feature)
index = get_feature_index(handle, devnumber, feature)
if index is not None:
if len(features) <= index:
features += [None] * (index + 1 - len(features))
features[index] = feature
# _log.debug("%s: found feature %s at %d", features, _base._hex(feature), index)
return index
def get_device_features(handle, devnumber):
"""Returns an array of feature ids.
Their position in the array is the index to be used when requesting that
feature on the device.
"""
# _log.debug("device %d get device features", devnumber)
# get the index of the FEATURE_SET
# FEATURE.ROOT should always be available for all devices
fs_index = _base.request(handle, devnumber, FEATURE.ROOT, FEATURE.FEATURE_SET)
if fs_index is None:
_log.warn("device %d FEATURE_SET not available", devnumber)
return None
fs_index = fs_index[:1]
# For debugging purposes, query all the available features on the device,
# even if unknown.
# get the number of active features the device has
features_count = _base.request(handle, devnumber, fs_index + b'\x00')
if not features_count:
# this can happen if the device disappeard since the fs_index request
# otherwise we should get at least a count of 1 (the FEATURE_SET we've just used above)
_log.debug("device %d no features available?!", devnumber)
return None
features_count = ord(features_count[:1])
# _log.debug("device %d found %d features", devnumber, features_count)
features = [None] * 0x20
for index in range(1, 1 + features_count):
# for each index, get the feature residing at that index
feature = _base.request(handle, devnumber, fs_index + b'\x10', _pack('!B', index))
if feature:
# feature_flags = ord(feature[2:3]) & 0xE0
feature = feature[0:2].upper()
features[index] = feature
# if feature_flags:
# _log.debug("device %d feature <%s:%s> at index %d: %s",
# devnumber, _hex(feature), FEATURE_NAME[feature], index,
# ','.join([FEATURE_FLAGS[k] for k in FEATURE_FLAGS if feature_flags & k]))
# else:
# _log.debug("device %d feature <%s:%s> at index %d", devnumber, _hex(feature), FEATURE_NAME[feature], index)
features[0] = FEATURE.ROOT
while features[-1] is None:
del features[-1]
return tuple(features)
def get_device_firmware(handle, devnumber, features=None):
"""Reads a device's firmware info.
:returns: a list of FirmwareInfo tuples, ordered by firmware layer.
"""
fw_fi = _get_feature_index(handle, devnumber, FEATURE.FIRMWARE, features)
if fw_fi is None:
return None
fw_count = _base.request(handle, devnumber, _pack('!BB', fw_fi, 0x00))
if fw_count:
fw_count = ord(fw_count[:1])
fw = []
for index in range(0, fw_count):
fw_info = _base.request(handle, devnumber, _pack('!BB', fw_fi, 0x10), params=index)
if fw_info:
level = ord(fw_info[:1]) & 0x0F
if level == 0 or level == 1:
kind = FIRMWARE_KIND[level]
name, = _unpack('!3s', fw_info[1:4])
name = name.decode('ascii')
version = _hex(fw_info[4:6])
version = '%s.%s' % (version[0:2], version[2:4])
build, = _unpack('!H', fw_info[6:8])
if build:
version += ' b%d' % build
extras = fw_info[9:].rstrip(b'\x00') or None
fw_info = _FirmwareInfo(level, kind, name, version, extras)
elif level == 2:
fw_info = _FirmwareInfo(2, FIRMWARE_KIND[2], '', ord(fw_info[1:2]), None)
else:
fw_info = _FirmwareInfo(level, FIRMWARE_KIND[-1], '', '', None)
fw.append(fw_info)
# _log.debug("device %d firmware %s", devnumber, fw_info)
return tuple(fw)
def get_device_kind(handle, devnumber, features=None):
"""Reads a device's type.
:see DEVICE_KIND:
:returns: a string describing the device type, or ``None`` if the device is
not available or does not support the ``NAME`` feature.
"""
name_fi = _get_feature_index(handle, devnumber, FEATURE.NAME, features)
if name_fi is None:
return None
d_kind = _base.request(handle, devnumber, _pack('!BB', name_fi, 0x20))
if d_kind:
d_kind = ord(d_kind[:1])
# _log.debug("device %d type %d = %s", devnumber, d_kind, DEVICE_KIND[d_kind])
return DEVICE_KIND[d_kind]
def get_device_name(handle, devnumber, features=None):
"""Reads a device's name.
:returns: a string with the device name, or ``None`` if the device is not
available or does not support the ``NAME`` feature.
"""
name_fi = _get_feature_index(handle, devnumber, FEATURE.NAME, features)
if name_fi is None:
return None
name_length = _base.request(handle, devnumber, _pack('!BB', name_fi, 0x00))
if name_length:
name_length = ord(name_length[:1])
d_name = b''
while len(d_name) < name_length:
name_fragment = _base.request(handle, devnumber, _pack('!BB', name_fi, 0x10), len(d_name))
if name_fragment:
name_fragment = name_fragment[:name_length - len(d_name)]
d_name += name_fragment
else:
break
d_name = d_name.decode('ascii')
# _log.debug("device %d name %s", devnumber, d_name)
return d_name
def get_device_battery_level(handle, devnumber, features=None):
"""Reads a device's battery level.
:raises FeatureNotSupported: if the device does not support this feature.
"""
bat_fi = _get_feature_index(handle, devnumber, FEATURE.BATTERY, features)
if bat_fi is not None:
battery = _base.request(handle, devnumber, _pack('!BB', bat_fi, 0))
if battery:
discharge, dischargeNext, status = _unpack('!BBB', battery[:3])
_log.debug("device %d battery %d%% charged, next level %d%% charge, status %d = %s",
devnumber, discharge, dischargeNext, status, BATTERY_STATUS[status])
return (discharge, dischargeNext, BATTERY_STATUS[status])
def get_device_keys(handle, devnumber, features=None):
rk_fi = _get_feature_index(handle, devnumber, FEATURE.REPROGRAMMABLE_KEYS, features)
if rk_fi is None:
return None
count = _base.request(handle, devnumber, _pack('!BB', rk_fi, 0))
if count:
keys = []
count = ord(count[:1])
for index in range(0, count):
keydata = _base.request(handle, devnumber, _pack('!BB', rk_fi, 0x10), index)
if keydata:
key, key_task, flags = _unpack('!HHB', keydata[:5])
rki = _ReprogrammableKeyInfo(index, key, KEY_NAME[key], key_task, KEY_NAME[key_task], flags)
keys.append(rki)
return keys

View File

@@ -3,87 +3,77 @@
# Unlikely to be used directly unless you're expanding the API.
#
from __future__ import absolute_import, division, print_function, unicode_literals
from time import time as _timestamp
from struct import pack as _pack
from struct import unpack as _unpack
from binascii import hexlify as _hexlify
_hex = lambda d: _hexlify(d).decode('ascii').upper()
from random import getrandbits as _random_bits
from .constants import ERROR_NAME
from .exceptions import (NoReceiver as _NoReceiver,
FeatureCallError as _FeatureCallError)
from logging import getLogger
_log = getLogger('LUR').getChild('base')
from logging import getLogger, DEBUG as _DEBUG
_log = getLogger('LUR.base')
del getLogger
from .common import strhex as _strhex, KwException as _KwException
from . import hidpp10 as _hidpp10
from . import hidpp20 as _hidpp20
import hidapi as _hid
#
# These values are defined by the Logitech documentation.
# Overstepping these boundaries will only produce log warnings.
#
#
"""Minimim lenght of a feature call packet."""
_MIN_CALL_SIZE = 7
"""Maximum lenght of a feature call packet."""
_MAX_CALL_SIZE = 20
"""Minimum size of a feature reply packet."""
_MIN_REPLY_SIZE = _MIN_CALL_SIZE
"""Maximum size of a feature reply packet."""
_MAX_REPLY_SIZE = _MAX_CALL_SIZE
_SHORT_MESSAGE_SIZE = 7
_LONG_MESSAGE_SIZE = 20
_MEDIUM_MESSAGE_SIZE = 15
_MAX_READ_SIZE = 32
"""Default timeout on read (in ms)."""
DEFAULT_TIMEOUT = 1500
DEFAULT_TIMEOUT = 3000
_RECEIVER_REQUEST_TIMEOUT = 500
_DEVICE_REQUEST_TIMEOUT = DEFAULT_TIMEOUT
_PING_TIMEOUT = 5000
#
# Exceptions that may be raised by this API.
#
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
unloaded."""
pass
class NoSuchDevice(_KwException):
"""Raised when trying to reach a device number not paired to the receiver."""
pass
class DeviceUnreachable(_KwException):
"""Raised when a request is made to an unreachable (turned off) device."""
pass
#
#
#
def _logdebug_hook(reply_code, devnumber, data):
"""Default unhandled hook, logs the reply as DEBUG."""
_log.warn("UNHANDLED [%02X %02X %s %s] (%s)", reply_code, devnumber, _hex(data[:2]), _hex(data[2:]), repr(data))
"""The function that will be called on unhandled incoming events.
The hook must be a function with the signature: ``_(int, int, str)``, where
the parameters are: (reply_code, devnumber, data).
This hook will only be called by the request() function, when it receives
replies that do not match the requested feature call. As such, it is not
suitable for intercepting broadcast events from the device (e.g. special
keys being pressed, battery charge events, etc), at least not in a timely
manner. However, these events *may* be delivered here if they happen while
doing a feature call to the device.
The default implementation logs the unhandled reply as DEBUG.
"""
unhandled_hook = _logdebug_hook
#
#
#
def list_receiver_devices():
def receivers():
"""List all the Linux devices exposed by the UR attached to the machine."""
# (Vendor ID, Product ID) = ('Logitech', 'Unifying Receiver')
# interface 2 if the actual receiver interface
for d in _hid.enumerate(0x046d, 0xc52b, 2):
if d.driver is None or d.driver == 'logitech-djreceiver':
if d.driver == 'logitech-djreceiver':
yield d
# apparently there are TWO product ids possible for the UR?
for d in _hid.enumerate(0x046d, 0xc532, 2):
if d.driver == 'logitech-djreceiver':
yield d
_COUNT_DEVICES_REQUEST = b'\x10\xFF\x81\x00\x00\x00\x00'
def try_open(path):
def open_path(path):
"""Checks if the given Linux device path points to the right UR device.
:param path: the Linux device path.
@@ -96,28 +86,7 @@ def try_open(path):
:returns: an open receiver handle if this is the right Linux device, or
``None``.
"""
receiver_handle = _hid.open_path(path)
if receiver_handle is None:
# could be a file permissions issue (did you add the udev rules?)
# in any case, unreachable
_log.debug("[%s] open failed", path)
return None
_hid.write(receiver_handle, _COUNT_DEVICES_REQUEST)
# if this is the right hidraw device, we'll receive a 'bad device' from the UR
# otherwise, the read should produce nothing
reply = _hid.read(receiver_handle, _MAX_REPLY_SIZE, DEFAULT_TIMEOUT / 2)
if reply:
if reply[:5] == _COUNT_DEVICES_REQUEST[:5]:
# 'device 0 unreachable' is the expected reply from a valid receiver handle
_log.info("[%s] success: handle %X", path, receiver_handle)
return receiver_handle
_log.debug("[%s] %X ignored reply %s", path, receiver_handle, _hex(reply))
else:
_log.debug("[%s] %X no reply", path, receiver_handle)
close(receiver_handle)
return _hid.open_path(path)
def open():
@@ -125,9 +94,8 @@ def open():
:returns: An open file handle for the found receiver, or ``None``.
"""
for rawdevice in list_receiver_devices():
_log.info("checking %s", rawdevice)
handle = try_open(rawdevice.path)
for rawdevice in receivers():
handle = open_path(rawdevice.path)
if handle:
return handle
@@ -136,169 +104,249 @@ def close(handle):
"""Closes a HID device handle."""
if handle:
try:
_hid.close(handle)
# _log.info("closed receiver handle %X", handle)
if type(handle) == int:
_hid.close(handle)
else:
handle.close()
# _log.info("closed receiver handle %r", handle)
return True
except:
_log.exception("closing receiver handle %X", handle)
# _log.exception("closing receiver handle %r", handle)
pass
return False
def write(handle, devnumber, data):
"""Writes some data to a certain device.
"""Writes some data to the receiver, addressed to a certain device.
:param handle: an open UR handle.
:param devnumber: attached device number.
:param data: data to send, up to 5 bytes.
The first two (required) bytes of data must be the feature index for the
device, and a function code for that feature.
The first two (required) bytes of data must be the SubId and address.
:raises NoReceiver: if the receiver is no longer available, i.e. has
been physically removed from the machine, or the kernel driver has been
unloaded. The handle will be closed automatically.
"""
assert _MIN_CALL_SIZE == 7
assert _MAX_CALL_SIZE == 20
# the data is padded to either 5 or 18 bytes
wdata = _pack('!BB18s' if len(data) > 5 else '!BB5s', 0x10, devnumber, data)
_log.debug("<= w[10 %02X %s %s]", devnumber, _hex(wdata[2:4]), _hex(wdata[4:]))
if not _hid.write(handle, wdata):
_log.warn("write failed, assuming receiver %X no longer available", handle)
if len(data) > _SHORT_MESSAGE_SIZE - 2 or data[:1] == b'\x82':
wdata = _pack(b'!BB18s', 0x11, devnumber, data)
else:
wdata = _pack(b'!BB5s', 0x10, devnumber, data)
if _log.isEnabledFor(_DEBUG):
_log.debug("(%s) <= w[%02X %02X %s %s]", handle, ord(wdata[:1]), devnumber, _strhex(wdata[2:4]), _strhex(wdata[4:]))
try:
_hid.write(int(handle), wdata)
except Exception as reason:
_log.error("write failed, assuming handle %r no longer available", handle)
close(handle)
raise _NoReceiver
raise NoReceiver(reason=reason)
def read(handle, timeout=DEFAULT_TIMEOUT):
"""Read some data from the receiver. Usually called after a write (feature
call), to get the reply.
:param handle: an open UR handle.
:param timeout: read timeout on the UR handle.
If any data was read in the given timeout, returns a tuple of
(reply_code, devnumber, message data). The reply code is generally ``0x11``
for a successful feature call, or ``0x10`` to indicate some error, e.g. the
device is no longer available.
:returns: a tuple of (devnumber, message data), or `None`
:raises NoReceiver: if the receiver is no longer available, i.e. has
been physically removed from the machine, or the kernel driver has been
unloaded. The handle will be closed automatically.
"""
data = _hid.read(handle, _MAX_REPLY_SIZE, timeout)
if data is None:
_log.warn("read failed, assuming receiver %X no longer available", handle)
reply = _read(handle, timeout)
if reply:
return reply[1:]
def _read(handle, timeout):
"""Read an incoming packet from the receiver.
:returns: a tuple of (report_id, devnumber, data), or `None`.
:raises NoReceiver: if the receiver is no longer available, i.e. has
been physically removed from the machine, or the kernel driver has been
unloaded. The handle will be closed automatically.
"""
try:
data = _hid.read(int(handle), _MAX_READ_SIZE, timeout)
except Exception as reason:
_log.error("read failed, assuming handle %r no longer available", handle)
close(handle)
raise _NoReceiver
raise NoReceiver(reason=reason)
if data:
if len(data) < _MIN_REPLY_SIZE:
_log.warn("=> r[%s] read packet too short: %d bytes", _hex(data), len(data))
data += b'\x00' * (_MIN_REPLY_SIZE - len(data))
if len(data) > _MAX_REPLY_SIZE:
_log.warn("=> r[%s] read packet too long: %d bytes", _hex(data), len(data))
code = ord(data[:1])
report_id = ord(data[:1])
assert (report_id == 0x10 and len(data) == _SHORT_MESSAGE_SIZE or
report_id == 0x11 and len(data) == _LONG_MESSAGE_SIZE or
report_id == 0x20 and len(data) == _MEDIUM_MESSAGE_SIZE)
devnumber = ord(data[1:2])
_log.debug("=> r[%02X %02X %s %s]", code, devnumber, _hex(data[2:4]), _hex(data[4:]))
return code, devnumber, data[2:]
# _l.log(_LOG_LEVEL, "(-) => r[]")
if _log.isEnabledFor(_DEBUG):
_log.debug("(%s) => r[%02X %02X %s %s]", handle, report_id, devnumber, _strhex(data[2:4]), _strhex(data[4:]))
return report_id, devnumber, data[2:]
_MAX_READ_TIMES = 3
request_context = None
def _skip_incoming(handle):
"""Read anything already in the input buffer.
Used by request() and ping() before their write.
"""
ihandle = int(handle)
while True:
try:
data = _hid.read(ihandle, _MAX_READ_SIZE, 0)
except Exception as reason:
_log.error("read failed, assuming receiver %s no longer available", handle)
close(handle)
raise NoReceiver(reason=reason)
if data:
report_id = ord(data[:1])
assert (report_id == 0x10 and len(data) == _SHORT_MESSAGE_SIZE or
report_id == 0x11 and len(data) == _LONG_MESSAGE_SIZE or
report_id == 0x20 and len(data) == _MEDIUM_MESSAGE_SIZE)
_unhandled(report_id, ord(data[1:2]), data[2:])
else:
return
#
#
#
"""The function that may be called on incoming notifications.
The hook must be a callable accepting one tuple parameter, with the format
``(<int> devnumber, <bytes[2]> request_id, <bytes> data)``.
This hook will only be called by the request()/ping() functions, when received
replies do not match the expected request_id. As such, it is not suitable for
intercepting broadcast notifications from the device (e.g. special keys being
pressed, battery charge notifications, etc), at least not in a timely manner.
"""
notifications_hook = None
def _unhandled(report_id, devnumber, data):
"""Deliver a possible notification to the notifications_hook (if any)."""
if notifications_hook:
n = make_notification(devnumber, data)
if n:
notifications_hook(n)
def make_notification(devnumber, data):
"""Guess if this is a notification (and not just a request reply), and
return a Notification tuple if it is."""
sub_id = ord(data[:1])
if sub_id & 0x80 != 0x80:
# HID++ 1.0 standard notifications are 0x40 - 0x7F
# HID++ 2.0 feature notifications have the SoftwareID 0
address = ord(data[1:2])
if sub_id >= 0x40 or address & 0x0F == 0x00:
return _HIDPP_Notification(devnumber, sub_id, address, data[2:])
from collections import namedtuple
_DEFAULT_REQUEST_CONTEXT_CLASS = namedtuple('_DEFAULT_REQUEST_CONTEXT_CLASS', ['write', 'read', 'unhandled_hook'])
_DEFAULT_REQUEST_CONTEXT = _DEFAULT_REQUEST_CONTEXT_CLASS(write=write, read=read, unhandled_hook=unhandled_hook)
_HIDPP_Notification = namedtuple('_HIDPP_Notification', ['devnumber', 'sub_id', 'address', 'data'])
_HIDPP_Notification.__str__ = lambda self: 'Notification(%d,%02X,%02X,%s)' % (self.devnumber, self.sub_id, self.address, _strhex(self.data))
_HIDPP_Notification.__unicode__ = _HIDPP_Notification.__str__
del namedtuple
def request(handle, devnumber, feature_index_function, params=b'', features=None):
def request(handle, devnumber, request_id, *params):
"""Makes a feature call to a device and waits for a matching reply.
This function will skip all incoming messages and events not related to the
device we're requesting for, or the feature specified in the initial
request; it will also wait for a matching reply indefinitely.
This function will wait for a matching reply indefinitely.
:param handle: an open UR handle.
:param devnumber: attached device number.
:param feature_index_function: a two-byte string of (feature_index, feature_function).
:param request_id: a 16-bit integer.
:param params: parameters for the feature call, 3 to 16 bytes.
:param features: optional features array for the device, only used to fill
the FeatureCallError exception if one occurs.
:returns: the reply data packet, or ``None`` if the device is no longer
available.
:raisees FeatureCallError: if the feature call replied with an error.
:returns: the reply data, or ``None`` if some error occured.
"""
if type(params) == int:
params = _pack('!B', params)
# _log.debug("device %d request {%s} params [%s]", devnumber, _hex(feature_index_function), _hex(params))
if len(feature_index_function) != 2:
raise ValueError('invalid feature_index_function {%s}: it must be a two-byte string' % _hex(feature_index_function))
# import inspect as _inspect
# print ('\n '.join(str(s) for s in _inspect.stack()))
if request_context is None or handle != request_context.handle:
context = _DEFAULT_REQUEST_CONTEXT
_unhandled = unhandled_hook
assert isinstance(request_id, int)
if devnumber != 0xFF and request_id < 0x8000:
timeout = _DEVICE_REQUEST_TIMEOUT
# 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
request_id = (request_id & 0xFFF0) | 0x08 | _random_bits(3)
else:
context = request_context
_unhandled = getattr(context, 'unhandled_hook')
timeout = _RECEIVER_REQUEST_TIMEOUT
context.write(handle, devnumber, feature_index_function + params)
params = b''.join(_pack(b'B', p) if isinstance(p, int) else p for p in params)
# if _log.isEnabledFor(_DEBUG):
# _log.debug("(%s) device %d request_id {%04X} params [%s]", handle, devnumber, request_id, _strhex(params))
request_data = _pack(b'!H', request_id) + params
read_times = _MAX_READ_TIMES
while read_times > 0:
divisor = (1 + _MAX_READ_TIMES - read_times)
reply = context.read(handle, int(DEFAULT_TIMEOUT * (divisor + 1) / 2 / divisor))
read_times -= 1
_skip_incoming(handle)
ihandle = int(handle)
write(ihandle, devnumber, request_data)
if not reply:
# keep waiting...
continue
while True:
now = _timestamp()
reply = _read(handle, timeout)
delta = _timestamp() - now
reply_code, reply_devnumber, reply_data = reply
if reply:
report_id, reply_devnumber, reply_data = reply
if reply_devnumber == devnumber:
if report_id == 0x10 and reply_data[:1] == b'\x8F' and reply_data[1:3] == request_data[:2]:
error = ord(reply_data[3:4])
if reply_devnumber != devnumber:
# this message not for the device we're interested in
# _l.log(_LOG_LEVEL, "device %d request got reply for unexpected device %d: [%s]", devnumber, reply_devnumber, _hex(reply_data))
# worst case scenario, this is a reply for a concurrent request
# on this receiver
if _unhandled:
_unhandled(reply_code, reply_devnumber, reply_data)
continue
# if error == _hidpp10.ERROR.resource_error: # device unreachable
# _log.warn("(%s) device %d error on request {%04X}: unknown device", handle, devnumber, request_id)
# raise DeviceUnreachable(number=devnumber, request=request_id)
if reply_code == 0x10 and reply_data[:1] == b'\x8F' and reply_data[1:3] == feature_index_function:
# device not present
_log.debug("device %d request failed on {%s} call: [%s]", devnumber, _hex(feature_index_function), _hex(reply_data))
return None
# if error == _hidpp10.ERROR.unknown_device: # unknown device
# _log.error("(%s) device %d error on request {%04X}: unknown device", handle, devnumber, request_id)
# raise NoSuchDevice(number=devnumber, request=request_id)
if reply_code == 0x10 and reply_data[:1] == b'\x8F':
# device not present
_log.debug("device %d request failed: [%s]", devnumber, _hex(reply_data))
return None
_log.debug("(%s) device 0x%02X error on request {%04X}: %d = %s",
handle, devnumber, request_id, error, _hidpp10.ERROR[error])
break
if reply_code == 0x11 and reply_data[0] == b'\xFF' and reply_data[1:3] == feature_index_function:
# the feature call returned with an error
error_code = ord(reply_data[3])
_log.warn("device %d request feature call error %d = %s: %s", devnumber, error_code, ERROR_NAME[error_code], _hex(reply_data))
feature_index = ord(feature_index_function[:1])
feature_function = feature_index_function[1:2]
feature = None if features is None else features[feature_index] if feature_index < len(features) else None
raise _FeatureCallError(devnumber, feature, feature_index, feature_function, error_code, reply_data)
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])
_log.error("(%s) device %d error on feature request {%04X}: %d = %s",
handle, devnumber, request_id, error, _hidpp20.ERROR[error])
raise _hidpp20.FeatureCallError(number=devnumber, request=request_id, error=error, params=params)
if reply_code == 0x11 and reply_data[:2] == feature_index_function:
# a matching reply
# _log.debug("device %d matched reply with feature-index-function [%s]", devnumber, _hex(reply_data[2:]))
return reply_data[2:]
if reply_data[:2] == request_data[:2]:
if request_id & 0xFF00 == 0x8300:
# long registry r/w should return a long reply
assert report_id == 0x11
elif request_id & 0xF000 == 0x8000:
# short registry r/w should return a short reply
assert report_id == 0x10
if reply_code == 0x10 and devnumber == 0xFF and reply_data[:2] == feature_index_function:
# direct calls to the receiver (device 0xFF) may also return successfully with reply code 0x10
# _log.debug("device %d matched reply with feature-index-function [%s]", devnumber, _hex(reply_data[2:]))
return reply_data[2:]
if devnumber == 0xFF:
if request_id == 0x83B5 or request_id == 0x81F1:
# these replies have to match the first parameter as well
if reply_data[2:3] == params[:1]:
return reply_data[2:]
else:
# hm, not mathing my request, and certainly not a notification
continue
else:
return reply_data[2:]
else:
return reply_data[2:]
# _log.debug("device %d unmatched reply {%s} (expected {%s})", devnumber, _hex(reply_data[:2]), _hex(feature_index_function))
if _unhandled:
_unhandled(reply_code, reply_devnumber, reply_data)
_unhandled(report_id, reply_devnumber, reply_data)
if delta >= timeout:
_log.warn("timeout on device %d request {%04X} params[%s]", devnumber, request_id, _strhex(params))
break
# raise DeviceUnreachable(number=devnumber, request=request_id)
def ping(handle, devnumber):
@@ -306,43 +354,51 @@ def ping(handle, devnumber):
:returns: The HID protocol supported by the device, as a floating point number, if the device is active.
"""
if request_context is None or handle != request_context.handle:
context = _DEFAULT_REQUEST_CONTEXT
_unhandled = unhandled_hook
else:
context = request_context
_unhandled = getattr(context, 'unhandled_hook')
if _log.isEnabledFor(_DEBUG):
_log.debug("(%s) pinging device %d", handle, devnumber)
context.write(handle, devnumber, b'\x00\x10\x00\x00\xAA')
read_times = _MAX_READ_TIMES
while read_times > 0:
divisor = (1 + _MAX_READ_TIMES - read_times)
reply = context.read(handle, int(DEFAULT_TIMEOUT * (divisor + 1) / 2 / divisor))
read_times -= 1
# import inspect as _inspect
# print ('\n '.join(str(s) for s in _inspect.stack()))
if not reply:
# keep waiting...
continue
# 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(b'!HBBB', request_id, 0, 0, _random_bits(8))
reply_code, reply_devnumber, reply_data = reply
_skip_incoming(handle)
ihandle = int(handle)
write(ihandle, devnumber, request_data)
if reply_devnumber != devnumber:
# this message not for the device we're interested in
# _l.log(_LOG_LEVEL, "device %d request got reply for unexpected device %d: [%s]", devnumber, reply_devnumber, _hex(reply_data))
# worst case scenario, this is a reply for a concurrent request
# on this receiver
if _unhandled:
_unhandled(reply_code, reply_devnumber, reply_data)
continue
while True:
now = _timestamp()
reply = _read(ihandle, _PING_TIMEOUT)
delta = _timestamp() - now
if reply_code == 0x11 and reply_data[:2] == b'\x00\x10' and reply_data[4:5] == b'\xAA':
major, minor = _unpack('!BB', reply_data[2:4])
return major + minor / 10.0
if reply:
report_id, number, data = reply
if number == devnumber:
if data[:2] == request_data[:2] and data[4:5] == request_data[-1:]:
# HID++ 2.0+ device, currently connected
return ord(data[2:3]) + ord(data[3:4]) / 10.0
if reply_code == 0x10 and reply_data == b'\x8F\x00\x10\x01\x00':
return 1.0
if report_id == 0x10 and data[:1] == b'\x8F' and data[1:3] == request_data[:2]:
assert data[-1:] == b'\x00'
error = ord(data[3:4])
if reply_code == 0x10 and reply_data[:3] == b'\x8F\x00\x10':
return None
if error == _hidpp10.ERROR.invalid_SubID__command: # a valid reply from a HID++ 1.0 device
return 1.0
_log.warn("don't know how to interpret ping reply %s", reply)
if error == _hidpp10.ERROR.resource_error: # device unreachable
# raise DeviceUnreachable(number=devnumber, request=request_id)
break
if error == _hidpp10.ERROR.unknown_device: # no paired device with that number
_log.error("(%s) device %d error on ping request: unknown device", handle, devnumber)
raise NoSuchDevice(number=devnumber, request=request_id)
_unhandled(report_id, number, data)
if delta >= _PING_TIMEOUT:
_log.warn("(%s) timeout on device %d ping", handle, devnumber)
# raise DeviceUnreachable(number=devnumber, request=request_id)

View File

@@ -2,30 +2,192 @@
# Some common functions and types.
#
from collections import namedtuple
from __future__ import absolute_import, division, print_function, unicode_literals
from binascii import hexlify as _hexlify
_hex = lambda d: _hexlify(d).decode('ascii').upper()
from struct import pack as _pack
class FallbackDict(dict):
def __init__(self, fallback_function=lambda x: None, *args, **kwargs):
super(FallbackDict, self).__init__(*args, **kwargs)
self.fallback = fallback_function
class NamedInt(int):
"""An reqular Python integer with an attached name.
def __getitem__(self, key):
Caution: comparison with strings will also match this NamedInt's name
(case-insensitive)."""
def __new__(cls, value, name):
assert isinstance(name, str) or isinstance(name, unicode)
obj = int.__new__(cls, value)
obj.name = str(name)
return obj
def bytes(self, count=2):
if self.bit_length() > count * 8:
raise ValueError('cannot fit %X into %d bytes' % (self, count))
return _pack(b'!L', self)[-count:]
def __eq__(self, other):
if isinstance(other, NamedInt):
return int(self) == int(other) and self.name == other.name
if isinstance(other, int):
return int(self) == int(other)
if isinstance(other, str) or isinstance(other, unicode):
return self.name.lower() == other.lower()
def __ne__(self, other):
return not self.__eq__(other)
def __hash__(self):
return int(self)
def __str__(self):
return self.name
__unicode__ = __str__
def __repr__(self):
return 'NamedInt(%d, %r)' % (int(self), self.name)
class NamedInts(object):
"""An ordered set of NamedInt values.
Indexing can be made by int or string, and will return the corresponding
NamedInt if it exists in this set, or `None`.
Extracting slices will return all present NamedInts in the given interval
(extended slices are not supported).
Assigning a string to an indexed int will create a new NamedInt in this set;
if the value already exists in the set (int or string), ValueError will be
raised.
"""
__slots__ = ['__dict__', '_values', '_indexed', '_fallback']
def __init__(self, **kwargs):
def _readable_name(n):
if not isinstance(n, str) and not isinstance(n, unicode):
raise TypeError("expected string, got " + type(n))
n = n.replace('__', '/').replace('_', ' ')
return str(n)
values = {k: NamedInt(v, _readable_name(k)) for (k, v) in kwargs.items()}
self.__dict__ = values
self._values = sorted(list(values.values()))
self._indexed = {int(v): v for v in self._values}
self._fallback = None
# print ('%r' % self)
@classmethod
def range(cls, from_value, to_value, name_generator=lambda x: str(x), step=1):
values = {name_generator(x): x for x in range(from_value, to_value + 1, step)}
return NamedInts(**values)
def flag_names(self, value):
unknown_bits = value
for k in self._indexed:
assert bin(k).count('1') == 1
if k & value == k:
unknown_bits &= ~k
yield str(self._indexed[k])
if unknown_bits:
yield 'unknown:%06X' % unknown_bits
def __getitem__(self, index):
if isinstance(index, int):
if index in self._indexed:
return self._indexed[int(index)]
if self._fallback and type(index) == int:
value = NamedInt(index, self._fallback(index))
self._indexed[index] = value
self._values = sorted(self._values + [value])
return value
elif isinstance(index, str) or isinstance(index, unicode):
if index in self.__dict__:
return self.__dict__[index]
elif isinstance(index, slice):
if index.start is None and index.stop is None:
return self._values[:]
v_start = int(self._values[0]) if index.start is None else int(index.start)
v_stop = (self._values[-1] + 1) if index.stop is None else int(index.stop)
if v_start > v_stop or v_start > self._values[-1] or v_stop <= self._values[0]:
return []
if v_start <= self._values[0] and v_stop > self._values[-1]:
return self._values[:]
start_index = 0
stop_index = len(self._values)
for i, value in enumerate(self._values):
if value < v_start:
start_index = i + 1
elif index.stop is None:
break
if value >= v_stop:
stop_index = i
break
return self._values[start_index:stop_index]
def __setitem__(self, index, name):
assert isinstance(index, int), type(index)
if isinstance(name, NamedInt):
assert int(index) == int(name), repr(index) + ' ' + repr(name)
value = name
elif isinstance(name, str) or isinstance(name, unicode):
value = NamedInt(index, name)
else:
raise TypeError('name must be a string')
if str(value) in self.__dict__:
raise ValueError('%s (%d) already known' % (value, int(value)))
if int(value) in self._indexed:
raise ValueError('%d (%s) already known' % (int(value), value))
self._values = sorted(self._values + [value])
self.__dict__[str(value)] = value
self._indexed[int(value)] = value
def __contains__(self, value):
if isinstance(value, int):
return value in self._indexed
if isinstance(value, str) or isinstance(value, unicode):
return value in self.__dict__
def __iter__(self):
for v in self._values:
yield v
def __len__(self):
return len(self._values)
def __repr__(self):
return 'NamedInts(%s)' % ', '.join(repr(v) for v in self._values)
def strhex(x):
return _hexlify(x).decode('ascii').upper()
class KwException(Exception):
def __init__(self, **kwargs):
super(KwException, self).__init__(kwargs)
def __getattr__(self, k):
try:
return super(FallbackDict, self).__getitem__(key)
except KeyError:
return self.fallback(key)
return super(KwException, self).__getattr__(k)
except AttributeError:
return self.args[0][k]
def list2dict(values_list):
return dict(zip(range(0, len(values_list)), values_list))
from collections import namedtuple
"""Firmware information."""
FirmwareInfo = namedtuple('FirmwareInfo', [
'level',
'kind',
'name',
'version',
@@ -34,15 +196,8 @@ FirmwareInfo = namedtuple('FirmwareInfo', [
"""Reprogrammable keys informations."""
ReprogrammableKeyInfo = namedtuple('ReprogrammableKeyInfo', [
'index',
'id',
'name',
'key',
'task',
'task_name',
'flags'])
class Packet(namedtuple('Packet', ['code', 'devnumber', 'data'])):
def __str__(self):
return 'Packet(%02X,%02X,%s)' % (self.code, self.devnumber, 'None' if self.data is None else _hex(self.data))
del namedtuple

View File

@@ -1,109 +0,0 @@
#
# Constants used by the rest of the API.
#
from struct import pack as _pack
from binascii import hexlify as _hexlify
_hex = lambda d: _hexlify(d).decode('ascii').upper()
from .common import (FallbackDict, list2dict)
"""Possible features available on a Logitech device.
A particular device might not support all these features, and may support other
unknown features as well.
"""
FEATURE = type('FEATURE', (),
dict(
ROOT=b'\x00\x00',
FEATURE_SET=b'\x00\x01',
FIRMWARE=b'\x00\x03',
NAME=b'\x00\x05',
BATTERY=b'\x10\x00',
REPROGRAMMABLE_KEYS=b'\x1B\x00',
WIRELESS=b'\x1D\x4B',
SOLAR_CHARGE=b'\x43\x01',
))
def _feature_name(key):
if key is None:
return None
if type(key) == int:
return FEATURE_NAME[_pack('!H', key)]
return 'UNKNOWN_' + _hex(key)
"""Feature names indexed by feature id."""
FEATURE_NAME = FallbackDict(_feature_name)
FEATURE_NAME[FEATURE.ROOT] = 'ROOT'
FEATURE_NAME[FEATURE.FEATURE_SET] = 'FEATURE_SET'
FEATURE_NAME[FEATURE.FIRMWARE] = 'FIRMWARE'
FEATURE_NAME[FEATURE.NAME] = 'NAME'
FEATURE_NAME[FEATURE.BATTERY] = 'BATTERY'
FEATURE_NAME[FEATURE.REPROGRAMMABLE_KEYS] = 'REPROGRAMMABLE_KEYS'
FEATURE_NAME[FEATURE.WIRELESS] = 'WIRELESS'
FEATURE_NAME[FEATURE.SOLAR_CHARGE] = 'SOLAR_CHARGE'
FEATURE_FLAGS = { 0x20: 'internal', 0x40: 'hidden', 0x80: 'obsolete' }
_DEVICE_KINDS = ('keyboard', 'remote control', 'numpad', 'mouse',
'touchpad', 'trackball', 'presenter', 'receiver')
"""Possible types of devices connected to an UR."""
DEVICE_KIND = FallbackDict(lambda x: 'unknown', list2dict(_DEVICE_KINDS))
_FIRMWARE_KINDS = ('Firmware', 'Bootloader', 'Hardware', 'Other')
"""Names of different firmware levels possible, indexed by level."""
FIRMWARE_KIND = FallbackDict(lambda x: 'Unknown', list2dict(_FIRMWARE_KINDS))
_BATTERY_STATUSES = ('Discharging (in use)', 'Recharging', 'Almost full',
'Full', 'Slow recharge', 'Invalid battery', 'Thermal error',
'Charging error')
"""Names for possible battery status values."""
BATTERY_STATUS = FallbackDict(lambda x: 'unknown', list2dict(_BATTERY_STATUSES))
_KEY_NAMES = ( 'unknown_0000', 'Volume up', 'Volume down', 'Mute', 'Play/Pause',
'Next', 'Previous', 'Stop', 'Application switcher',
'unknown_0009', 'Calculator', 'unknown_000B', 'unknown_000C',
'unknown_000D', 'Mail')
"""Standard names for reprogrammable keys."""
KEY_NAME = FallbackDict(lambda x: 'unknown_%04X' % x, list2dict(_KEY_NAMES))
"""Possible flags on a reprogrammable key."""
KEY_FLAG = type('KEY_FLAG', (), dict(
REPROGRAMMABLE=0x10,
FN_SENSITIVE=0x08,
NONSTANDARD=0x04,
IS_FN=0x02,
MSE=0x01,
))
KEY_FLAG_NAME = FallbackDict(lambda x: 'unknown')
KEY_FLAG_NAME[KEY_FLAG.REPROGRAMMABLE] = 'reprogrammable'
KEY_FLAG_NAME[KEY_FLAG.FN_SENSITIVE] = 'fn-sensitive'
KEY_FLAG_NAME[KEY_FLAG.NONSTANDARD] = 'nonstandard'
KEY_FLAG_NAME[KEY_FLAG.IS_FN] = 'is-fn'
KEY_FLAG_NAME[KEY_FLAG.MSE] = 'mse'
_ERROR_NAMES = ('Ok', 'Unknown', 'Invalid argument', 'Out of range',
'Hardware error', 'Logitech internal', 'Invalid feature index',
'Invalid function', 'Busy', 'Unsupported')
"""Names for error codes."""
ERROR_NAME = FallbackDict(lambda x: 'Unknown error', list2dict(_ERROR_NAMES))
"""Maximum number of devices that can be attached to a single receiver."""
MAX_ATTACHED_DEVICES = 6
del FallbackDict
del list2dict

View File

@@ -0,0 +1,103 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from collections import namedtuple
from .common import NamedInts as _NamedInts
from . import hidpp10 as _hidpp10
from . import hidpp20 as _hidpp20
from . import settings as _settings
#
# common strings for settings
#
_SMOOTH_SCROLL = ('smooth-scroll', 'Smooth Scrolling', 'High-sensitivity mode for vertical scroll with the wheel.')
_DPI = ('dpi', 'Sensitivity (DPI)', None)
_FN_SWAP = ('fn-swap', 'Swap Fx function', ('When set, the F1..F12 keys will activate their special function,\n'
'and you must hold the FN key to activate their standard function.\n'
'\n'
'When unset, the F1..F12 keys will activate their standard function,\n'
'and you must hold the FN key to activate their special function.'))
def _register_smooth_scroll(register, true_value, mask):
return _settings.register_toggle(_SMOOTH_SCROLL[0], register, true_value=true_value, mask=mask,
label=_SMOOTH_SCROLL[1], description=_SMOOTH_SCROLL[2])
def _register_dpi(register, choices):
return _settings.register_choices(_DPI[0], register, choices,
label=_DPI[1], description=_DPI[2])
def check_features(device, already_known):
if _hidpp20.FEATURE.FN_STATUS in device.features and not any(s.name == 'fn-swap' for s in already_known):
tfn = _settings.feature_toggle(_FN_SWAP[0], _hidpp20.FEATURE.FN_STATUS, write_returns_value=True,
label=_FN_SWAP[1], description=_FN_SWAP[2])
already_known.append(tfn(device))
#
#
#
_DeviceDescriptor = namedtuple('_DeviceDescriptor',
['name', 'kind', 'codename', 'registers', 'settings'])
DEVICES = {}
def _D(name, codename=None, kind=None, registers=None, settings=None):
if kind is None:
kind = (_hidpp10.DEVICE_KIND.mouse if 'Mouse' in name
else _hidpp10.DEVICE_KIND.keyboard if 'Keyboard' in name
else _hidpp10.DEVICE_KIND.touchpad if 'Touchpad' in name
else _hidpp10.DEVICE_KIND.trackball if 'Trackball' in name
else None)
assert kind is not None
if codename is None:
codename = name.split(' ')[-1]
assert codename is not None
DEVICES[codename] = _DeviceDescriptor(name, kind, codename, registers, settings)
_D('Wireless Mouse M315')
_D('Wireless Mouse M325')
_D('Wireless Mouse M505')
_D('Wireless Mouse M510')
_D('Couch Mouse M515')
_D('Wireless Mouse M525')
_D('Wireless Trackball M570')
_D('Touch Mouse M600')
_D('Marathon Mouse M705',
settings=[
_register_smooth_scroll(0x01, true_value=0x40, mask=0x40),
# _register_dpi(0x63, _NamedInts.range(9, 11, lambda x: str(x * 100))),
],
)
_D('Wireless Keyboard K230')
_D('Wireless Keyboard K270')
_D('Wireless Keyboard K350')
_D('Wireless Keyboard K360')
_D('Wireless Touch Keyboard K400')
_D('Wireless Solar Keyboard K750')
_D('Wireless Illuminated Keyboard K800')
_D('Zone Touch Mouse T400')
_D('Wireless Rechargeable Touchpad T650')
_D('Logitech Cube', kind='mouse')
_D('Anywhere Mouse MX', codename='Anywhere MX',
settings=[
_register_smooth_scroll(0x01, true_value=0x40, mask=0x40),
],
)
_D('Performance Mouse MX', codename='Performance MX',
settings=[
_register_dpi(0x63, _NamedInts.range(0x81, 0x8F, lambda x: str((x - 0x80) * 100))),
],
)
del namedtuple

View File

@@ -1,36 +0,0 @@
#
# Exceptions that may be raised by this API.
#
from .constants import (FEATURE_NAME, ERROR_NAME)
class NoReceiver(Exception):
"""May be raised when trying to talk through a previously connected
receiver that is no longer available. Should only happen if the receiver is
physically disconnected from the machine, or its kernel driver module is
unloaded."""
pass
class FeatureNotSupported(Exception):
"""Raised when trying to request a feature not supported by the device."""
def __init__(self, devnumber, feature):
super(FeatureNotSupported, self).__init__(devnumber, feature, FEATURE_NAME[feature])
self.devnumber = devnumber
self.feature = feature
self.feature_name = FEATURE_NAME[feature]
class FeatureCallError(Exception):
"""Raised if the device replied to a feature call with an error."""
def __init__(self, devnumber, feature, feature_index, feature_function, error_code, data=None):
super(FeatureCallError, self).__init__(devnumber, feature, feature_index, feature_function, error_code, ERROR_NAME[error_code])
self.devnumber = devnumber
self.feature = feature
self.feature_name = None if feature is None else FEATURE_NAME[feature]
self.feature_index = feature_index
self.feature_function = feature_function
self.error_code = error_code
self.error_string = ERROR_NAME[error_code]
self.data = data

View File

@@ -0,0 +1,134 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from logging import getLogger # , DEBUG as _DEBUG
_log = getLogger('LUR').getChild('hidpp10')
del getLogger
from .common import (strhex as _strhex,
NamedInts as _NamedInts,
FirmwareInfo as _FirmwareInfo)
from .hidpp20 import FIRMWARE_KIND
#
# constants
#
DEVICE_KIND = _NamedInts(
keyboard=0x01,
mouse=0x02,
numpad=0x03,
presenter=0x04,
trackball=0x08,
touchpad=0x09)
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)
NOTIFICATION_FLAG = _NamedInts(
battery_status=0x100000,
wireless=0x000100,
software_present=0x0000800)
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)
PAIRING_ERRORS = _NamedInts(
device_timeout=0x01,
device_not_supported=0x02,
too_many_devices=0x03,
sequence_timeout=0x06)
#
# functions
#
def get_register(device, name, default_number=-1):
known_register = device.registers[name]
register = known_register or default_number
if register > 0:
reply = device.request(0x8100 + (register & 0xFF))
if reply:
return reply
if not known_register and device.ping():
_log.warn("%s: failed to read '%s' from default register 0x%02X, blacklisting", device, name, default_number)
device.registers[-default_number] = name
def get_battery(device):
"""Reads a device's battery level, if provided by the HID++ 1.0 protocol."""
reply = get_register(device, 'battery', 0x0D)
if reply:
charge = ord(reply[:1])
status = ord(reply[2:3]) & 0xF0
status = ('discharging' if status == 0x30
else 'charging' if status == 0x50
else 'fully charged' if status == 0x90
else None)
return charge, status
reply = get_register(device, 'battery_status', 0x07)
if reply:
battery_status = ord(reply[:1])
_log.info("%s: battery status %02X", device, battery_status)
def get_serial(device):
if device.kind is None:
dev_id = 0x03
receiver = device
else:
dev_id = 0x30 + device.number - 1
receiver = device.receiver
serial = receiver.request(0x83B5, dev_id)
if serial:
return _strhex(serial[1:5])
def get_firmware(device):
firmware = []
reply = device.request(0x81F1, 0x01)
if reply:
fw_version = _strhex(reply[1:3])
fw_version = '%s.%s' % (fw_version[0:2], fw_version[2:4])
reply = device.request(0x81F1, 0x02)
if reply:
fw_version += '.B' + _strhex(reply[1:3])
fw = _FirmwareInfo(FIRMWARE_KIND.Firmware, '', fw_version, None)
firmware.append(fw)
reply = device.request(0x81F1, 0x04)
if reply:
bl_version = _strhex(reply[1:3])
bl_version = '%s.%s' % (bl_version[0:2], bl_version[2:4])
bl = _FirmwareInfo(FIRMWARE_KIND.Bootloader, '', bl_version, None)
firmware.append(bl)
return tuple(firmware)

View File

@@ -0,0 +1,397 @@
#
# Logitech Unifying Receiver API.
#
from __future__ import absolute_import, division, print_function, unicode_literals
from struct import pack as _pack, unpack as _unpack
from weakref import proxy as _proxy
from logging import getLogger, DEBUG as _DEBUG
_log = getLogger('LUR').getChild('hidpp20')
del getLogger
from .common import (FirmwareInfo as _FirmwareInfo,
ReprogrammableKeyInfo as _ReprogrammableKeyInfo,
KwException as _KwException,
NamedInts as _NamedInts)
#
#
#
"""Possible features available on a Logitech device.
A particular device might not support all these features, and may support other
unknown features as well.
"""
FEATURE = _NamedInts(
ROOT=0x0000,
FEATURE_SET=0x0001,
FIRMWARE=0x0003,
NAME=0x0005,
BATTERY=0x1000,
REPROGRAMMABLE_KEYS=0x1B00,
WIRELESS=0x1D4B,
FN_STATUS=0x40A0,
SOLAR_CHARGE=0x4301,
TOUCH_MOUSE=0x6110)
FEATURE._fallback = lambda x: 'unknown:%04X' % x
FEATURE_FLAG = _NamedInts(
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)
FIRMWARE_KIND = _NamedInts(
Firmware=0x00,
Bootloader=0x01,
Hardware=0x02,
Other=0x03)
BATTERY_OK = lambda status: status < 5
BATTERY_STATUS = _NamedInts(
discharging=0x00,
recharging=0x01,
almost_full=0x02,
full=0x03,
slow_recharge=0x04,
invalid_battery=0x05,
thermal_error=0x06)
KEY = _NamedInts(
Volume_Up=0x0001,
Volume_Down=0x0002,
Mute=0x0003,
Play__Pause=0x0004,
Next=0x0005,
Previous=0x0006,
Stop=0x0007,
Application_Switcher=0x0008,
Calculator=0x000A,
Mail=0x000E,
Home=0x001A,
Music=0x001D,
Search=0x0029,
Sleep=0x002F)
KEY._fallback = lambda x: 'unknown:%04X' % x
KEY_FLAG = _NamedInts(
reprogrammable=0x10,
FN_sensitive=0x08,
nonstandard=0x04,
is_FN=0x02,
mse=0x01)
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 FeatureNotSupported(_KwException):
"""Raised when trying to request a feature not supported by the device."""
pass
class FeatureCallError(_KwException):
"""Raised if the device replied to a feature call with an error."""
pass
#
#
#
class FeaturesArray(object):
"""A sequence of features supported by a HID++ 2.0 device."""
__slots__ = ('supported', 'device', 'features')
assert int(FEATURE.ROOT) == 0x0000
def __init__(self, device):
assert device is not None
self.device = _proxy(device)
self.supported = True
self.features = None
def __del__(self):
self.supported = False
def _check(self):
# print (self.device, "check")
if self.supported:
assert self.device
if self.features is not None:
return True
protocol = self.device.protocol
if protocol == 0:
# device is not connected right now, will have to try later
return False
# I _think_ this is universally true
if protocol < 2.0:
self.supported = False
# self.device.features = None
self.device = None
return False
reply = self.device.request(0x0000, _pack(b'!H', FEATURE.FEATURE_SET))
if reply is None:
self.supported = False
else:
fs_index = ord(reply[0:1])
if fs_index:
count = self.device.request(fs_index << 8)
if count is None:
_log.warn("FEATURE_SET found, but failed to read features count")
# most likely the device is unavailable
return False
else:
count = ord(count[:1])
assert count >= fs_index
self.features = [None] * (1 + count)
self.features[0] = FEATURE.ROOT
self.features[fs_index] = FEATURE.FEATURE_SET
return True
else:
self.supported = False
return False
__bool__ = __nonzero__ = _check
def __getitem__(self, index):
if self._check():
if isinstance(index, int):
if index < 0 or index >= len(self.features):
raise IndexError(index)
if self.features[index] is None:
feature = self.device.feature_request(FEATURE.FEATURE_SET, 0x10, index)
if feature:
feature, = _unpack(b'!H', feature[:2])
self.features[index] = FEATURE[feature]
return self.features[index]
elif isinstance(index, slice):
indices = index.indices(len(self.features))
return [self.__getitem__(i) for i in range(*indices)]
def __contains__(self, value):
if self._check():
ivalue = int(value)
may_have = False
for f in self.features:
if f is None:
may_have = True
elif ivalue == int(f):
return True
elif ivalue < int(f):
break
if may_have:
reply = self.device.request(0x0000, _pack(b'!H', ivalue))
if reply:
index = ord(reply[0:1])
if index:
self.features[index] = FEATURE[ivalue]
return True
def index(self, value):
if self._check():
may_have = False
ivalue = int(value)
for index, f in enumerate(self.features):
if f is None:
may_have = True
elif ivalue == int(f):
return index
elif ivalue < int(f):
raise ValueError("%s not in list" % repr(value))
if may_have:
reply = self.device.request(0x0000, _pack(b'!H', ivalue))
if reply:
index = ord(reply[0:1])
self.features[index] = FEATURE[ivalue]
return index
raise ValueError("%s not in list" % repr(value))
def __iter__(self):
if self._check():
yield FEATURE.ROOT
index = 1
last_index = len(self.features)
while index < last_index:
yield self.__getitem__(index)
index += 1
def __len__(self):
return len(self.features) if self._check() else 0
#
#
#
class KeysArray(object):
"""A sequence of key mappings supported by a HID++ 2.0 device."""
__slots__ = ('device', 'keys')
def __init__(self, device, count):
assert device is not None
self.device = _proxy(device)
self.keys = [None] * count
def __getitem__(self, index):
if isinstance(index, int):
if index < 0 or index >= len(self.keys):
raise IndexError(index)
if self.keys[index] is None:
keydata = feature_request(self.device, FEATURE.REPROGRAMMABLE_KEYS, 0x10, index)
if keydata:
key, key_task, flags = _unpack(b'!HHB', keydata[:5])
self.keys[index] = _ReprogrammableKeyInfo(index, KEY[key], KEY[key_task], flags)
return self.keys[index]
elif isinstance(index, slice):
indices = index.indices(len(self.keys))
return [self.__getitem__(i) for i in range(*indices)]
def index(self, value):
for index, k in enumerate(self.keys):
if k is not None and int(value) == int(k.key):
return index
for index, k in enumerate(self.keys):
if k is None:
k = self.__getitem__(index)
if k is not None:
return index
def __iter__(self):
for k in range(0, len(self.keys)):
yield self.__getitem__(k)
def __len__(self):
return len(self.keys)
#
#
#
def feature_request(device, feature, function=0x00, *params):
if device.features:
if feature in device.features:
feature_index = device.features.index(int(feature))
return device.request((feature_index << 8) + (function & 0xFF), *params)
def get_firmware(device):
"""Reads a device's firmware info.
:returns: a list of FirmwareInfo tuples, ordered by firmware layer.
"""
count = feature_request(device, FEATURE.FIRMWARE)
if count:
count = ord(count[:1])
fw = []
for index in range(0, count):
fw_info = feature_request(device, FEATURE.FIRMWARE, 0x10, index)
if fw_info:
level = ord(fw_info[:1]) & 0x0F
if level == 0 or level == 1:
name, version_major, version_minor, build = _unpack(b'!3sBBH', fw_info[1:8])
version = '%02X.%02X' % (version_major, version_minor)
if build:
version += '.B%04X' % build
extras = fw_info[9:].rstrip(b'\x00') or None
fw_info = _FirmwareInfo(FIRMWARE_KIND[level], name.decode('ascii'), version, extras)
elif level == FIRMWARE_KIND.Hardware:
fw_info = _FirmwareInfo(FIRMWARE_KIND.Hardware, '', ord(fw_info[1:2]), None)
else:
fw_info = _FirmwareInfo(FIRMWARE_KIND.Other, '', '', None)
fw.append(fw_info)
# _log.debug("device %d firmware %s", devnumber, fw_info)
return tuple(fw)
def get_kind(device):
"""Reads a device's type.
:see DEVICE_KIND:
:returns: a string describing the device type, or ``None`` if the device is
not available or does not support the ``NAME`` feature.
"""
kind = feature_request(device, FEATURE.NAME, 0x20)
if kind:
kind = ord(kind[:1])
# _log.debug("device %d type %d = %s", devnumber, kind, DEVICE_KIND[kind])
return DEVICE_KIND[kind]
def get_name(device):
"""Reads a device's name.
:returns: a string with the device name, or ``None`` if the device is not
available or does not support the ``NAME`` feature.
"""
name_length = feature_request(device, FEATURE.NAME)
if name_length:
name_length = ord(name_length[:1])
name = b''
while len(name) < name_length:
fragment = feature_request(device, FEATURE.NAME, 0x10, len(name))
if fragment:
name += fragment[:name_length - len(name)]
else:
_log.error("failed to read whole name of %s (expected %d chars)", device, name_length)
return None
return name.decode('ascii')
def get_battery(device):
"""Reads a device's battery level.
:raises FeatureNotSupported: if the device does not support this feature.
"""
battery = feature_request(device, FEATURE.BATTERY)
if battery:
discharge, dischargeNext, status = _unpack(b'!BBB', battery[:3])
if _log.isEnabledFor(_DEBUG):
_log.debug("device %d battery %d%% charged, next level %d%% charge, status %d = %s",
device.number, discharge, dischargeNext, status, BATTERY_STATUS[status])
return discharge, BATTERY_STATUS[status]
def get_keys(device):
count = feature_request(device, FEATURE.REPROGRAMMABLE_KEYS)
if count:
return KeysArray(device, ord(count[:1]))

View File

@@ -2,12 +2,10 @@
#
#
from threading import Thread as _Thread
# from time import sleep as _sleep
from __future__ import absolute_import, division, print_function, unicode_literals
from . import base as _base
from .exceptions import NoReceiver as _NoReceiver
from .common import Packet as _Packet
import threading as _threading
from time import time as _timestamp
# for both Python 2 and 3
try:
@@ -15,127 +13,185 @@ try:
except ImportError:
from queue import Queue as _Queue
from logging import getLogger
from logging import getLogger, DEBUG as _DEBUG
_log = getLogger('LUR').getChild('listener')
del getLogger
from . import base as _base
_READ_EVENT_TIMEOUT = int(_base.DEFAULT_TIMEOUT / 2) # ms
#
#
#
def _event_dispatch(listener, callback):
while listener._active: # or not listener._events.empty():
try:
event = listener._events.get(True, _READ_EVENT_TIMEOUT * 10)
except:
continue
# _log.debug("delivering event %s", event)
try:
callback(event)
except:
_log.exception("callback for %s", event)
class ThreadedHandle(object):
"""A thread-local wrapper with different open handles for each thread.
class EventsListener(_Thread):
"""Listener thread for events from the Unifying Receiver.
Incoming packets will be passed to the callback function in sequence, by a
separate thread.
Closing a ThreadedHandle will close all handles.
"""
def __init__(self, receiver_handle, events_callback):
super(EventsListener, self).__init__(group='Unifying Receiver', name=self.__class__.__name__)
__slots__ = ['path', '_local', '_handles']
def __init__(self, initial_handle, path):
assert initial_handle
if type(initial_handle) != int:
raise TypeError('expected int as initial handle, got %r' % initial_handle)
assert path
self.path = path
self._local = _threading.local()
self._local.handle = initial_handle
self._handles = [initial_handle]
def _open(self):
handle = _base.open_path(self.path)
if handle is None:
_log.error("%s failed to open new handle", repr(self))
else:
# _log.debug("%s opened new handle %d", repr(self), handle)
self._local.handle = handle
self._handles.append(handle)
return handle
def close(self):
if self._local:
self._local = None
handles, self._handles = self._handles, []
if _log.isEnabledFor(_DEBUG):
_log.debug("%s closing %s", repr(self), handles)
for h in handles:
_base.close(h)
def __del__(self):
self.close()
def __index__(self):
if self._local:
try:
return self._local.handle
except:
return self._open()
__int__ = __index__
def __str__(self):
if self._local:
return str(int(self))
__unicode__ = __str__
def __repr__(self):
return '<ThreadedHandle(%s)>' % self.path
def __bool__(self):
return bool(self._local)
__nonzero__ = __bool__
#
#
#
# How long to wait during a read for the next packet.
# Ideally this should be rather long (10s ?), but the read is blocking
# and this means that when the thread is signalled to stop, it would take
# a while for it to acknowledge it.
_EVENT_READ_TIMEOUT = 500
# After this many read that did not produce a packet, call the tick() method.
_IDLE_READS = 4
class EventsListener(_threading.Thread):
"""Listener thread for notifications from the Unifying Receiver.
Incoming packets will be passed to the callback function in sequence.
"""
def __init__(self, receiver, notifications_callback):
super(EventsListener, self).__init__(name=self.__class__.__name__)
self.daemon = True
self._active = False
self._handle = receiver_handle
self.receiver = receiver
self._queued_notifications = _Queue(32)
self._notifications_callback = notifications_callback
self._tasks = _Queue(1)
self._backup_unhandled_hook = _base.unhandled_hook
_base.unhandled_hook = self.unhandled_hook
self._events = _Queue(32)
self._dispatcher = _Thread(group='Unifying Receiver',
name=self.__class__.__name__ + '-dispatch',
target=_event_dispatch, args=(self, events_callback))
self._dispatcher.daemon = True
self.tick_period = 0
def run(self):
self._active = True
_log.debug("started")
_base.request_context = self
_base.unhandled_hook = self._backup_unhandled_hook
del self._backup_unhandled_hook
self._dispatcher.start()
# This is necessary because notification packets might be received
# during requests made by our callback.
_base.notifications_hook = self._notifications_hook
ihandle = int(self.receiver.handle)
_log.info("started with %s (%d)", self.receiver, ihandle)
self.has_started()
last_tick = 0
idle_reads = 0
while self._active:
try:
# _log.debug("read next event")
event = _base.read(self._handle, _READ_EVENT_TIMEOUT)
except _NoReceiver:
self._handle = 0
_log.warn("receiver disconnected")
self._events.put(_Packet(0xFF, 0xFF, None))
self._active = False
if self._queued_notifications.empty():
try:
# _log.debug("read next notification")
n = _base.read(ihandle, _EVENT_READ_TIMEOUT)
except _base.NoReceiver:
_log.warning("receiver disconnected")
self.receiver.close()
break
if n:
n = _base.make_notification(*n)
else:
if event is not None:
matched = False
task = None if self._tasks.empty() else self._tasks.queue[0]
if task and task[-1] is None:
task_dev, task_data = task[:2]
if event[1] == task_dev:
# _log.debug("matching %s to (%d, %s)", event, task_dev, repr(task_data))
matched = event[2][:2] == task_data[:2] or (event[2][:1] in b'\x8F\xFF' and event[2][1:3] == task_data[:2])
# deliver any queued notifications
n = self._queued_notifications.get()
if matched:
# _log.debug("request reply %s", event)
task[-1] = event
self._tasks.task_done()
else:
event = _Packet(*event)
_log.info("queueing event %s", event)
self._events.put(event)
if n:
# if _log.isEnabledFor(_DEBUG):
# _log.debug("processing %s", n)
try:
self._notifications_callback(n)
except:
_log.exception("processing %s", n)
elif self.tick_period:
idle_reads += 1
if idle_reads % _IDLE_READS == 0:
idle_reads = 0
now = _timestamp()
if now - last_tick >= self.tick_period:
last_tick = now
self.tick(now)
_base.request_context = None
handle, self._handle = self._handle, 0
_base.close(handle)
_log.debug("stopped")
_base.notifications_hook = None
del self._queued_notifications
self.has_stopped()
def stop(self):
"""Tells the listener to stop as soon as possible."""
if self._active:
_log.debug("stopping")
self._active = False
# wait for the receiver handle to be closed
self.join()
self._active = False
@property
def handle(self):
return self._handle
def has_started(self):
"""Called right after the thread has started, and before it starts
reading notification packets."""
pass
def write(self, handle, devnumber, data):
assert handle == self._handle
# _log.debug("write %02X %s", devnumber, _base._hex(data))
task = [devnumber, data, None]
self._tasks.put(task)
_base.write(self._handle, devnumber, data)
# _log.debug("task queued %s", task)
def has_stopped(self):
"""Called right before the thread stops."""
pass
def read(self, handle, timeout=_base.DEFAULT_TIMEOUT):
assert handle == self._handle
# _log.debug("read %d", timeout)
assert not self._tasks.empty()
self._tasks.join()
task = self._tasks.get(False)
# _log.debug("task ready %s", task)
return task[-1]
def tick(self, timestamp):
"""Called about every tick_period seconds."""
pass
def unhandled_hook(self, reply_code, devnumber, data):
event = _Packet(reply_code, devnumber, data)
_log.info("queueing unhandled event %s", event)
self._events.put(event)
def _notifications_hook(self, n):
# Only consider unhandled notifications that were sent from this thread,
# i.e. triggered by a callback handling a previous notification.
if self._active and _threading.current_thread() == self:
if _log.isEnabledFor(_DEBUG):
_log.debug("queueing unhandled %s", n)
self._queued_notifications.put(n)
def __bool__(self):
return bool(self._active and self._handle)
return bool(self._active and self.receiver)
__nonzero__ = __bool__

View File

@@ -0,0 +1,358 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
import errno as _errno
from weakref import proxy as _proxy
from logging import getLogger
_log = getLogger('LUR').getChild('receiver')
del getLogger
from . import base as _base
from . import hidpp10 as _hidpp10
from . import hidpp20 as _hidpp20
from .common import strhex as _strhex, NamedInts as _NamedInts
from . import descriptors as _descriptors
#
#
#
"""A receiver may have a maximum of 6 paired devices at a time."""
MAX_PAIRED_DEVICES = 6
class PairedDevice(object):
def __init__(self, receiver, number):
assert receiver
self.receiver = _proxy(receiver)
assert number > 0 and number <= MAX_PAIRED_DEVICES
self.number = number
self._protocol = None
self._wpid = None
self._power_switch = None
self._polling_rate = None
self._codename = None
self._name = None
self._kind = None
self._serial = None
self._firmware = None
self._keys = None
self.features = _hidpp20.FeaturesArray(self)
self._registers = None
self._settings = None
@property
def protocol(self):
if self._protocol is None:
self._protocol = _base.ping(self.receiver.handle, self.number)
# _log.debug("device %d protocol %s", self.number, self._protocol)
return self._protocol or 0
@property
def wpid(self):
if self._wpid is None:
pair_info = self.receiver.request(0x83B5, 0x20 + self.number - 1)
if pair_info:
self._wpid = _strhex(pair_info[3:5])
if self._kind is None:
kind = ord(pair_info[7:8]) & 0x0F
self._kind = _hidpp10.DEVICE_KIND[kind]
if self._polling_rate is None:
self._polling_rate = ord(pair_info[2:3])
return self._wpid
@property
def polling_rate(self):
if self._polling_rate is None:
self.wpid, 0
return self._polling_rate
@property
def power_switch_location(self):
if self._power_switch is None:
ps = self.receiver.request(0x83B5, 0x30 + self.number - 1)
if ps:
ps = ord(ps[9:10]) & 0x0F
self._power_switch = _hidpp10.POWER_SWITCH_LOCATION[ps]
return self._power_switch
@property
def codename(self):
if self._codename is None:
codename = self.receiver.request(0x83B5, 0x40 + self.number - 1)
if codename:
self._codename = codename[2:].rstrip(b'\x00').decode('utf-8')
# _log.debug("device %d codename %s", self.number, self._codename)
return self._codename
@property
def name(self):
if self._name is None:
if self.codename in _descriptors.DEVICES:
self._name, self._kind = _descriptors.DEVICES[self._codename][:2]
elif self.protocol >= 2.0:
self._name = _hidpp20.get_name(self)
return self._name or self.codename or '?'
@property
def kind(self):
if self._kind is None:
pair_info = self.receiver.request(0x83B5, 0x20 + self.number - 1)
if pair_info:
kind = ord(pair_info[7:8]) & 0x0F
self._kind = _hidpp10.DEVICE_KIND[kind]
if self._wpid is None:
self._wpid = _strhex(pair_info[3:5])
if self._kind is None:
if self.codename in _descriptors.DEVICES:
self._name, self._kind = _descriptors.DEVICES[self._codename][:2]
elif self.protocol >= 2.0:
self._kind = _hidpp20.get_kind(self)
return self._kind or '?'
@property
def firmware(self):
if self._firmware is None:
p = self.protocol
if p >= 2.0:
self._firmware = _hidpp20.get_firmware(self)
if self._firmware is None and p == 1.0:
self._firmware = _hidpp10.get_firmware(self)
return self._firmware or ()
@property
def serial(self):
if self._serial is None:
self._serial = _hidpp10.get_serial(self)
return self._serial or '?'
@property
def keys(self):
if self._keys is None:
self._keys = _hidpp20.get_keys(self) or ()
return self._keys
@property
def registers(self):
if self._registers is None:
descriptor = _descriptors.DEVICES.get(self.codename)
if descriptor is None or descriptor.registers is None:
self._registers = _NamedInts()
else:
self._registers = descriptor.registers
return self._registers
@property
def settings(self):
if self._settings is None:
descriptor = _descriptors.DEVICES.get(self.codename)
if descriptor is None or descriptor.settings is None:
self._settings = []
else:
self._settings = [s(self) for s in descriptor.settings]
if self.features:
_descriptors.check_features(self, self._settings)
return self._settings
def request(self, request_id, *params):
return _base.request(self.receiver.handle, self.number, request_id, *params)
def feature_request(self, feature, function=0x00, *params):
return _hidpp20.feature_request(self, feature, function, *params)
def ping(self):
return _base.ping(self.receiver.handle, self.number) is not None
def __index__(self):
return self.number
__int__ = __index__
def __eq__(self, other):
return self.serial == other.serial
def __ne__(self, other):
return self.serial != other.serial
def __hash__(self):
return self.serial.__hash__()
def __str__(self):
return '<PairedDevice(%d,%s)>' % (self.number, self.codename or '?')
__unicode__ = __repr__ = __str__
#
#
#
class Receiver(object):
"""A Unifying Receiver instance.
The paired devices are available through the sequence interface.
"""
number = 0xFF
name = 'Unifying Receiver'
kind = None
max_devices = MAX_PAIRED_DEVICES
def __init__(self, handle, path=None):
assert handle
self.handle = handle
assert path
self.path = path
self._serial = None
self._firmware = None
self._devices = {}
def close(self):
handle, self.handle = self.handle, None
self._devices.clear()
return (handle and _base.close(handle))
def __del__(self):
self.close()
@property
def serial(self):
if self._serial is None and self.handle:
self._serial = _hidpp10.get_serial(self)
return self._serial
@property
def firmware(self):
if self._firmware is None and self.handle:
self._firmware = _hidpp10.get_firmware(self)
return self._firmware
def enable_notifications(self, enable=True):
"""Enable or disable device (dis)connection notifications on this
receiver."""
if not self.handle:
return False
if enable:
# set all possible flags
ok = self.request(0x8000, 0xFF, 0xFF, 0xFF)
else:
# clear out all possible flags
ok = self.request(0x8000)
if ok:
_log.info("device notifications %s", 'enabled' if enable else 'disabled')
else:
_log.warn("failed to %s device notifications", 'enable' if enable else 'disable')
return ok
def notify_devices(self):
"""Scan all devices."""
if self.handle:
if not self.request(0x8002, 0x02):
_log.warn("failed to trigger device link notifications")
def register_new_device(self, number):
if self._devices.get(number) is not None:
raise IndexError("device number %d already registered" % number)
dev = PairedDevice(self, number)
# create a device object, but only use it if the receiver knows about it
if dev.wpid:
_log.info("found device %d (%s)", number, dev.wpid)
self._devices[number] = dev
return dev
self._devices[number] = None
def set_lock(self, lock_closed=True, device=0, timeout=0):
if self.handle:
lock = 0x02 if lock_closed else 0x01
reply = self.request(0x80B2, lock, device, timeout)
if reply:
return True
_log.warn("failed to %s the receiver lock", 'close' if lock_closed else 'open')
def count(self):
count = self.request(0x8102)
return 0 if count is None else ord(count[1:2])
def request(self, request_id, *params):
if self.handle:
return _base.request(self.handle, 0xFF, request_id, *params)
def __iter__(self):
for number in range(1, 1 + MAX_PAIRED_DEVICES):
if number in self._devices:
dev = self._devices[number]
else:
dev = self.__getitem__(number)
if dev is not None:
yield dev
def __getitem__(self, key):
if not self.handle:
return None
dev = self._devices.get(key)
if dev is not None:
return dev
if type(key) != int:
raise TypeError('key must be an integer')
if key < 1 or key > MAX_PAIRED_DEVICES:
raise IndexError(key)
return self.register_new_device(key)
def __delitem__(self, key):
if self._devices.get(key) is None:
raise IndexError(key)
dev = self._devices[key]
reply = self.request(0x80B2, 0x03, int(key))
if reply:
del self._devices[key]
_log.warn("%s unpaired device %s", self, dev)
else:
_log.error("%s failed to unpair device %s", self, dev)
raise IndexError(key)
def __len__(self):
return len([d for d in self._devices.values() if d is not None])
def __contains__(self, dev):
if type(dev) == int:
return self._devices.get(dev) is not None
return self.__contains__(dev.number)
def __str__(self):
return '<Receiver(%s,%s%s)>' % (self.path, '' if type(self.handle) == int else 'T', self.handle)
__unicode__ = __repr__ = __str__
__bool__ = __nonzero__ = lambda self: self.handle is not None
@classmethod
def open(self):
"""Opens the first Logitech Unifying Receiver found attached to the machine.
:returns: An open file handle for the found receiver, or ``None``.
"""
exception = None
for rawdevice in _base.receivers():
exception = None
try:
handle = _base.open_path(rawdevice.path)
if handle:
return Receiver(handle, rawdevice.path)
except OSError as e:
_log.exception("open %s", rawdevice.path)
if e.errno == _errno.EACCES:
exception = e
if exception:
# only keep the last exception
raise exception

View File

@@ -0,0 +1,209 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from weakref import proxy as _proxy
from copy import copy as _copy
from .common import NamedInt as _NamedInt, NamedInts as _NamedInts
#
#
#
KIND = _NamedInts(toggle=0x1, choice=0x02, range=0x12)
class _Setting(object):
__slots__ = ['name', 'label', 'description',
'kind', '_rw', '_validator',
'_device', '_value']
def __init__(self, name, rw, validator, kind=None, label=None, description=None):
assert name
self.name = name
self.label = label or name
self.description = description
self._rw = rw
self._validator = validator
assert kind is None or kind & validator.kind != 0
self.kind = kind or validator.kind
def __call__(self, device):
o = _copy(self)
o._value = None
o._device = _proxy(device)
return o
@property
def choices(self):
return self._validator.choices if self._validator.kind & KIND.choice else None
def read(self, cached=True):
if self._device:
if self._value is None or not cached:
reply = self._rw.read(self._device)
# print ("read reply", repr(reply))
if reply:
# print ("pre-read", self._value)
self._value = self._validator.validate_read(reply)
# print ("post-read", self._value)
return self._value
def write(self, value):
if self._device:
data_bytes = self._validator.prepare_write(value)
reply = self._rw.write(self._device, data_bytes)
if reply:
self._value = self._validator.validate_write(value, reply)
return self._value
def __str__(self):
if hasattr(self, '_value'):
assert hasattr(self, '_device')
return '<Setting([%s:%s] %s:%s=%s)>' % (self._rw.kind, self._validator.kind, self._device.codename, self.name, self._value)
return '<Setting([%s:%s] %s)>' % (self._rw.kind, self._validator.kind, self.name)
__unicode__ = __repr__ = __str__
class _RegisterRW(object):
__slots__ = ['register']
kind = _NamedInt(0x01, 'register')
def __init__(self, register):
assert isinstance(register, int)
self.register = register
def read(self, device):
return device.request(0x8100 | (self.register & 0x2FF))
def write(self, device, data_bytes):
return device.request(0x8000 | (self.register & 0x2FF), data_bytes)
class _FeatureRW(object):
__slots__ = ['feature', 'read_fnid', 'write_fnid']
kind = _NamedInt(0x02, 'feature')
default_read_fnid = 0x00
default_write_fnid = 0x10
def __init__(self, feature, read_fnid=default_read_fnid, write_fnid=default_write_fnid):
assert isinstance(feature, _NamedInt)
self.feature = feature
self.read_fnid = read_fnid
self.write_fnid = write_fnid
def read(self, device):
assert self.feature is not None
return device.feature_request(self.feature, self.read_fnid)
def write(self, device, data_bytes):
assert self.feature is not None
return device.feature_request(self.feature, self.write_fnid, data_bytes)
class _BooleanValidator(object):
__slots__ = ['true_value', 'false_value', 'mask', 'write_returns_value']
kind = KIND.toggle
default_true = 0x01
default_false = 0x00
default_mask = 0xFF
def __init__(self, true_value=default_true, false_value=default_false, mask=default_mask, write_returns_value=False):
self.true_value = true_value
self.false_value = false_value
self.mask = mask
self.write_returns_value = write_returns_value
def validate_read(self, reply_bytes):
reply_value = ord(reply_bytes[:1]) & self.mask
return reply_value == self.true_value
def prepare_write(self, value):
# FIXME: this does not work right when there is more than one flag in
# the same register!
return self.true_value if value else self.false_value
def validate_write(self, value, reply_bytes):
if self.write_returns_value:
reply_value = ord(reply_bytes[:1]) & self.mask
return reply_value == self.true_value
# just assume the value was written correctly, otherwise there would not
# be any reply_bytes to check
return bool(value)
class _ChoicesValidator(object):
__slots__ = ['choices', 'write_returns_value']
kind = KIND.choice
def __init__(self, choices, write_returns_value=False):
assert isinstance(choices, _NamedInts)
self.choices = choices
self.write_returns_value = write_returns_value
def validate_read(self, reply_bytes):
assert self.choices is not None
reply_value = ord(reply_bytes[:1])
valid_value = self.choices[reply_value]
assert valid_value is not None, "%: failed to validate read value %02X" % (self.__class__.__name__, reply_value)
return valid_value
def prepare_write(self, value):
assert self.choices is not None
choice = self.choices[value]
if choice is None:
raise ValueError("invalid choice " + repr(value))
assert isinstance(choice, _NamedInt)
return choice.bytes(1)
def validate_write(self, value, reply_bytes):
assert self.choices is not None
if self.write_returns_value:
reply_value = ord(reply_bytes[:1])
choice = self.choices[reply_value]
assert choice is not None, "failed to validate write reply %02X" % reply_value
return choice
# just assume the value was written correctly, otherwise there would not
# be any reply_bytes to check
return self.choices[value]
#
#
#
def register_toggle(name, register,
true_value=_BooleanValidator.default_true, false_value=_BooleanValidator.default_false,
mask=_BooleanValidator.default_mask, write_returns_value=False,
label=None, description=None):
rw = _RegisterRW(register)
validator = _BooleanValidator(true_value=true_value, false_value=false_value, mask=mask, write_returns_value=write_returns_value)
return _Setting(name, rw, validator, label=label, description=description)
def register_choices(name, register, choices,
kind=KIND.choice, write_returns_value=False,
label=None, description=None):
assert choices
rw = _RegisterRW(register)
validator = _ChoicesValidator(choices, write_returns_value=write_returns_value)
return _Setting(name, rw, validator, kind=kind, label=label, description=description)
def feature_toggle(name, feature,
read_function_id=_FeatureRW.default_read_fnid, write_function_id=_FeatureRW.default_write_fnid,
true_value=_BooleanValidator.default_true, false_value=_BooleanValidator.default_false,
mask=_BooleanValidator.default_mask, write_returns_value=False,
label=None, description=None):
rw = _FeatureRW(feature, read_function_id, write_function_id)
validator = _BooleanValidator(true_value=true_value, false_value=false_value, mask=mask, write_returns_value=write_returns_value)
return _Setting(name, rw, validator, label=label, description=description)

View File

@@ -0,0 +1,332 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from time import time as _timestamp
from struct import unpack as _unpack
from weakref import proxy as _proxy
from logging import getLogger, DEBUG as _DEBUG
_log = getLogger('LUR.status')
del getLogger
from .common import NamedInts as _NamedInts
from . import hidpp10 as _hidpp10
from . import hidpp20 as _hidpp20
#
#
#
ALERT = _NamedInts(NONE=0x00, LOW=0x01, MED=0x02, HIGH=0xFF)
# device properties that may be reported
ENCRYPTED='encrypted'
BATTERY_LEVEL='battery-level'
BATTERY_STATUS='battery-status'
LIGHT_LEVEL='light-level'
ERROR='error'
# if not updates have been receiver from the device for a while, assume
# it has gone offline and clear all its know properties.
_STATUS_TIMEOUT = 120 # seconds
#
#
#
class ReceiverStatus(dict):
def __init__(self, receiver, changed_callback):
assert receiver
self._receiver = _proxy(receiver)
assert changed_callback
self._changed_callback = changed_callback
# self.updated = 0
self.lock_open = False
self.new_device = None
self[ERROR] = None
def __str__(self):
count = len(self._receiver)
return ('No devices found.' if count == 0 else
'1 device found.' if count == 1 else
'%d devices found.' % count)
__unicode__ = __str__
def _changed(self, alert=ALERT.LOW, reason=None):
# self.updated = _timestamp()
self._changed_callback(self._receiver, alert=alert, reason=reason)
def process_notification(self, n):
if n.sub_id == 0x4A:
self.lock_open = bool(n.address & 0x01)
reason = 'pairing lock is ' + ('open' if self.lock_open else 'closed')
_log.info("%s: %s", self._receiver, reason)
if self.lock_open:
self[ERROR] = None
self.new_device = None
pair_error = ord(n.data[:1])
if pair_error:
self[ERROR] = _hidpp10.PAIRING_ERRORS[pair_error]
self.new_device = None
_log.warn("pairing error %d: %s", pair_error, self[ERROR])
else:
self[ERROR] = None
self._changed(reason=reason)
return True
#
#
#
class DeviceStatus(dict):
def __init__(self, device, changed_callback):
assert device
self._device = _proxy(device)
assert changed_callback
self._changed_callback = changed_callback
self._active = None
self.updated = 0
def __str__(self):
def _item(name, format):
value = self.get(name)
if value is not None:
return format % value
def _items():
battery_level = _item(BATTERY_LEVEL, 'Battery: %d%%')
if battery_level:
yield battery_level
battery_status = _item(BATTERY_STATUS, ' <small>(%s)</small>')
if battery_status:
yield battery_status
light_level = _item(LIGHT_LEVEL, 'Light: %d lux')
if light_level:
if battery_level:
yield ', '
yield light_level
return ''.join(i for i in _items())
__unicode__ = __str__
def __bool__(self):
return bool(self._active)
__nonzero__ = __bool__
def _changed(self, active=True, alert=ALERT.NONE, reason=None, timestamp=None):
assert self._changed_callback
self._active = active
if not active:
battery = self.get(BATTERY_LEVEL)
self.clear()
if battery is not None:
self[BATTERY_LEVEL] = battery
if self.updated == 0:
alert |= ALERT.LOW
self.updated = timestamp or _timestamp()
# if _log.isEnabledFor(_DEBUG):
# _log.debug("device %d changed: active=%s %s", self._device.number, self._active, dict(self))
self._changed_callback(self._device, alert, reason)
def poll(self, timestamp):
if self._active:
d = self._device
if not d:
_log.error("polling status of invalid device")
return
# read these from the device in case they haven't been read already
d.protocol, d.serial, d.firmware
if BATTERY_LEVEL not in self:
battery = _hidpp10.get_battery(d)
if battery is None and d.protocol >= 2.0:
battery = _hidpp20.get_battery(d)
# really unnecessary, if the device has SOLAR_CHARGE it should be
# broadcasting it's battery status anyway, it will just take a little while
# if battery is None and _hidpp20.FEATURE.SOLAR_CHARGE in d.features:
# d.feature_request(_hidpp20.FEATURE.SOLAR_CHARGE, 0x00, 1, 1)
# return
if battery:
self[BATTERY_LEVEL], self[BATTERY_STATUS] = battery
self._changed(timestamp=timestamp)
elif BATTERY_STATUS in self:
self[BATTERY_STATUS] = None
self._changed(timestamp=timestamp)
# make sure we know all the features of the device
if d.features:
d.features[:]
elif len(self) > 0 and timestamp - self.updated > _STATUS_TIMEOUT:
# if the device has been inactive for too long, clear out any known
# properties, they are most likely obsolete anyway
self.clear()
self._changed(active=False, timestamp=timestamp)
def process_notification(self, n):
# incoming packets with SubId >= 0x80 are supposedly replies from
# HID++ 1.0 requests, should never get here
assert n.sub_id < 0x80
# 0x40 to 0x7F appear to be HID++ 1.0 notifications
if n.sub_id >= 0x40:
return self._process_hidpp10_notification(n)
# assuming 0x00 to 0x3F are feature (HID++ 2.0) notifications
try:
feature = self._device.features[n.sub_id]
except IndexError:
_log.warn("%s: notification from invalid feature index %02X: %s", self._device, n.sub_id, n)
return False
return self._process_feature_notification(n, feature)
def _process_hidpp10_notification(self, n):
if n.sub_id == 0x40:
if n.address == 0x02:
# device un-paired
self.clear()
self._device.status = None
self._changed(False, ALERT.HIGH, 'unpaired')
else:
_log.warn("%s: disconnection with unknown type %02X: %s", self._device, n.address, n)
return True
if n.sub_id == 0x41:
if n.address == 0x04: # unifying protocol
# wpid = _strhex(n.data[4:5] + n.data[3:4])
# assert wpid == device.wpid
flags = ord(n.data[:1]) & 0xF0
link_encrypyed = bool(flags & 0x20)
link_established = not (flags & 0x40)
if _log.isEnabledFor(_DEBUG):
sw_present = bool(flags & 0x10)
has_payload = bool(flags & 0x80)
_log.debug("%s: connection notification: software=%s, encrypted=%s, link=%s, payload=%s",
self._device, sw_present, link_encrypyed, link_established, has_payload)
self[ENCRYPTED] = link_encrypyed
self._changed(link_established)
elif n.address == 0x03:
_log.warn("%s: connection notification with eQuad protocol, ignored: %s", self._device.number, n)
else:
_log.warn("%s: connection notification with unknown protocol %02X: %s", self._device.number, n.address, n)
return True
if n.sub_id == 0x49:
# raw input event? just ignore it
# if n.address == 0x01, no idea what it is, but they keep on coming
# if n.address == 0x03, it's an actual input event
return True
if n.sub_id == 0x4B:
if n.address == 0x01:
if _log.isEnabledFor(_DEBUG):
_log.debug("%s: device powered on", self._device)
self._changed(alert=ALERT.LOW, reason='powered on')
else:
_log.info("%s: unknown %s", self._device, n)
return True
_log.warn("%s: unrecognized %s", self._device, n)
def _process_feature_notification(self, n, feature):
if feature == _hidpp20.FEATURE.BATTERY:
if n.address == 0x00:
discharge = ord(n.data[:1])
battery_status = ord(n.data[1:2])
self[BATTERY_LEVEL] = discharge
self[BATTERY_STATUS] = BATTERY_STATUS[battery_status]
if _hidpp20.BATTERY_OK(battery_status):
alert = ALERT.NONE
reason = self[ERROR] = None
if _log.isEnabledFor(_DEBUG):
_log.debug("%s: battery %d% charged, %s", self._device, discharge, self[BATTERY_STATUS])
else:
alert = ALERT.MED
reason = self[ERROR] = self[BATTERY_STATUS]
_log.warn("%s: battery %d% charged, ALERT %s", self._device, discharge, reason)
self._changed(alert=alert, reason=reason)
else:
_log.info("%s: unknown BATTERY %s", self._device, n)
return True
if feature == _hidpp20.FEATURE.REPROGRAMMABLE_KEYS:
if n.address == 0x00:
_log.info("%s: reprogrammable key: %s", self._device, n)
else:
_log.info("%s: unknown REPROGRAMMABLE KEYS %s", self._device, n)
return True
if feature == _hidpp20.FEATURE.WIRELESS:
if n.address == 0x00:
if _log.isEnabledFor(_DEBUG):
_log.debug("wireless status: %s", n)
if n.data[0:3] == b'\x01\x01\x01':
self._changed(alert=ALERT.LOW, reason='powered on')
else:
_log.info("%s: unknown WIRELESS %s", self._device, n)
else:
_log.info("%s: unknown WIRELESS %s", self._device, n)
return True
if feature == _hidpp20.FEATURE.SOLAR_CHARGE:
if n.data[5:9] == b'GOOD':
charge, lux, adc = _unpack(b'!BHH', n.data[:5])
self[BATTERY_LEVEL] = charge
# guesstimate the battery voltage, emphasis on 'guess'
self[BATTERY_STATUS] = '%1.2fV' % (adc * 2.67793237653 / 0x0672)
if n.address == 0x00:
self[LIGHT_LEVEL] = None
self._changed()
elif n.address == 0x10:
self[LIGHT_LEVEL] = lux
if lux > 200: # guesstimate
self[BATTERY_STATUS] += ', charging'
self._changed()
elif n.address == 0x20:
_log.debug("%s: Solar key pressed", self._device)
self._changed(alert=ALERT.MED)
# first cancel any reporting
self._device.feature_request(_hidpp20.FEATURE.SOLAR_CHARGE)
# trigger a new report chain
reports_count = 15
reports_period = 2 # seconds
self._device.feature_request(_hidpp20.FEATURE.SOLAR_CHARGE, 0x00, reports_count, reports_period)
else:
_log.info("%s: unknown SOLAR CHAGE %s", self._device, n)
else:
_log.warn("%s: SOLAR CHARGE not GOOD? %s", self._device, n)
return True
if feature == _hidpp20.FEATURE.TOUCH_MOUSE:
if n.address == 0x00:
_log.info("%s: TOUCH MOUSE points %s", self._device, n)
elif n.address == 0x10:
touch = ord(n.data[:1])
button_down = bool(touch & 0x02)
mouse_lifted = bool(touch & 0x01)
_log.info("%s: TOUCH MOUSE status: button_down=%s mouse_lifted=%s", self._device, button_down, mouse_lifted)
else:
_log.info("%s: unknown TOUCH MOUSE %s", self._device, n)
return True
_log.info("%s: unrecognized %s for feature %s (index %02X)", self._device, n, feature, n.sub_id)

View File

@@ -1,3 +0,0 @@
#
# Tests for the logitech.unifying_receiver package.
#

View File

@@ -1,17 +0,0 @@
#
# test loading the hidapi library
#
import logging
import unittest
class Test_Import_HIDAPI(unittest.TestCase):
def test_00_import_hidapi(self):
import hidapi
self.assertIsNotNone(hidapi)
logging.info("hidapi loaded native implementation %s", hidapi.native_implementation)
if __name__ == '__main__':
unittest.main()

View File

@@ -1,33 +0,0 @@
#
#
#
import unittest
import struct
from ..constants import *
class Test_UR_Constants(unittest.TestCase):
def test_10_feature_names(self):
for code in range(0x0000, 0x10000):
feature = struct.pack('!H', code)
name = FEATURE_NAME[feature]
self.assertIsNotNone(name)
self.assertEqual(FEATURE_NAME[code], name)
if name.startswith('UNKNOWN_'):
self.assertEqual(code, struct.unpack('!H', feature)[0])
else:
self.assertTrue(hasattr(FEATURE, name))
self.assertEqual(feature, getattr(FEATURE, name))
def test_20_error_names(self):
for code in range(0, len(ERROR_NAME)):
name = ERROR_NAME[code]
self.assertIsNotNone(name)
# self.assertEqual(code, ERROR_NAME.index(name))
if __name__ == '__main__':
unittest.main()

View File

@@ -1,187 +0,0 @@
#
#
#
import unittest
from .. import base
from ..exceptions import *
from ..constants import *
class Test_UR_Base(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.ur_available = False
cls.handle = None
cls.device = None
@classmethod
def tearDownClass(cls):
if cls.handle:
base.close(cls.handle)
cls.ur_available = False
cls.handle = None
cls.device = None
def test_10_list_receiver_devices(self):
rawdevices = base.list_receiver_devices()
self.assertIsNotNone(rawdevices, "list_receiver_devices returned None")
# self.assertIsInstance(rawdevices, Iterable, "list_receiver_devices should have returned an iterable")
Test_UR_Base.ur_available = len(list(rawdevices)) > 0
def test_20_try_open(self):
if not self.ur_available:
self.fail("No receiver found")
for rawdevice in base.list_receiver_devices():
handle = base.try_open(rawdevice.path)
if handle is None:
continue
self.assertIsInstance(handle, int, "try_open should have returned an int")
if Test_UR_Base.handle is None:
Test_UR_Base.handle = handle
else:
base.close(handle)
base.close(Test_UR_Base.handle)
Test_UR_Base.handle = None
self.fail("try_open found multiple valid receiver handles")
self.assertIsNotNone(self.handle, "no valid receiver handles found")
def test_25_ping_device_zero(self):
if self.handle is None:
self.fail("No receiver found")
w = base.write(self.handle, 0, b'\x00\x10\x00\x00\xAA')
self.assertIsNone(w, "write should have returned None")
reply = base.read(self.handle, base.DEFAULT_TIMEOUT * 3)
self.assertIsNotNone(reply, "None reply for ping")
self.assertIsInstance(reply, tuple, "read should have returned a tuple")
reply_code, reply_device, reply_data = reply
self.assertEqual(reply_device, 0, "got ping reply for valid device")
self.assertGreater(len(reply_data), 4, "ping reply has wrong length: %s" % base._hex(reply_data))
if reply_code == 0x10:
# ping fail
self.assertEqual(reply_data[:3], b'\x8F\x00\x10', "0x10 reply with unknown reply data: %s" % base._hex(reply_data))
elif reply_code == 0x11:
self.fail("Got valid ping from device 0")
else:
self.fail("ping got bad reply code: " + reply)
def test_30_ping_all_devices(self):
if self.handle is None:
self.fail("No receiver found")
devices = []
for device in range(1, 1 + MAX_ATTACHED_DEVICES):
w = base.write(self.handle, device, b'\x00\x10\x00\x00\xAA')
self.assertIsNone(w, "write should have returned None")
reply = base.read(self.handle, base.DEFAULT_TIMEOUT * 3)
self.assertIsNotNone(reply, "None reply for ping")
self.assertIsInstance(reply, tuple, "read should have returned a tuple")
reply_code, reply_device, reply_data = reply
self.assertEqual(reply_device, device, "ping reply for wrong device")
self.assertGreater(len(reply_data), 4, "ping reply has wrong length: %s" % base._hex(reply_data))
if reply_code == 0x10:
# ping fail
self.assertEqual(reply_data[:3], b'\x8F\x00\x10', "0x10 reply with unknown reply data: %s" % base._hex(reply_data))
elif reply_code == 0x11:
# ping ok
self.assertEqual(reply_data[:2], b'\x00\x10', "0x11 reply with unknown reply data: %s" % base._hex(reply_data))
self.assertEqual(reply_data[4:5], b'\xAA')
devices.append(device)
else:
self.fail("ping got bad reply code: " + reply)
if devices:
Test_UR_Base.device = devices[0]
def test_50_request_bad_device(self):
if self.handle is None:
self.fail("No receiver found")
device = 1 if self.device is None else self.device + 1
reply = base.request(self.handle, device, FEATURE.ROOT, FEATURE.FEATURE_SET)
self.assertIsNone(reply, "request returned valid reply")
def test_52_request_root_no_feature(self):
if self.handle is None:
self.fail("No receiver found")
if self.device is None:
self.fail("No devices attached")
reply = base.request(self.handle, self.device, FEATURE.ROOT)
self.assertIsNotNone(reply, "request returned None reply")
self.assertEqual(reply[:2], b'\x00\x00', "request returned for wrong feature id")
def test_55_request_root_feature_set(self):
if self.handle is None:
self.fail("No receiver found")
if self.device is None:
self.fail("No devices attached")
reply = base.request(self.handle, self.device, FEATURE.ROOT, FEATURE.FEATURE_SET)
self.assertIsNotNone(reply, "request returned None reply")
index = reply[:1]
self.assertGreater(index, b'\x00', "FEATURE_SET not available on device " + str(self.device))
def test_57_request_ignore_undhandled(self):
if self.handle is None:
self.fail("No receiver found")
if self.device is None:
self.fail("No devices attached")
fs_index = base.request(self.handle, self.device, FEATURE.ROOT, FEATURE.FEATURE_SET)
self.assertIsNotNone(fs_index)
fs_index = fs_index[:1]
self.assertGreater(fs_index, b'\x00')
global received_unhandled
received_unhandled = None
def _unhandled(code, device, data):
self.assertIsNotNone(code)
self.assertIsInstance(code, int)
self.assertIsNotNone(device)
self.assertIsInstance(device, int)
self.assertIsNotNone(data)
self.assertIsInstance(data, str)
global received_unhandled
received_unhandled = (code, device, data)
base.unhandled_hook = _unhandled
base.write(self.handle, self.device, FEATURE.ROOT + FEATURE.FEATURE_SET)
reply = base.request(self.handle, self.device, fs_index + b'\x00')
self.assertIsNotNone(reply, "request returned None reply")
self.assertNotEquals(reply[:1], b'\x00')
self.assertIsNotNone(received_unhandled, "extra message not received by unhandled hook")
received_unhandled = None
base.unhandled_hook = None
base.write(self.handle, self.device, FEATURE.ROOT + FEATURE.FEATURE_SET)
reply = base.request(self.handle, self.device, fs_index + b'\x00')
self.assertIsNotNone(reply, "request returned None reply")
self.assertNotEquals(reply[:1], b'\x00')
self.assertIsNone(received_unhandled)
del received_unhandled
# def test_90_receiver_missing(self):
# if self.handle is None:
# self.fail("No receiver found")
#
# logging.warn("remove the receiver in 5 seconds or this test will fail")
# import time
# time.sleep(5)
# with self.assertRaises(NoReceiver):
# self.test_30_ping_all_devices()
if __name__ == '__main__':
unittest.main()

View File

@@ -1,134 +0,0 @@
#
#
#
import unittest
import warnings
from .. import api
from ..constants import *
from ..common import *
class Test_UR_API(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.receiver = None
cls.device = None
cls.features = None
cls.device_info = None
@classmethod
def tearDownClass(cls):
if cls.receiver:
cls.receiver.close()
cls.device = None
cls.features = None
cls.device_info = None
def _check(self, check_device=True, check_features=False):
if self.receiver is None:
self.fail("No receiver found")
if check_device and self.device is None:
self.fail("Found no devices attached.")
if check_device and check_features and self.features is None:
self.fail("no feature set available")
def test_00_open_receiver(self):
Test_UR_API.receiver = api.Receiver.open()
self._check(check_device=False)
def test_05_ping_device_zero(self):
self._check(check_device=False)
ok = api.ping(self.receiver.handle, 0)
self.assertIsNotNone(ok, "invalid ping reply")
self.assertFalse(ok, "device zero replied")
def test_10_ping_all_devices(self):
self._check(check_device=False)
devices = []
for devnumber in range(1, 1 + MAX_ATTACHED_DEVICES):
ok = api.ping(self.receiver.handle, devnumber)
self.assertIsNotNone(ok, "invalid ping reply")
if ok:
devices.append(self.receiver[devnumber])
if devices:
Test_UR_API.device = devices[0].number
def test_30_get_feature_index(self):
self._check()
fs_index = api.get_feature_index(self.receiver.handle, self.device, FEATURE.FEATURE_SET)
self.assertIsNotNone(fs_index, "feature FEATURE_SET not available")
self.assertGreater(fs_index, 0, "invalid FEATURE_SET index: " + str(fs_index))
def test_31_bad_feature(self):
self._check()
reply = api.request(self.receiver.handle, self.device, FEATURE.ROOT, params=b'\xFF\xFF')
self.assertIsNotNone(reply, "invalid reply")
self.assertEqual(reply[:5], b'\x00' * 5, "invalid reply")
def test_40_get_device_features(self):
self._check()
features = api.get_device_features(self.receiver.handle, self.device)
self.assertIsNotNone(features, "failed to read features array")
self.assertIn(FEATURE.FEATURE_SET, features, "feature FEATURE_SET not available")
# cache this to simplify next tests
Test_UR_API.features = features
def test_50_get_device_firmware(self):
self._check(check_features=True)
d_firmware = api.get_device_firmware(self.receiver.handle, self.device, self.features)
self.assertIsNotNone(d_firmware, "failed to get device firmware")
self.assertGreater(len(d_firmware), 0, "device reported no firmware")
for fw in d_firmware:
self.assertIsInstance(fw, FirmwareInfo)
def test_52_get_device_kind(self):
self._check(check_features=True)
d_kind = api.get_device_kind(self.receiver.handle, self.device, self.features)
self.assertIsNotNone(d_kind, "failed to get device kind")
self.assertGreater(len(d_kind), 0, "empty device kind")
def test_55_get_device_name(self):
self._check(check_features=True)
d_name = api.get_device_name(self.receiver.handle, self.device, self.features)
self.assertIsNotNone(d_name, "failed to read device name")
self.assertGreater(len(d_name), 0, "empty device name")
def test_59_get_device_info(self):
self._check(check_features=True)
device_info = api.get_device(self.receiver.handle, self.device, features=self.features)
self.assertIsNotNone(device_info, "failed to read full device info")
self.assertIsInstance(device_info, api.PairedDevice)
Test_UR_API.device_info = device_info
def test_60_get_battery_level(self):
self._check(check_features=True)
if FEATURE.BATTERY in self.features:
battery = api.get_device_battery_level(self.receiver.handle, self.device, self.features)
self.assertIsNotNone(battery, "failed to read battery level")
self.assertIsInstance(battery, tuple, "result not a tuple")
else:
warnings.warn("BATTERY feature not supported by device %d" % self.device)
def test_70_list_devices(self):
self._check(check_device=False)
for dev in self.receiver:
self.assertIsNotNone(dev)
self.assertIsInstance(dev, api.PairedDevice)
if __name__ == '__main__':
unittest.main()

7
lib/solaar/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
__version__ = '0.8.7'

386
lib/solaar/cli.py Normal file
View File

@@ -0,0 +1,386 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
import sys
NAME = 'solaar-cli'
from solaar import __version__
#
#
#
def _fail(text):
sys.exit("%s: error: %s" % (NAME, text))
def _require(module, os_package):
try:
__import__(module)
except ImportError:
_fail("missing required package '%s'" % os_package)
#
#
#
def _receiver():
from logitech.unifying_receiver import Receiver
try:
r = Receiver.open()
except Exception as e:
_fail(str(e))
if r is None:
_fail("Logitech Unifying Receiver not found")
return r
def _find_device(receiver, name, may_be_receiver=False):
if len(name) == 1:
try:
number = int(name)
except:
pass
else:
if number in range(1, 1 + receiver.max_devices):
dev = receiver[number]
if dev is None:
_fail("no paired device with number", number)
return dev
if len(name) < 3:
_fail("need at least 3 characters to match a device")
name = name.lower()
if may_be_receiver and ('receiver'.startswith(name) or name == receiver.serial.lower()):
return receiver
for dev in receiver:
if (name == dev.serial.lower() or
name == dev.codename.lower() or
name == str(dev.kind).lower() or
name in dev.name.lower()):
return dev
_fail("no device found matching '%s'" % name)
def _print_receiver(receiver, verbose=False):
paired_count = receiver.count()
if not verbose:
print ("-: Unifying Receiver [%s:%s] with %d devices" % (receiver.path, receiver.serial, paired_count))
return
print ("-: Unifying Receiver")
print (" Device path :", receiver.path)
print (" Serial :", receiver.serial)
for f in receiver.firmware:
print (" %-11s: %s" % (f.kind, f.version))
print (" Has", paired_count, "paired device(s).")
notification_flags = receiver.request(0x8100)
if notification_flags:
notification_flags = ord(notification_flags[0:1]) << 16 | ord(notification_flags[1:2]) << 8
if notification_flags:
from logitech.unifying_receiver import hidpp10
notification_names = hidpp10.NOTIFICATION_FLAG.flag_names(notification_flags)
print (" Enabled notifications: 0x%06X = %s." % (notification_flags, ', '.join(notification_names)))
else:
print (" All notifications disabled.")
if paired_count > 0:
activity = receiver.request(0x83B3)
if activity:
activity = [(d, ord(activity[d - 1:d])) for d in range(1, receiver.max_devices)]
print (" Device activity counters:", ', '.join(('%d=%d' % (d, a)) for d, a in activity if a > 0))
def _print_device(dev, verbose=False):
p = dev.protocol
state = '' if p > 0 else 'inactive'
if not verbose:
print ("%d: %s [%s:%s]" % (dev.number, dev.name, dev.codename, dev.serial), state)
return
print ("%d: %s" % (dev.number, dev.name))
print (" Codename :", dev.codename)
print (" Kind :", dev.kind)
if p == 0:
print (" Protocol : unknown (device is inactive)")
else:
print (" Protocol : HID++ %1.1f" % p)
print (" Polling rate :", dev.polling_rate, "ms")
print (" Wireless PID :", dev.wpid)
print (" Serial number:", dev.serial)
for fw in dev.firmware:
print (" %-11s:" % fw.kind, (fw.name + ' ' + fw.version).strip())
if dev.power_switch_location:
print (" The power switch is located on the", dev.power_switch_location)
from logitech.unifying_receiver import hidpp10, hidpp20
if p > 0:
if dev.features:
print (" Supports %d HID++ 2.0 features:" % len(dev.features))
for index, feature in enumerate(dev.features):
feature = dev.features[index]
flags = dev.request(0x0000, feature.bytes(2))
flags = 0 if flags is None else ord(flags[1:2])
flags = hidpp20.FEATURE_FLAG.flag_names(flags)
print (" %2d: %-20s {%04X} %s" % (index, feature, feature, ', '.join(flags)))
if dev.keys:
print (" Has %d reprogrammable keys:" % len(dev.keys))
for k in dev.keys:
flags = hidpp20.KEY_FLAG.flag_names(k.flags)
print (" %2d: %-20s => %-20s %s" % (k.index, k.key, k.task, ', '.join(flags)))
if p > 0:
battery = hidpp20.get_battery(dev)
if battery is None:
battery = hidpp10.get_battery(dev)
if battery:
charge, status = battery
print (" Battery is %d%% charged," % charge, status)
else:
print (" Battery status unavailable.")
else:
print (" Battery status is unknown (device is inactive).")
#
#
#
def show_devices(receiver, args):
if args.device == 'all':
_print_receiver(receiver, args.verbose)
for dev in receiver:
if args.verbose:
print ("")
_print_device(dev, args.verbose)
else:
dev = _find_device(receiver, args.device, True)
if dev is receiver:
_print_receiver(receiver, args.verbose)
else:
_print_device(dev, args.verbose)
def pair_device(receiver, args):
# get all current devices
known_devices = [dev.number for dev in receiver]
from logitech.unifying_receiver import status
r_status = status.ReceiverStatus(receiver, lambda *args, **kwargs: None)
done = [False]
def _notification_handler(n):
if n.devnumber == 0xFF:
r_status.process_notification(n)
if not r_status.lock_open:
done[0] = True
elif n.sub_id == 0x41 and n.address == 0x04:
if n.devnumber not in known_devices:
r_status.new_device = receiver[n.devnumber]
from logitech.unifying_receiver import base
base.notifications_hook = _notification_handler
# check if it's necessary to set the notification flags
notification_flags = receiver.request(0x8100)
if notification_flags:
# just to see if any bits are set
notification_flags = ord(notification_flags[:1]) + ord(notification_flags[1:2]) + ord(notification_flags[2:3])
if not notification_flags:
# if there are any notifications set, just assume the one we need is already set
receiver.enable_notifications()
receiver.set_lock(False, timeout=20)
print ("Pairing: turn your new device on (timing out in 20 seconds).")
while not done[0]:
n = base.read(receiver.handle, 2000)
if n:
n = base.make_notification(*n)
if n:
_notification_handler(n)
if not notification_flags:
# only clear the flags if they weren't set before, otherwise a
# concurrently running Solaar app will stop working properly
receiver.enable_notifications(False)
base.notifications_hook = None
if r_status.new_device:
dev = r_status.new_device
print ("Paired device %d: %s [%s:%s]" % (dev.number, dev.name, dev.codename, dev.serial))
else:
_fail(r_status[status.ERROR])
def unpair_device(receiver, args):
dev = _find_device(receiver, args.device)
# query these now, it's last chance to get them
number, name, codename, serial = dev.number, dev.name, dev.codename, dev.serial
try:
del receiver[number]
print ("Unpaired %d: %s [%s:%s]" % (number, name, codename, serial))
except Exception as e:
_fail("failed to unpair device %s: %s" % (dev.name, e))
def config_device(receiver, args):
dev = _find_device(receiver, args.device)
# if dev is receiver:
# _fail("no settings for the receiver")
if not dev.settings:
_fail("no settings for %s" % dev.name)
if not args.setting:
print ("[%s:%s]" % (dev.serial, dev.kind))
print ("#", dev.name)
for s in dev.settings:
print ("")
print ("# %s" % s.label)
if s.choices:
print ("# possible values: one of [", ', '.join(str(v) for v in s.choices), "], or higher/lower/highest/max/lowest/min")
else:
print ("# possible values: on/true/t/yes/y/1 or off/false/f/no/n/0")
value = s.read()
if value is None:
print ("# %s = ? (failed to read from device)" % s.name)
else:
print (s.name, "=", value)
return
setting = None
for s in dev.settings:
if args.setting.lower() == s.name.lower():
setting = s
break
if setting is None:
_fail("no setting '%s' for %s" % (args.setting, dev.name))
if args.value is None:
result = setting.read()
if result is None:
_fail("failed to read '%s'" % setting.name)
print ("%s = %s" % (setting.name, setting.read()))
return
from logitech.unifying_receiver import settings as _settings
if setting.kind == _settings.KIND.toggle:
value = args.value
try:
value = bool(int(value))
except:
if value.lower() in ['1', 'true', 'yes', 'on', 't', 'y']:
value = True
elif value.lower() in ['0', 'false', 'no', 'off', 'f', 'n']:
value = False
else:
_fail("don't know how to interpret '%s' as boolean" % value)
elif setting.choices:
value = args.value.lower()
if value in ['higher', 'lower']:
old_value = setting.read()
if old_value is None:
_fail("could not read current value of '%s'" % setting.name)
if value == 'lower':
lower_values = setting.choices[:old_value]
value = lower_values[-1] if lower_values else setting.choices[:][0]
elif value == 'higher':
higher_values = setting.choices[old_value + 1:]
value = higher_values[0] if higher_values else setting.choices[:][-1]
elif value in ('highest', 'max'):
value = setting.choices[:][-1]
elif value in ('lowest', 'min'):
value = setting.choices[:][0]
elif value not in setting.choices:
_fail("possible values for '%s' are: [%s]" % (setting.name, ', '.join(str(v) for v in setting.choices)))
value = setting.choices[value]
else:
raise NotImplemented
result = setting.write(value)
if result is None:
_fail("failed to set '%s' = '%s' [%s]" % (setting.name, value, repr(value)))
print ("%s = %s" % (setting.name, result))
#
#
#
def _parse_arguments():
import argparse
arg_parser = argparse.ArgumentParser(prog=NAME.lower())
arg_parser.add_argument('-d', '--debug', action='count', default=0,
help='print logging messages, for debugging purposes (may be repeated for extra verbosity)')
arg_parser.add_argument('-V', '--version', action='version', version='%(prog)s ' + __version__)
subparsers = arg_parser.add_subparsers(title='commands')
sp = subparsers.add_parser('show', help='show information about paired devices')
sp.add_argument('device', nargs='?', default='all',
help='device to show information about; may be a device number (1..6), a device serial, '
'at least 3 characters of a device\'s name, "receiver", or "all" (the default)')
sp.add_argument('-v', '--verbose', action='store_true',
help='print all available information about the inspected device(s)')
sp.set_defaults(cmd=show_devices)
sp = subparsers.add_parser('config', help='read/write device-specific settings',
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 device serial, '
'or at least 3 characters of a device\'s name')
sp.add_argument('setting', nargs='?',
help='device-specific setting; leave empty to list available settings')
sp.add_argument('value', nargs='?',
help='new value for the setting')
sp.set_defaults(cmd=config_device)
sp = subparsers.add_parser('pair', help='pair a new device',
epilog='The Logitech Unifying Receiver supports up to 6 paired devices at the same time.')
sp.set_defaults(cmd=pair_device)
sp = subparsers.add_parser('unpair', help='unpair a device')
sp.add_argument('device',
help='device to unpair; may be a device number (1..6), a device serial, '
'or at least 3 characters of a device\'s name.')
sp.set_defaults(cmd=unpair_device)
args = arg_parser.parse_args()
import logging
if args.debug > 0:
log_level = logging.WARNING - 10 * args.debug
log_format='%(asctime)s %(levelname)8s %(name)s: %(message)s'
logging.basicConfig(level=max(log_level, logging.DEBUG), format=log_format)
else:
logging.root.addHandler(logging.NullHandler())
logging.root.setLevel(logging.CRITICAL)
return args
def main():
_require('pyudev', 'python-pyudev')
args = _parse_arguments()
receiver = _receiver()
args.cmd(receiver, args)
if __name__ == '__main__':
main()

164
lib/solaar/gtk.py Normal file
View File

@@ -0,0 +1,164 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
NAME = 'Solaar'
from solaar import __version__
#
#
#
def _require(module, os_package):
try:
__import__(module)
except ImportError:
import sys
sys.exit("%s: missing required package '%s'" % (NAME, os_package))
def _parse_arguments():
import argparse
arg_parser = argparse.ArgumentParser(prog=NAME.lower())
arg_parser.add_argument('-S', '--no-systray', action='store_false', dest='systray',
help='do not place an icon in the desktop\'s systray')
arg_parser.add_argument('-N', '--no-notifications', action='store_false', dest='notifications',
help='disable desktop notifications (shown only when in systray)')
arg_parser.add_argument('-d', '--debug', action='count', default=0,
help='print logging messages, for debugging purposes (may be repeated for extra verbosity)')
arg_parser.add_argument('-V', '--version', action='version', version='%(prog)s ' + __version__)
args = arg_parser.parse_args()
import logging
if args.debug > 0:
log_level = logging.WARNING - 10 * args.debug
log_format='%(asctime)s %(levelname)8s [%(threadName)s] %(name)s: %(message)s'
logging.basicConfig(level=max(log_level, logging.DEBUG), format=log_format)
else:
logging.root.addHandler(logging.NullHandler())
logging.root.setLevel(logging.CRITICAL)
return args
def _run(args):
import solaar.ui as ui
# even if --no-notifications is given on the command line, still have to
# check they are available, and decide whether to put the option in the
# systray icon
args.notifications &= args.systray
if args.systray and ui.notify.init(NAME):
ui.action.toggle_notifications.set_active(args.notifications)
if not args.notifications:
ui.notify.uninit()
else:
ui.action.toggle_notifications = None
from solaar.listener import DUMMY, ReceiverListener
window = ui.main_window.create(NAME, DUMMY.name, DUMMY.max_devices, args.systray)
if args.systray:
menu_actions = (ui.action.toggle_notifications,
ui.action.about)
icon = ui.status_icon.create(window, menu_actions)
else:
icon = None
listener = [None]
# initializes the receiver listener
def check_for_listener(notify=False):
# print ("check_for_listener", notify)
listener[0] = None
try:
listener[0] = ReceiverListener.open(status_changed)
except OSError:
ui.error_dialog(window, 'Permissions error',
'Found a possible Unifying Receiver device,\n'
'but did not have permission to open it.')
if listener[0] is None:
if notify:
status_changed(DUMMY)
else:
return True
from gi.repository import Gtk, GObject
from logitech.unifying_receiver import status
# callback delivering status notifications from the receiver/devices to the UI
def status_changed(receiver, device=None, alert=status.ALERT.NONE, reason=None):
if alert & status.ALERT.MED:
GObject.idle_add(window.present)
if window:
GObject.idle_add(ui.main_window.update, window, receiver, device)
if icon:
GObject.idle_add(ui.status_icon.update, icon, receiver, device)
if ui.notify.available:
# always notify on receiver updates
if device is None or alert & status.ALERT.LOW:
GObject.idle_add(ui.notify.show, device or receiver, reason)
if receiver is DUMMY:
GObject.timeout_add(3000, check_for_listener)
GObject.timeout_add(10, check_for_listener, True)
if icon:
GObject.timeout_add(1000, ui.status_icon.check_systray, icon, window)
Gtk.main()
if listener[0]:
listener[0].stop()
listener[0].join()
ui.notify.uninit()
def main():
_require('pyudev', 'python-pyudev')
_require('gi.repository', 'python-gi')
_require('gi.repository.Gtk', 'gir1.2-gtk-3.0')
args = _parse_arguments()
# ensure no more than a single instance runs at a time
import os.path as _path
import os as _os
lock_fd = None
for p in _os.environ.get('XDG_RUNTIME_DIR'), '/run/lock', '/var/lock', _os.environ.get('TMPDIR', '/tmp'):
if p and _path.isdir(p) and _os.access(p, _os.W_OK):
lock_path = _path.join(p, 'solaar.single-instance.%d' % _os.getuid())
try:
lock_fd = open(lock_path, 'wb')
# print ("Single instance lock file is %s" % lock_path)
break
except:
pass
if lock_fd:
import fcntl as _fcntl
try:
_fcntl.flock(lock_fd, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
except IOError as e:
if e.errno == 11:
import sys
sys.exit("solaar: error: Solaar is already running.")
else:
raise
else:
import sys
print ("solaar: warning: failed to create single instance lock file, ignoring.", file=sys.stderr)
try:
_run(args)
finally:
if lock_fd:
_fcntl.flock(lock_fd, _fcntl.LOCK_UN)
lock_fd.close()
if __name__ == '__main__':
main()

157
lib/solaar/listener.py Normal file
View File

@@ -0,0 +1,157 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from logging import getLogger, DEBUG as _DEBUG
_log = getLogger('listener')
del getLogger
from logitech.unifying_receiver import (Receiver,
listener as _listener,
status as _status)
#
#
#
from collections import namedtuple
_GHOST_DEVICE = namedtuple('_GHOST_DEVICE', ['number', 'name', 'kind', 'status', 'max_devices'])
_GHOST_DEVICE.__bool__ = lambda self: False
_GHOST_DEVICE.__nonzero__ = _GHOST_DEVICE.__bool__
del namedtuple
def _ghost(device):
return _GHOST_DEVICE(number=device.number, name=device.name, kind=device.kind, status=None, max_devices=None)
DUMMY = _GHOST_DEVICE(Receiver.number, Receiver.name, None, 'Receiver not found.', Receiver.max_devices)
#
#
#
# how often to poll devices that haven't updated their statuses on their own
# (through notifications)
_POLL_TICK = 60 # seconds
class ReceiverListener(_listener.EventsListener):
"""Keeps the status of a Unifying Receiver.
"""
def __init__(self, receiver, status_changed_callback=None):
super(ReceiverListener, self).__init__(receiver, self._notifications_handler)
self.tick_period = _POLL_TICK
self._last_tick = 0
self.status_changed_callback = status_changed_callback
# make it a bit similar with the regular devices
receiver.kind = None
# replace the
receiver.handle = _listener.ThreadedHandle(receiver.handle, receiver.path)
receiver.status = _status.ReceiverStatus(receiver, self._status_changed)
def has_started(self):
_log.info("notifications listener has started")
self.receiver.enable_notifications()
self.receiver.notify_devices()
self._status_changed(self.receiver, _status.ALERT.LOW)
def has_stopped(self):
_log.info("notifications listener has stopped")
if self.receiver:
self.receiver.enable_notifications(False)
self.receiver.close()
self.receiver = None
self._status_changed(None, _status.ALERT.LOW)
def tick(self, timestamp):
if _log.isEnabledFor(_DEBUG):
_log.debug("polling status: %s %s", self.receiver, list(iter(self.receiver)))
if self._last_tick > 0 and timestamp - self._last_tick > _POLL_TICK * 3:
# if we missed a couple of polls, most likely the computer went into
# sleep, and we have to reinitialize the receiver again
_log.warn("possible sleep detected, closing this listener")
self.stop()
return
self._last_tick = timestamp
# read these in case they haven't been read already
self.receiver.serial, self.receiver.firmware
if self.receiver.status.lock_open:
# don't mess with stuff while pairing
return
for dev in self.receiver:
if dev.status is not None:
dev.status.poll(timestamp)
def _status_changed(self, device, alert=_status.ALERT.NONE, reason=None):
if _log.isEnabledFor(_DEBUG):
_log.debug("status_changed %s: %s %s (%X) %s", device,
None if device is None else 'active' if device.status else 'inactive',
None if device is None else device.status,
alert, reason or '')
if self.status_changed_callback:
r = self.receiver or DUMMY
if device is None or device.kind is None:
# the status of the receiver changed
self.status_changed_callback(r, None, alert, reason)
else:
if device.status is None:
# device was unpaired, and since the object is weakref'ed
# it won't be valid for much longer
device = _ghost(device)
self.status_changed_callback(r, device, alert, reason)
if device.status is None:
# the receiver changed status as well
self.status_changed_callback(r)
def _notifications_handler(self, n):
assert self.receiver
if n.devnumber == 0xFF:
# a receiver notification
if self.receiver.status is not None:
self.receiver.status.process_notification(n)
else:
# a device notification
assert n.devnumber > 0 and n.devnumber <= self.receiver.max_devices
already_known = n.devnumber in self.receiver
dev = self.receiver[n.devnumber]
if not dev:
_log.warn("received %s for invalid device %d: %r", n, n.devnumber, dev)
return
if not already_known:
# read these as soon as possible, they will be used everywhere
dev.protocol, dev.codename
dev.status = _status.DeviceStatus(dev, self._status_changed)
# the receiver changed status as well
self._status_changed(self.receiver)
# status may be None if the device has just been unpaired
if dev.status is not None:
dev.status.process_notification(n)
if self.receiver.status.lock_open and not already_known:
# this should be the first notification after a device was paired
assert n.sub_id == 0x41 and n.address == 0x04
_log.info("pairing detected new device")
self.receiver.status.new_device = dev
def __str__(self):
return '<ReceiverListener(%s,%s)>' % (self.receiver.path, self.receiver.handle)
__unicode__ = __str__
@classmethod
def open(self, status_changed_callback=None):
receiver = Receiver.open()
if receiver:
rl = ReceiverListener(receiver, status_changed_callback)
rl.start()
return rl

47
lib/solaar/ui/__init__.py Normal file
View File

@@ -0,0 +1,47 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
def _look_for_application_icons():
import os.path as _path
import os as _os
import sys as _sys
# print ("path[0] = %s" % _sys.path[0])
prefix_share = _path.normpath(_path.join(_path.realpath(_sys.path[0]), '..'))
src_share = _path.normpath(_path.join(_path.realpath(_sys.path[0]), '..', 'share'))
local_share = _os.environ.get('XDG_DATA_HOME', _path.expanduser('~/.local/share'))
data_dirs = _os.environ.get('XDG_DATA_DIRS', '/usr/local/share:/usr/share')
share_solaar = [prefix_share] + list(_path.join(x, 'solaar') for x in [src_share, local_share] + data_dirs.split(':'))
for location in share_solaar:
# print ("checking %s" % location)
solaar_png = _path.join(location, 'icons', 'solaar-mask.png')
if _path.exists(solaar_png):
_os.environ['XDG_DATA_DIRS'] = location + ':' + data_dirs
# print ('XDG_DATA_DIRS=%s' % _os.environ['XDG_DATA_DIRS'])
break
del _sys
del _os
# del _path
# look for application-specific icons before initializing Gtk
_look_for_application_icons()
from gi.repository import GObject, Gtk
GObject.threads_init()
def error_dialog(window, title, text):
m = Gtk.MessageDialog(window, Gtk.DialogFlags.MODAL, Gtk.MessageType.ERROR, Gtk.ButtonsType.CLOSE, text)
m.set_title(title)
m.run()
m.destroy()
from . import notify, status_icon, main_window

115
lib/solaar/ui/action.py Normal file
View File

@@ -0,0 +1,115 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from gi.repository import Gtk, Gdk
from . import notify, pair_window
from ..ui import error_dialog
_NAME = 'Solaar'
from solaar import __version__
def make(name, label, function, *args):
action = Gtk.Action(name, label, label, None)
action.set_icon_name(name)
if function:
action.connect('activate', function, *args)
return action
def make_toggle(name, label, function, *args):
action = Gtk.ToggleAction(name, label, label, None)
action.set_icon_name(name)
action.connect('activate', function, *args)
return action
#
#
#
def _toggle_notifications(action):
if action.get_active():
notify.init('Solaar')
else:
notify.uninit()
action.set_sensitive(notify.available)
toggle_notifications = make_toggle('notifications', 'Notifications', _toggle_notifications)
def _show_about_window(action):
about = Gtk.AboutDialog()
about.set_icon_name(_NAME.lower())
about.set_program_name(_NAME)
about.set_logo_icon_name(_NAME.lower())
about.set_version(__version__)
about.set_comments('Shows status of devices connected\nto a Logitech Unifying Receiver.')
about.set_copyright(b'\xC2\xA9'.decode('utf-8') + ' 2012 Daniel Pavel')
about.set_license_type(Gtk.License.GPL_2_0)
about.set_authors(('Daniel Pavel http://github.com/pwr',))
try:
about.add_credit_section('Testing', ('Douglas Wagner', 'Julien Gascard'))
about.add_credit_section('Technical specifications\nprovided by',
('Julien Danjou http://julien.danjou.info/blog/2012/logitech-unifying-upower',))
except TypeError:
# gtk3 < 3.6 has incorrect gi bindings
pass
except:
# is the Gtk3 version too old?
pass
about.set_website('http://pwr.github.com/Solaar/')
about.set_website_label('Solaar')
about.run()
about.destroy()
about = make('help-about', 'About ' + _NAME, _show_about_window)
quit = make('application-exit', 'Quit', Gtk.main_quit)
#
#
#
def _pair_device(action, frame):
window = frame.get_toplevel()
pair_dialog = pair_window.create(action, frame._device)
pair_dialog.set_transient_for(window)
pair_dialog.set_destroy_with_parent(True)
pair_dialog.set_modal(True)
pair_dialog.set_type_hint(Gdk.WindowTypeHint.DIALOG)
pair_dialog.set_position(Gtk.WindowPosition.CENTER)
pair_dialog.present()
def pair(frame):
return make('list-add', 'Pair new device', _pair_device, frame)
def _unpair_device(action, frame):
window = frame.get_toplevel()
# window.present()
device = frame._device
qdialog = Gtk.MessageDialog(window, 0,
Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE,
"Unpair device\n%s ?" % device.name)
qdialog.set_icon_name('remove')
qdialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
qdialog.add_button('Unpair', Gtk.ResponseType.ACCEPT)
choice = qdialog.run()
qdialog.destroy()
if choice == Gtk.ResponseType.ACCEPT:
try:
del device.receiver[device.number]
except:
error_dialog(window, 'Unpairing failed', 'Failed to unpair device\n%s .' % device.name)
def unpair(frame):
return make('edit-delete', 'Unpair', _unpair_device, frame)

View File

@@ -0,0 +1,202 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from gi.repository import Gtk, GObject
from logitech.unifying_receiver import settings as _settings
#
#
#
try:
from Queue import Queue as _Queue
except ImportError:
from queue import Queue as _Queue
_apply_queue = _Queue(4)
def _process_apply_queue():
def _write_start(sbox):
_, failed, spinner, control = sbox.get_children()
control.set_sensitive(False)
failed.set_visible(False)
spinner.set_visible(True)
spinner.start()
while True:
task = _apply_queue.get()
assert isinstance(task, tuple)
# print ("task", *task)
if task[0] == 'write':
_, setting, value, sbox = task
GObject.idle_add(_write_start, sbox, priority=0)
value = setting.write(value)
elif task[0] == 'read':
_, setting, force_read, sbox = task
value = setting.read(not force_read)
GObject.idle_add(_update_setting_item, sbox, value, priority=99)
from threading import Thread as _Thread
_queue_processor = _Thread(name='SettingsProcessor', target=_process_apply_queue)
_queue_processor.daemon = True
_queue_processor.start()
#
#
#
def _switch_notify(switch, _, setting, spinner):
# print ("switch notify", switch, switch.get_active(), setting)
if switch.get_sensitive():
# value = setting.write(switch.get_active() == True)
# _update_setting_item(switch.get_parent(), value)
_apply_queue.put(('write', setting, switch.get_active() == True, switch.get_parent()))
def _combo_notify(cbbox, setting, spinner):
# print ("combo notify", cbbox, cbbox.get_active_id(), setting)
if cbbox.get_sensitive():
_apply_queue.put(('write', setting, cbbox.get_active_id(), cbbox.get_parent()))
# def _scale_notify(scale, setting, spinner):
# _apply_queue.put(('write', setting, scale.get_value(), scale.get_parent()))
# def _snap_to_markers(scale, scroll, value, setting):
# value = int(value)
# candidate = None
# delta = 0xFFFFFFFF
# for c in setting.choices:
# d = abs(value - int(c))
# if d < delta:
# candidate = c
# delta = d
# assert candidate is not None
# scale.set_value(int(candidate))
# return True
def _add_settings(box, device):
for s in device.settings:
sbox = Gtk.HBox(homogeneous=False, spacing=8)
sbox.pack_start(Gtk.Label(s.label), False, False, 0)
spinner = Gtk.Spinner()
spinner.set_tooltip_text('Working...')
failed = Gtk.Image.new_from_icon_name('dialog-warning', Gtk.IconSize.SMALL_TOOLBAR)
failed.set_tooltip_text('Failed to read value from the device.')
if s.kind == _settings.KIND.toggle:
control = Gtk.Switch()
control.connect('notify::active', _switch_notify, s, spinner)
elif s.kind == _settings.KIND.choice:
control = Gtk.ComboBoxText()
for entry in s.choices:
control.append(str(entry), str(entry))
control.connect('changed', _combo_notify, s, spinner)
# elif s.kind == _settings.KIND.range:
# first, second = s.choices[:2]
# last = s.choices[-1:][0]
# control = Gtk.HScale.new_with_range(first, last, second - first)
# control.set_draw_value(False)
# control.set_has_origin(False)
# for entry in s.choices:
# control.add_mark(int(entry), Gtk.PositionType.TOP, str(entry))
# control.connect('change-value', _snap_to_markers, s)
# control.connect('value-changed', _scale_notify, s, spinner)
else:
raise NotImplemented
control.set_sensitive(False) # the first read will enable it
sbox.pack_end(control, False, False, 0)
sbox.pack_end(spinner, False, False, 0)
sbox.pack_end(failed, False, False, 0)
if s.description:
sbox.set_tooltip_text(s.description)
sbox.show_all()
spinner.start() # the first read will stop it
failed.set_visible(False)
box.pack_start(sbox, False, False, 0)
yield sbox
def _update_setting_item(sbox, value):
_, failed, spinner, control = sbox.get_children()
spinner.set_visible(False)
spinner.stop()
# print ("update", control, "with new value", value)
if value is None:
control.set_sensitive(False)
failed.set_visible(True)
return
failed.set_visible(False)
if isinstance(control, Gtk.Switch):
control.set_active(value)
elif isinstance(control, Gtk.ComboBoxText):
control.set_active_id(str(value))
# elif isinstance(control, Gtk.Scale):
# control.set_value(int(value))
else:
raise NotImplemented
control.set_sensitive(True)
#
#
#
def create():
b = Gtk.VBox(homogeneous=False, spacing=4)
b.set_property('margin', 8)
return b
def update(frame):
box = frame._config_box
assert box
device = frame._device
if device is None:
# remove all settings widgets
# if another device gets paired here, it will add its own widgets
_remove_children(box)
return
if not box.get_visible():
# no point in doing this right now, is there?
return
if not device.settings:
# nothing to do here
return
force_read = False
items = box.get_children()
if len(device.settings) != len(items):
_remove_children(box)
if device.status:
items = list(_add_settings(box, device))
assert len(device.settings) == len(items)
force_read = True
device_active = bool(device.status)
# if the device just became active, re-read the settings
force_read |= device_active and not box.get_sensitive()
box.set_sensitive(device_active)
if device_active:
for sbox, s in zip(items, device.settings):
_apply_queue.put(('read', s, force_read, sbox))
def _remove_children(container):
container.foreach(lambda x, _: container.remove(x), None)

79
lib/solaar/ui/icons.py Normal file
View File

@@ -0,0 +1,79 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from gi.repository import Gtk
#
#
#
_LARGE_SIZE = 64
Gtk.IconSize.LARGE = Gtk.icon_size_register('large', _LARGE_SIZE, _LARGE_SIZE)
# Gtk.IconSize.XLARGE = Gtk.icon_size_register('x-large', _LARGE_SIZE * 2, _LARGE_SIZE * 2)
# print ("menu", int(Gtk.IconSize.MENU), Gtk.icon_size_lookup(Gtk.IconSize.MENU))
# print ("small toolbar", int(Gtk.IconSize.SMALL_TOOLBAR), Gtk.icon_size_lookup(Gtk.IconSize.SMALL_TOOLBAR))
# print ("large toolbar", int(Gtk.IconSize.LARGE_TOOLBAR), Gtk.icon_size_lookup(Gtk.IconSize.LARGE_TOOLBAR))
# print ("button", int(Gtk.IconSize.BUTTON), Gtk.icon_size_lookup(Gtk.IconSize.BUTTON))
# print ("dnd", int(Gtk.IconSize.DND), Gtk.icon_size_lookup(Gtk.IconSize.DND))
# print ("dialog", int(Gtk.IconSize.DIALOG), Gtk.icon_size_lookup(Gtk.IconSize.DIALOG))
APP_ICON = { 1: 'solaar', 2: 'solaar-mask', 0: 'solaar-init', -1: 'solaar-fail' }
#
#
#
def battery(level):
if level < 0:
return 'battery_unknown'
return 'battery_%03d' % (10 * ((level + 5) // 10))
_ICON_SETS = {}
def device_icon_set(name, kind=None):
icon_set = _ICON_SETS.get(name)
if icon_set is None:
icon_set = Gtk.IconSet.new()
_ICON_SETS[name] = icon_set
names = ['preferences-desktop-peripherals']
if kind:
if str(kind) == 'numpad':
names += ('input-dialpad',)
elif str(kind) == 'touchpad':
names += ('input-tablet',)
elif str(kind) == 'trackball':
names += ('input-mouse',)
names += ('input-' + str(kind),)
theme = Gtk.IconTheme.get_default()
if theme.has_icon(name):
names += (name,)
source = Gtk.IconSource.new()
for n in names:
source.set_icon_name(n)
icon_set.add_source(source)
icon_set.names = names
return icon_set
def device_icon_file(name, kind=None):
icon_set = device_icon_set(name, kind)
assert icon_set
theme = Gtk.IconTheme.get_default()
for n in reversed(icon_set.names):
if theme.has_icon(n):
return theme.lookup_icon(n, _LARGE_SIZE, 0).get_filename()
def icon_file(name, size=_LARGE_SIZE):
theme = Gtk.IconTheme.get_default()
if theme.has_icon(name):
return theme.lookup_icon(name, size, 0).get_filename()

62
lib/solaar/ui/indicate.py Normal file
View File

@@ -0,0 +1,62 @@
#
#
#
# import logging
# try:
# from gi.repository import Indicate
# from time import time as _timestamp
# # import ui
# # necessary because the notifications daemon does not know about our XDG_DATA_DIRS
# _icons = {}
# # def _icon(title):
# # if title not in _icons:
# # _icons[title] = ui.icon_file(title)
# # return _icons.get(title)
# def init(app_title):
# global available
# try:
# s = Indicate.Server()
# s.set_type('message.im')
# s.set_default()
# print s
# s.show()
# s.connect('server-display', server_display)
# i = Indicate.Indicator()
# i.set_property('sender', 'test message sender')
# i.set_property('body', 'test message body')
# i.set_property_time('time', _timestamp())
# i.set_subtype('im')
# print i, i.list_properties()
# i.show()
# i.connect('user-display', display)
# pass
# except:
# available = False
# init('foo')
# # assumed to be working since the import succeeded
# available = True
# def server_display(s):
# print 'server display', s
# def display(i):
# print "indicator display", i
# i.hide()
# except ImportError:
# available = False
# init = lambda app_title: False
# uninit = lambda: None
# show = lambda dev: None

View File

@@ -0,0 +1,438 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from gi.repository import Gtk, Gdk, GObject
from logitech.unifying_receiver import status as _status
from . import config_panel as _config_panel
from . import action as _action, icons as _icons
#
#
#
_RECEIVER_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR
_DEVICE_ICON_SIZE = Gtk.IconSize.DIALOG
_STATUS_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR
_TOOLBAR_ICON_SIZE = Gtk.IconSize.MENU
_PLACEHOLDER = '~'
_FALLBACK_ICON = 'preferences-desktop-peripherals'
#
#
#
def _make_receiver_box(name):
frame = Gtk.Frame()
frame._device = None
frame.set_name(name)
icon_set = _icons.device_icon_set(name)
icon = Gtk.Image.new_from_icon_set(icon_set, _RECEIVER_ICON_SIZE)
icon.set_padding(2, 2)
frame._icon = icon
label = Gtk.Label('Scanning...')
label.set_alignment(0, 0.5)
frame._label = label
pairing_icon = Gtk.Image.new_from_icon_name('network-wireless', _RECEIVER_ICON_SIZE)
pairing_icon.set_tooltip_text('The pairing lock is open.')
pairing_icon._tick = 0
frame._pairing_icon = pairing_icon
toolbar = Gtk.Toolbar()
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
toolbar.set_icon_size(_TOOLBAR_ICON_SIZE)
toolbar.set_show_arrow(False)
frame._toolbar = toolbar
hbox = Gtk.HBox(homogeneous=False, spacing=8)
hbox.pack_start(icon, False, False, 0)
hbox.pack_start(label, True, True, 0)
hbox.pack_start(pairing_icon, False, False, 0)
hbox.pack_start(toolbar, False, False, 0)
info_label = Gtk.Label()
info_label.set_markup('<small>reading ...</small>')
info_label.set_property('margin-left', 36)
info_label.set_alignment(0, 0)
info_label.set_selectable(True)
frame._info_label = info_label
def _update_info_label(f):
device = f._device
if f._info_label.get_visible() and '\n' not in f._info_label.get_text():
items = [('Path', device.path), ('Serial', device.serial)] + \
[(fw.kind, fw.version) for fw in device.firmware]
f._info_label.set_markup('<small><tt>%s</tt></small>' % '\n'.join('%-13s: %s' % item for item in items))
def _toggle_info_label(action, f):
active = action.get_active()
vb = f.get_child()
for c in vb.get_children()[1:]:
c.set_visible(active)
if active:
GObject.timeout_add(50, _update_info_label, f)
toggle_info_action = _action.make_toggle('dialog-information', 'Details', _toggle_info_label, frame)
toolbar.insert(toggle_info_action.create_tool_item(), 0)
toolbar.insert(_action.pair(frame).create_tool_item(), -1)
# toolbar.insert(ui.action.about.create_tool_item(), -1)
vbox = Gtk.VBox(homogeneous=False, spacing=2)
vbox.set_border_width(2)
vbox.pack_start(hbox, True, True, 0)
vbox.pack_start(Gtk.HSeparator(), False, False, 0)
vbox.pack_start(info_label, True, True, 0)
frame.add(vbox)
frame.show_all()
pairing_icon.set_visible(False)
_toggle_info_label(toggle_info_action, frame)
return frame
def _make_device_box(index):
frame = Gtk.Frame()
frame._device = None
frame.set_name(_PLACEHOLDER)
icon = Gtk.Image.new_from_icon_name(_FALLBACK_ICON, _DEVICE_ICON_SIZE)
icon.set_alignment(0.5, 0)
frame._icon = icon
label = Gtk.Label('Initializing...')
label.set_alignment(0, 0.5)
label.set_padding(4, 0)
frame._label = label
battery_icon = Gtk.Image.new_from_icon_name(_icons.battery(-1), _STATUS_ICON_SIZE)
battery_label = Gtk.Label()
battery_label.set_width_chars(6)
battery_label.set_alignment(0, 0.5)
light_icon = Gtk.Image.new_from_icon_name('light_unknown', _STATUS_ICON_SIZE)
light_label = Gtk.Label()
light_label.set_alignment(0, 0.5)
light_label.set_width_chars(8)
not_encrypted_icon = Gtk.Image.new_from_icon_name('security-low', _STATUS_ICON_SIZE)
not_encrypted_icon.set_name('not-encrypted')
not_encrypted_icon.set_tooltip_text('The wireless link between this device and the Unifying Receiver is not encrypted.\n'
'\n'
'For pointing devices (mice, trackballs, trackpads), this is a minor security issue.\n'
'\n'
'It is, however, a major security issue for text-input devices (keyboards, numpads),\n'
'because typed text can be sniffed inconspicuously by 3rd parties within range.')
toolbar = Gtk.Toolbar()
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
toolbar.set_icon_size(_TOOLBAR_ICON_SIZE)
toolbar.set_show_arrow(False)
frame._toolbar = toolbar
status_box = Gtk.HBox(homogeneous=False, spacing=2)
status_box.pack_start(battery_icon, False, True, 0)
status_box.pack_start(battery_label, False, True, 0)
status_box.pack_start(light_icon, False, True, 0)
status_box.pack_start(light_label, False, True, 0)
status_box.pack_end(toolbar, False, False, 0)
status_box.pack_end(not_encrypted_icon, False, False, 0)
frame._status_icons = status_box
status_vbox = Gtk.VBox(homogeneous=False, spacing=4)
status_vbox.pack_start(label, True, True, 0)
status_vbox.pack_start(status_box, True, True, 0)
device_box = Gtk.HBox(homogeneous=False, spacing=4)
# device_box.set_border_width(4)
device_box.pack_start(icon, False, False, 0)
device_box.pack_start(status_vbox, True, True, 0)
device_box.show_all()
info_label = Gtk.Label()
info_label.set_markup('<small>reading ...</small>')
info_label.set_property('margin-left', 54)
info_label.set_selectable(True)
info_label.set_alignment(0, 0)
frame._info_label = info_label
def _update_info_label(f):
if frame._info_label.get_text().count('\n') < 4:
device = f._device
assert device
items = [None, None, None, None, None, None, None, None]
hid = device.protocol
items[0] = ('Protocol', 'HID++ %1.1f' % hid if hid else 'unknown')
items[1] = ('Polling rate', '%d ms' % device.polling_rate)
items[2] = ('Wireless PID', device.wpid)
items[3] = ('Serial', device.serial)
firmware = device.firmware
if firmware:
items[4:] = [(fw.kind, (fw.name + ' ' + fw.version).strip()) for fw in firmware]
frame._info_label.set_markup('<small><tt>%s</tt></small>' % '\n'.join('%-13s: %s' % i for i in items if i))
def _toggle_info_label(action, f):
active = action.get_active()
if active:
# set config toggle button as inactive
f._toolbar.get_children()[-1].set_active(False)
vb = f.get_child()
children = vb.get_children()
children[1].set_visible(active) # separator
children[2].set_visible(active) # info label
if active:
GObject.timeout_add(30, _update_info_label, f)
def _toggle_config(action, f):
active = action.get_active()
if active:
# set info toggle button as inactive
f._toolbar.get_children()[0].set_active(False)
vb = f.get_child()
children = vb.get_children()
children[1].set_visible(active) # separator
children[3].set_visible(active) # config box
children[4].set_visible(active) # unpair button
if active:
GObject.timeout_add(30, _config_panel.update, f)
toggle_info_action = _action.make_toggle('dialog-information', 'Details', _toggle_info_label, frame)
toolbar.insert(toggle_info_action.create_tool_item(), 0)
toggle_config_action = _action.make_toggle('preferences-system', 'Configuration', _toggle_config, frame)
toolbar.insert(toggle_config_action.create_tool_item(), -1)
vbox = Gtk.VBox(homogeneous=False, spacing=2)
vbox.set_border_width(2)
vbox.pack_start(device_box, True, True, 0)
vbox.pack_start(Gtk.HSeparator(), False, False, 0)
vbox.pack_start(info_label, False, False, 0)
frame._config_box = _config_panel.create()
vbox.pack_start(frame._config_box, False, False, 0)
unpair = Gtk.Button('Unpair')
unpair.set_image(Gtk.Image.new_from_icon_name('edit-delete', Gtk.IconSize.BUTTON))
unpair.connect('clicked', _action._unpair_device, frame)
unpair.set_relief(Gtk.ReliefStyle.NONE)
unpair.set_property('margin-left', 106)
unpair.set_property('margin-right', 106)
unpair.set_property('can-focus', False) # exclude from tab-navigation
vbox.pack_end(unpair, False, False, 0)
vbox.show_all()
frame.add(vbox)
_toggle_info_label(toggle_info_action, frame)
_toggle_config(toggle_config_action, frame)
return frame
def create(title, name, max_devices, systray=False):
window = Gtk.Window()
window.set_title(title)
window.set_icon_name(_icons.APP_ICON[0])
window.set_role('status-window')
vbox = Gtk.VBox(homogeneous=False, spacing=12)
vbox.set_border_width(4)
rbox = _make_receiver_box(name)
vbox.add(rbox)
for i in range(1, 1 + max_devices):
dbox = _make_device_box(i)
vbox.add(dbox)
vbox.set_visible(True)
window.add(vbox)
geometry = Gdk.Geometry()
geometry.min_width = 320
geometry.min_height = 32
window.set_geometry_hints(vbox, geometry, Gdk.WindowHints.MIN_SIZE)
window.set_resizable(False)
def _toggle_visible(w, trigger):
if w.get_visible():
# hiding moves the window to 0,0
position = w.get_position()
w.hide()
w.move(*position)
else:
if isinstance(trigger, Gtk.StatusIcon):
x, y = w.get_position()
if x == 0 and y == 0:
# if the window hasn't been shown yet, position it next to the status icon
x, y, _ = Gtk.StatusIcon.position_menu(Gtk.Menu(), trigger)
w.move(x, y)
w.present()
return True
def _set_has_systray(w, systray):
# print ("set has systray", systray, w._has_systray)
if systray != w._has_systray:
w._has_systray = systray
if systray:
if w._delete_event_connection is None or not w.get_skip_taskbar_hint():
w.set_skip_taskbar_hint(True)
w.set_skip_pager_hint(True)
if w._delete_event_connection:
w.disconnect(w._delete_event_connection)
w._delete_event_connection = w.connect('delete-event', _toggle_visible)
else:
if w._delete_event_connection is None or w.get_skip_taskbar_hint():
w.set_skip_taskbar_hint(False)
w.set_skip_pager_hint(False)
if w._delete_event_connection:
w.disconnect(w._delete_event_connection)
w._delete_event_connection = w.connect('delete-event', Gtk.main_quit)
w.present()
from types import MethodType
window.toggle_visible = MethodType(_toggle_visible, window)
window.set_has_systray = MethodType(_set_has_systray, window)
del MethodType
window.set_keep_above(True)
window._delete_event_connection = None
window._has_systray = None
window.set_has_systray(systray)
return window
#
#
#
def _update_receiver_box(frame, receiver):
frame._label.set_text(str(receiver.status))
if receiver:
frame._device = receiver
frame._icon.set_sensitive(True)
if receiver.status.lock_open:
if frame._pairing_icon._tick == 0:
def _pairing_tick(i, s):
if s and s.lock_open:
i.set_sensitive(bool(i._tick % 2))
i._tick += 1
return True
i.set_visible(False)
i.set_sensitive(True)
i._tick = 0
frame._pairing_icon.set_visible(True)
GObject.timeout_add(1000, _pairing_tick, frame._pairing_icon, receiver.status)
else:
frame._pairing_icon.set_visible(False)
frame._pairing_icon.set_sensitive(True)
frame._pairing_icon._tick = 0
frame._toolbar.set_sensitive(True)
else:
frame._device = None
frame._icon.set_sensitive(False)
frame._pairing_icon.set_visible(False)
frame._toolbar.set_sensitive(False)
frame._toolbar.get_children()[0].set_active(False)
frame._info_label.set_text('')
def _update_device_box(frame, dev):
if dev is None:
frame.set_visible(False)
frame.set_name(_PLACEHOLDER)
frame._device = None
_config_panel.update(frame)
return
first_run = frame.get_name() != dev.name
if first_run:
frame._device = dev
frame.set_name(dev.name)
icon_set = _icons.device_icon_set(dev.name, dev.kind)
frame._icon.set_from_icon_set(icon_set, _DEVICE_ICON_SIZE)
frame._label.set_markup('<b>%s</b>' % dev.name)
for i in frame._toolbar.get_children():
i.set_active(False)
battery_icon, battery_label, light_icon, light_label, not_encrypted_icon, _ = frame._status_icons
battery_level = dev.status.get(_status.BATTERY_LEVEL)
if dev.status:
frame._label.set_sensitive(True)
if battery_level is None:
battery_icon.set_sensitive(False)
battery_icon.set_from_icon_name(_icons.battery(-1), _STATUS_ICON_SIZE)
battery_label.set_markup('<small>no status</small>')
battery_label.set_sensitive(True)
else:
battery_icon.set_from_icon_name(_icons.battery(battery_level), _STATUS_ICON_SIZE)
battery_icon.set_sensitive(True)
battery_label.set_text('%d%%' % battery_level)
battery_label.set_sensitive(True)
battery_status = dev.status.get(_status.BATTERY_STATUS)
battery_icon.set_tooltip_text(battery_status or '')
light_level = dev.status.get(_status.LIGHT_LEVEL)
if light_level is None:
light_icon.set_visible(False)
light_label.set_visible(False)
else:
icon_name = 'light_%03d' % (20 * ((light_level + 50) // 100))
light_icon.set_from_icon_name(icon_name, _STATUS_ICON_SIZE)
light_icon.set_visible(True)
light_label.set_text('%d lux' % light_level)
light_label.set_visible(True)
not_encrypted_icon.set_visible(dev.status.get(_status.ENCRYPTED) == False)
else:
frame._label.set_sensitive(False)
battery_icon.set_sensitive(False)
battery_label.set_sensitive(False)
if battery_level is None:
battery_label.set_markup('<small>inactive</small>')
else:
battery_label.set_markup('%d%%' % battery_level)
light_icon.set_visible(False)
light_label.set_visible(False)
not_encrypted_icon.set_visible(False)
frame._toolbar.get_children()[-1].set_active(False)
frame.set_visible(True)
_config_panel.update(frame)
def update(window, receiver, device=None):
assert receiver is not None
# print ("update", receiver, receiver.status, len(receiver), device)
window.set_icon_name(_icons.APP_ICON[1 if receiver else -1])
vbox = window.get_child()
frames = list(vbox.get_children())
assert len(frames) == 1 + receiver.max_devices, frames
if device is None:
_update_receiver_box(frames[0], receiver)
if not receiver:
for frame in frames[1:]:
_update_device_box(frame, None)
else:
_update_device_box(frames[device.number], None if device.status is None else device)

View File

@@ -2,30 +2,24 @@
# Optional desktop notifications.
#
import logging
from __future__ import absolute_import, division, print_function, unicode_literals
try:
# this import is allowed to fail, in which case the entire feature is unavailable
from gi.repository import Notify
import logging
import ui
from logitech.devices.constants import STATUS
from . import icons as _icons
# necessary because the notifications daemon does not know about our XDG_DATA_DIRS
_icons = {}
def _icon(title):
if title not in _icons:
_icons[title] = ui.icon_file(title)
return _icons.get(title)
# assumed to be working since the import succeeded
available = True
# cache references to shown notifications here, so if another status comes
# while its notification is still visible we don't create another one
_notifications = {}
def init(app_title):
"""Init the notifications system."""
global available
@@ -47,7 +41,7 @@ try:
Notify.uninit()
def show(dev):
def show(dev, reason=None):
"""Show a notification with title and text."""
if available and Notify.is_initted():
summary = dev.name
@@ -57,8 +51,13 @@ try:
if n is None:
n = _notifications[summary] = Notify.Notification()
n.update(summary, dev.status_text, _icon(summary) or dev.kind)
urgency = Notify.Urgency.LOW if dev.status > STATUS.CONNECTED else Notify.Urgency.NORMAL
message = reason or ('unpaired' if dev.status is None else
(str(dev.status) or ('connected' if dev.status else 'inactive')))
# we need to use the filename here because the notifications daemon
# is an external application that does not know about our icon sets
n.update(summary, message, _icons.device_icon_file(dev.name, dev.kind))
urgency = Notify.Urgency.LOW if dev.status else Notify.Urgency.NORMAL
n.set_urgency(urgency)
try:
@@ -68,8 +67,7 @@ try:
logging.exception("showing %s", n)
except ImportError:
logging.warn("desktop notifications disabled")
available = False
init = lambda app_title: False
uninit = lambda: None
show = lambda dev: None
show = lambda dev, reason: None

View File

@@ -0,0 +1,198 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from gi.repository import Gtk, GObject
from logging import getLogger, DEBUG as _DEBUG
_log = getLogger('pair-window')
del getLogger
from . import icons as _icons
from logitech.unifying_receiver import status as _status
#
#
#
_PAIRING_TIMEOUT = 30
def _create_page(assistant, kind, header=None, icon_name=None, text=None):
p = Gtk.VBox(False, 8)
assistant.append_page(p)
assistant.set_page_type(p, kind)
if header:
item = Gtk.HBox(False, 16)
p.pack_start(item, False, True, 0)
label = Gtk.Label(header)
label.set_alignment(0, 0)
label.set_line_wrap(True)
item.pack_start(label, True, True, 0)
if icon_name:
icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
icon.set_alignment(1, 0)
item.pack_start(icon, False, False, 0)
if text:
label = Gtk.Label(text)
label.set_alignment(0, 0)
label.set_line_wrap(True)
p.pack_start(label, False, False, 0)
p.show_all()
return p
# def _fake_device(receiver):
# from logitech.unifying_receiver import PairedDevice
# dev = PairedDevice(receiver, 6)
# dev._wpid = '1234'
# dev._kind = 'touchpad'
# dev._codename = 'T650'
# dev._name = 'Wireless Rechargeable Touchpad T650'
# dev._serial = '0123456789'
# dev._protocol = 2.0
# dev.status = _status.DeviceStatus(dev, lambda *foo: None)
# dev.status['encrypted'] = False
# return dev
def _check_lock_state(assistant, receiver):
if not assistant.is_drawable():
if _log.isEnabledFor(_DEBUG):
_log.debug("assistant %s destroyed, bailing out", assistant)
return False
if receiver.status.get(_status.ERROR):
# receiver.status.new_device = _fake_device(receiver)
_pairing_failed(assistant, receiver, receiver.status.pop(_status.ERROR))
return False
if receiver.status.new_device:
device, receiver.status.new_device = receiver.status.new_device, None
_pairing_succeeded(assistant, receiver, device)
return False
if not receiver.status.lock_open:
_pairing_failed(assistant, receiver, 'failed to open pairing lock')
return False
return True
def _prepare(assistant, page, receiver):
index = assistant.get_current_page()
if _log.isEnabledFor(_DEBUG):
_log.debug("prepare %s %d %s", assistant, index, page)
if index == 0:
if receiver.set_lock(False, timeout=_PAIRING_TIMEOUT):
assert receiver.status.new_device is None
assert receiver.status.get(_status.ERROR) is None
spinner = page.get_children()[-1]
spinner.start()
GObject.timeout_add(2000, _check_lock_state, assistant, receiver)
assistant.set_page_complete(page, True)
else:
GObject.idle_add(_pairing_failed, assistant, receiver, 'the pairing lock did not open')
else:
assistant.remove_page(0)
def _finish(assistant, receiver):
if _log.isEnabledFor(_DEBUG):
_log.debug("finish %s", assistant)
assistant.destroy()
receiver.status.new_device = None
if receiver.status.lock_open:
receiver.set_lock()
else:
receiver.status[_status.ERROR] = None
def _pairing_failed(assistant, receiver, error):
if _log.isEnabledFor(_DEBUG):
_log.debug("%s fail: %s", receiver, error)
assistant.commit()
header = 'Pairing failed: %s.' % error
if 'timeout' in str(error):
text = 'Make sure your device is within range,\nand it has a decent battery charge.'
else:
text = None
_create_page(assistant, Gtk.AssistantPageType.SUMMARY, header, 'dialog-error', text)
assistant.next_page()
assistant.commit()
def _pairing_succeeded(assistant, receiver, device):
assert device
if _log.isEnabledFor(_DEBUG):
_log.debug("%s success: %s", receiver, device)
page = _create_page(assistant, Gtk.AssistantPageType.SUMMARY)
header = Gtk.Label('Found a new device:')
header.set_alignment(0.5, 0)
page.pack_start(header, False, False, 0)
device_icon = Gtk.Image()
icon_set = _icons.device_icon_set(device.name, device.kind)
device_icon.set_from_icon_set(icon_set, Gtk.IconSize.LARGE)
device_icon.set_alignment(0.5, 1)
page.pack_start(device_icon, True, True, 0)
device_label = Gtk.Label()
device_label.set_markup('<b>%s</b>' % device.name)
device_label.set_alignment(0.5, 0)
page.pack_start(device_label, True, True, 0)
hbox = Gtk.HBox(False, 8)
hbox.pack_start(Gtk.Label(' '), False, False, 0)
hbox.set_property('expand', False)
hbox.set_property('halign', Gtk.Align.CENTER)
page.pack_start(hbox, False, False, 0)
def _check_encrypted(dev):
if assistant.is_drawable():
if device.status.get('encrypted') == False:
hbox.pack_start(Gtk.Image.new_from_icon_name('security-low', Gtk.IconSize.MENU), False, False, 0)
hbox.pack_start(Gtk.Label('The wireless link is not encrypted!'), False, False, 0)
hbox.show_all()
else:
return True
GObject.timeout_add(500, _check_encrypted, device)
page.show_all()
assistant.next_page()
assistant.commit()
def create(action, receiver):
assistant = Gtk.Assistant()
assistant.set_title(action.get_label())
assistant.set_icon_name(action.get_icon_name())
assistant.set_size_request(400, 240)
assistant.set_resizable(False)
assistant.set_role('pair-device')
page_intro = _create_page(assistant, Gtk.AssistantPageType.PROGRESS,
'Turn on the device you want to pair.', 'preferences-desktop-peripherals',
'If the device is already turned on,\nturn if off and on again.')
spinner = Gtk.Spinner()
spinner.set_visible(True)
page_intro.pack_end(spinner, True, True, 24)
assistant.connect('prepare', _prepare, receiver)
assistant.connect('cancel', _finish, receiver)
assistant.connect('close', _finish, receiver)
return assistant

View File

@@ -0,0 +1,132 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from gi.repository import Gtk, GObject, GdkPixbuf
from . import action as _action, icons as _icons
from logitech.unifying_receiver import status as _status
#
#
#
_NO_DEVICES = [None] * 6
def create(window, menu_actions=None):
name = window.get_title()
icon = Gtk.StatusIcon()
icon.set_title(name)
icon.set_name(name)
icon.set_from_icon_name(_icons.APP_ICON[0])
icon._devices = list(_NO_DEVICES)
icon.set_tooltip_text(name)
icon.connect('activate', window.toggle_visible)
menu = Gtk.Menu()
for a in menu_actions or ():
if a:
menu.append(a.create_menu_item())
menu.append(_action.quit.create_menu_item())
menu.show_all()
icon.connect('popup_menu',
lambda icon, button, time, menu:
menu.popup(None, None, icon.position_menu, icon, button, time),
menu)
return icon
def check_systray(icon, window):
# use size-changed to detect if the systray is available or not
def _size_changed(i, size, w):
import logging
logging.info("size-chagend %s %s", size, w)
def _check_systray(i2, w2):
logging.info("check_systray %s %s", i2.is_embedded(), i2.get_visible())
w2.set_has_systray(i2.is_embedded() and i2.get_visible())
# first guess
GObject.timeout_add(250, _check_systray, i, w)
# just to make sure...
# GObject.timeout_add(1000, _check_systray, i, w)
_size_changed(icon, None, window)
icon.connect('size-changed', _size_changed, window)
_PIXMAPS = {}
def _icon_with_battery(level, active):
battery_icon = _icons.battery(level)
name = '%s-%s' % (battery_icon, active)
if name not in _PIXMAPS:
mask = _icons.icon_file(_icons.APP_ICON[2], 128)
assert mask
mask = GdkPixbuf.Pixbuf.new_from_file(mask)
assert mask.get_width() == 128 and mask.get_height() == 128
mask.saturate_and_pixelate(mask, 0.7, False)
battery = _icons.icon_file(battery_icon, 128)
assert battery
battery = GdkPixbuf.Pixbuf.new_from_file(battery)
assert battery.get_width() == 128 and battery.get_height() == 128
if not active:
battery.saturate_and_pixelate(battery, 0, True)
# TODO can the masking be done at runtime?
battery.composite(mask, 0, 7, 80, 121, -32, 7, 1, 1, GdkPixbuf.InterpType.NEAREST, 255)
_PIXMAPS[name] = mask
return _PIXMAPS[name]
def update(icon, receiver, device=None):
# print ("icon update", receiver, receiver.status, len(receiver), device)
if device is not None:
icon._devices[device.number] = None if device.status is None else device
if not receiver:
icon._devices[:] = _NO_DEVICES
if not icon.is_embedded():
return
def _lines(r, devices):
yield '<b>Solaar</b>: %s' % r.status
yield ''
for dev in devices:
if dev is None:
continue
yield '<b>%s</b>' % dev.name
assert hasattr(dev, 'status') and dev.status is not None
p = str(dev.status)
if p: # does it have any properties to print?
if dev.status:
yield '\t%s' % p
else:
yield '\t%s <small>(inactive)</small>' % p
else:
if dev.status:
yield '\t<small>no status</small>'
else:
yield '\t<small>(inactive)</small>'
yield ''
icon.set_tooltip_markup('\n'.join(_lines(receiver, icon._devices)).rstrip('\n'))
battery_status = None
battery_level = 1000
for dev in icon._devices:
if dev is not None:
level = dev.status.get(_status.BATTERY_LEVEL)
if level is not None and level < battery_level:
battery_status = dev.status
battery_level = level
if battery_status is None:
icon.set_from_icon_name(_icons.APP_ICON[1 if receiver else -1])
else:
icon.set_from_pixbuf(_icon_with_battery(battery_level, bool(battery_status)))

View File

@@ -1,7 +0,0 @@
#!/bin/sh
cd -P `dirname "$0"`
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$PWD/native/`uname -m`
exec python -m unittest discover -v "$@"

35
packaging/build_deb.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/sh
set -e
if test ! -r "$HOME/.devscripts"; then
echo "$HOME/.descripts must exist"
fi
cd `dirname "$0"`/..
DEBIAN_FILES="$PWD/packaging/debian"
DIST="$PWD/dist/${DISTRIBUTION:=debian}"
BUILD_DIR="${TMPDIR:-/tmp}/$DIST"
rm -rf "$BUILD_DIR"
mkdir -m 0700 -p "$BUILD_DIR"
python "setup.py" sdist --dist-dir="$BUILD_DIR" --formats=gztar
cd "$BUILD_DIR"
S=`ls -1 solaar-*.tar.gz`
VERSION=${S#solaar-}
VERSION=${VERSION%.tar.gz}
tar xfz "$S"
mv "$S" solaar_$VERSION.orig.tar.gz
cd solaar-$VERSION
cp -a "$DEBIAN_FILES" .
test -n "$DEBIAN_FILES_EXTRA" && cp -a $DEBIAN_FILES_EXTRA/* debian/
debuild ${DEBUILD_ARGS:-$@}
rm -rf "$DIST"
mkdir -p "$DIST"
cp -a ../solaar_$VERSION* "$DIST"
cd "$DIST"

30
packaging/build_ppa.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/sh
set -e
cd `dirname "$0"`/..
DISTRIBUTION=ubuntu
DEBIAN_FILES_EXTRA="$PWD/packaging/ubuntu"
. "$HOME/.devscripts"
DEBIAN_CHANGELOG="$PWD/packaging/debian/changelog"
PPA_CHANGELOG="$DEBIAN_FILES_EXTRA/changelog"
latest=`head -n 1 "$DEBIAN_CHANGELOG" | sed -e 's#(\([^)]*\))#(\1ppa1)#; s#UNRELEASED#precise#'`
cat - "$DEBIAN_CHANGELOG" > "$PPA_CHANGELOG" <<_CHANGELOG
$latest
* Customized debian/ for ubuntu launchpad ppa.
-- $DEBFULLNAME <$DEBMAIL> $(date -R)
_CHANGELOG
DEBUILD_ARGS="-S -sa"
. packaging/build_deb.sh
rm -f "$PPA_CHANGELOG"
#dput solaar-ppa solaar_*_source.changes

View File

@@ -0,0 +1,5 @@
solaar (0.8.7-1) UNRELEASED; urgency=low
* Debian packaging scripts, supports ubuntu ppa as well.
-- Daniel Pavel <daniel.pavel@gmail.com> Tue, 18 Jan 2013 18:36:00 +0200

1
packaging/debian/compat Normal file
View File

@@ -0,0 +1 @@
8

21
packaging/debian/control Normal file
View File

@@ -0,0 +1,21 @@
Source: solaar
Section: misc
Priority: optional
Maintainer: Daniel Pavel <daniel.pavel@gmail.com>
Build-Depends: debhelper (>= 8)
Build-Depends-Indep: python
X-Python-Version: >= 2.7
X-Python3-Version: >= 3.2
Standards-Version: 3.9.4
Homepage: http://pwr.github.com/Solaar
Vcs-Git: git://github.com/pwr/Solaar.git
Vcs-browser: http://github.com/pwr/Solaar
Package: solaar
Architecture: all
Depends: ${misc:Depends}, ${python:Depends}, python-pyudev (>= 0.13), python-gi (>= 3.2), gir1.2-gtk-3.0 (>= 3.4), adduser, udev
Suggests: gir1.2-notify-0.7
Description: Logitech Unifying Receiver peripherals manager for Linux
Solaar is a Linux device manager for Logitech's Unifying Receiver peripherals.
It is able to pair/unpair devices to the receiver, and for some devices read
battery status.

View File

@@ -0,0 +1,39 @@
Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0
Upstream-Name: Solaar
Upstream-Contact: Daniel Pavel <daniel.pavel@gmail.com>
Upstream-Source: http://github.com/pwr/Solaar
Files: *
Copyright: Copyright (C) 2012 Daniel Pavel
License: GPL-2
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License version 2 as
published by the Free Software Foundation.
.
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 St, Fifth Floor, Boston, MA 02110-1301, USA.
.
On Debian systems, the complete text of the GNU General Public License,
version 2, can be found in /usr/share/common-licenses/GPL-2.
Files: share/icons/solaar*.png
Copyright: Copyright (C) 2012 Daniel Pavel
License: LGPL
.
Files: share/icons/light_*.png
Copyright: GNOME Project
License: LGPL
These files were copied from the Gnome icon theme (weather-*).
Files: share/icons/battery_*.png
Copyright: Oxygen Icons
Copyright (C) 2012 Daniel Pavel
License: LGPL
Based on icons from the Oxygen icon theme, with some modifications.

23
packaging/debian/rules Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/make -f
# -*- makefile -*-
# Uncomment this to turn on verbose mode.
#export DH_VERBOSE=1
#export DH_OPTIONS=-v
PREFIX = /usr
-include debian/rules.extra
%:
# Adding the required helpers
dh $@ --with=python2
override_dh_auto_install:
dh_auto_install -- --prefix=$(PREFIX) --install-lib=$(PREFIX)/share/solaar/lib
override_dh_python2:
dh_python2 $(PREFIX)/share/solaar
override_dh_installudev:
cp rules.d/??-logitech-unifying-receiver.rules debian/solaar.logitech-unifying-receiver.udev
dh_installudev --priority=99 --name=logitech-unifying-receiver

View File

@@ -0,0 +1 @@
# this file is included by debian/rules

View File

@@ -0,0 +1,10 @@
#!/bin/sh
set -e
# creating plugdev group if he isn't already there
if ! getent group plugdev >/dev/null; then
addgroup --system plugdev
fi
#DEBHELPER#

View File

@@ -0,0 +1 @@
3.0 (quilt)

2
packaging/debian/watch Normal file
View File

@@ -0,0 +1,2 @@
version=3
http://github.com/pwr/solaar/tags .*/v?(\d.*)\.(?:tgz|tar\.(?:gz|bz2|xz))

View File

@@ -0,0 +1,53 @@
# Copyright 1999-2012 Gentoo Foundation
# Distributed under the terms of the GNU General Public License v2
# $Header: $
EAPI=5
PYTHON_COMPAT=( python{2_7,3_2} )
inherit distutils-r1 udev user linux-info gnome2-utils
DESCRIPTION="Solaar is a Linux device manager for Logitech's Unifying Receiver peripherals"
HOMEPAGE="http://pwr.github.com/Solaar/"
SRC_URI="https://github.com/pwr/Solaar/archive/${PV}.tar.gz"
LICENSE="GPL-2"
SLOT="0"
KEYWORDS="~amd64"
IUSE="doc"
RDEPEND="${PYTHON_DEPS}
dev-python/pyudev
dev-python/pygobject[${PYTHON_USEDEP}]"
MY_P="Solaar-${PV}"
S="${WORKDIR}/${MY_P}"
DOCS=( README.md COPYING COPYRIGHT ChangeLog )
pkg_setup() {
enewgroup plugdev
CONFIG_CHECK="HID_LOGITECH_DJ"
linux-info_pkg_setup
}
src_install() {
distutils-r1_src_install
udev_dorules rules.d/*.rules
if use doc; then
dodoc -r docs/*
fi
}
pkg_postinst() {
gnome2_icon_cache_update
elog "To be able to use this application, the user must be on the plugdev group."
}
pkg_preinst() { gnome2_icon_savelist; }
pkg_postrm() { gnome2_icon_cache_update; }

View File

@@ -0,0 +1,11 @@
# this file is included by debian/rules
PREFIX = /opt/extras.ubuntu.com/solaar
# hacky...
override_dh_link:
dh_link
sed -i -e 's#Exec=solaar#Exec=/opt/extras.ubuntu.com/solaar/bin/solaar#' \
debian/solaar/opt/extras.ubuntu.com/solaar/share/applications/solaar.desktop
sed -i -e 's#Icon=solaar#Icon=/opt/extras.ubuntu.com/solaar/share/icons/solaar.png#' \
debian/solaar/opt/extras.ubuntu.com/solaar/share/applications/solaar.desktop

View File

@@ -0,0 +1 @@
opt/extras.ubuntu.com/solaar/share/applications/solaar.desktop usr/share/applications/extras-solaar.desktop

45
rules.d/install.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/sh
set -e
Z=`readlink -f "$0"`
RULES_D=/etc/udev/rules.d
if ! test -d "$RULES_D"; then
echo "$RULES_D not found; is udev installed?"
exit 1
fi
RULE=99-logitech-unifying-receiver.rules
if test -n "$1"; then
SOURCE="$1"
else
SOURCE="`dirname "$Z"`/$RULE"
if ! id -G -n | grep -q -F plugdev; then
GROUP=$(id -g -n)
echo "User '$USER' does not belong to the 'plugdev' group, will use group '$GROUP' in the udev rule."
TEMP_RULE="${TMPDIR:-/tmp}/$$-$RULE"
cp -f "$SOURCE" "$TEMP_RULE"
SOURCE=$TEMP_RULE
sed -i -e "s/GROUP=\"plugdev\"/GROUP=\"$GROUP\"/" "$SOURCE"
fi
fi
if test "`id -u`" != "0"; then
echo "Switching to root to install the udev rule."
test -x /usr/bin/pkexec && exec /usr/bin/pkexec "$Z" "$SOURCE"
test -x /usr/bin/sudo && exec /usr/bin/sudo -- "$Z" "$SOURCE"
test -x /bin/su && exec /bin/su -c "\"$Z\" \"$SOURCE\""
echo "Could not switch to root: none of pkexec, sudo or su were found?"
exit 1
fi
echo "Installing $RULE."
cp "$SOURCE" "$RULES_D/$RULE"
chmod a+r "$RULES_D/$RULE"
echo "Reloading udev rules."
udevadm control --reload-rules
echo "Done. Now remove the Unfiying Receiver, wait 10 seconds and plug it in again."

45
setup.py Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env python
from glob import glob
from distutils.core import setup
setup(name='solaar',
version='0.8.7',
description='Linux devices manager for the Logitech Unifying Receiver.',
long_description='''
Solaar is a Linux device manager for Logitech's Unifying Receiver peripherals.
It is able to pair/unpair devices to the receiver, and for some devices read
battery status.
'''.strip(),
author='Daniel Pavel',
author_email='daniel.pavel@gmail.com',
license='GPLv2',
url='http://pwr.github.com/Solaar/',
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: X11 Applications :: GTK',
'Environment :: Console',
'Intended Audience :: End Users/Desktop',
'License :: DFSG approved',
'License :: OSI Approved :: GNU General Public License v2 (GPLv2)',
'Natural Language :: English',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.2',
'Operating System :: POSIX :: Linux',
'Topic :: Utilities',
],
platforms=['linux'],
requires=['pyudev (>= 0.13)', 'gi.repository.GObject (>= 2.0)', 'gi.repository.Gtk (>= 3.0)'],
package_dir={'': 'lib'},
packages=['hidapi', 'logitech', 'logitech.unifying_receiver', 'solaar', 'solaar.ui'],
data_files=[('share/icons', ['share/solaar/icons/solaar.png']),
('share/applications', ['share/applications/solaar.desktop']),
('share/solaar/icons', glob('share/solaar/icons/*.png')),
],
scripts=glob('bin/*'),
)

View File

@@ -0,0 +1,10 @@
[Desktop Entry]
Name=Solaar
Comment=Logitech Unifying Receiver peripherals manager
Exec=solaar
Icon=solaar
StartupNotify=false
Terminal=false
Type=Application
Categories=Utility;GTK;
#Categories=Utility;GTK;Settings;HardwareSettings;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

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