Compare commits

...

68 Commits
0.7.2 ... 0.8.3

Author SHA1 Message Date
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
62 changed files with 4041 additions and 2610 deletions

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

101
README.md Normal file
View File

@@ -0,0 +1,101 @@
**Solaar** is a Linux device manager for Logitech's
[Unifying Receiver](http://www.logitech.com/en-us/66/6079) peripherals.
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. Depending on the
device, it may be able to read its battery status.
A few devices also have extended support, mostly because I was able to directly
test on them:
* The [K750 Solar Keyboard](http://www.logitech.com/keyboards/keyboard/devices/7454)
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.
Also, you can change the way the function keys (`F1`..`F12`) work, i.e.
whether holding `FN` while pressing the function keys will generate the
standard keycodes or the special function (yellow icons) keycodes.
* The [M705 Marathon Mouse](http://www.logitech.com/product/marathon-mouse-m705)
supports turning on/off Smooth Scrolling (higher sensitivity on vertical
scrolling with the wheel).
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 test on right now.
## 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
- When running under Ubuntu's Unity, the tray icon will probably not appear, nor
will the application window. Either run the application with the '-S' option,
or whitelist "Solaar" into the systray. For details, see
[How do I access and enable more icons to be in the system tray?](http://askubuntu.com/questions/30742/how-do-i-access-and-enable-more-icons-to-be-in-the-system-tray).
Support for Unity's indicators is a planned feature.
- 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)

152
app/listener.py Normal file
View File

@@ -0,0 +1,152 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from logging import getLogger, DEBUG as _DEBUG
_log = getLogger('listener')
del getLogger
from types import MethodType as _MethodType
from logitech.unifying_receiver import (Receiver,
listener as _listener,
status as _status)
#
#
#
class _DUMMY_RECEIVER(object):
__slots__ = []
name = Receiver.name
kind = None
max_devices = Receiver.max_devices
status = 'Receiver not found.'
__bool__ = __nonzero__ = lambda self: False
__unicode__ = __str__ = __repr__ = lambda self: 'DUMMY'
DUMMY = _DUMMY_RECEIVER()
from collections import namedtuple
_GHOST_DEVICE = namedtuple('_GHOST_DEVICE', ['number', 'name', 'kind', 'status'])
del namedtuple
#
#
#
_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._events_handler)
self.tick_period = _POLL_TICK
self._last_tick = 0
self.status_changed_callback = status_changed_callback
receiver.status = _status.ReceiverStatus(receiver, self._status_changed)
# enhance the original register_new_device
def _register_with_status(r, number):
if bool(self):
dev = r.__register_new_device(number)
if dev is not None:
# read these as soon as possible, they will be used everywhere
dev.protocol, dev.codename
dev.status = _status.DeviceStatus(dev, self._status_changed)
self._status_changed(r)
return dev
receiver.__register_new_device = receiver.register_new_device
receiver.register_new_device = _MethodType(_register_with_status, receiver)
def has_started(self):
_log.info("events 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("events 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("tick: polling status: %s %s", self.receiver, list(iter(self.receiver)))
if self._last_tick > 0 and timestamp - self._last_tick > _POLL_TICK * 2:
# 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 (%X) %s", device, 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:
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(number=device.number, name=device.name, kind=device.kind, status=None)
self.status_changed_callback(r, device, alert, reason)
if device.status is None:
self.status_changed_callback(r)
def _events_handler(self, event):
assert self.receiver
if event.devnumber == 0xFF:
# a receiver event
if self.receiver.status is not None:
self.receiver.status.process_event(event)
else:
# a device event
assert event.devnumber > 0 and event.devnumber <= self.receiver.max_devices
already_known = event.devnumber in self.receiver
dev = self.receiver[event.devnumber]
if dev and dev.status is not None:
dev.status.process_event(event)
if self.receiver.status.lock_open and not already_known:
# this should be the first event after a device was paired
assert event.sub_id == 0x41 and event.address == 0x04
_log.info("pairing detected new device")
self.receiver.status.new_device = dev
else:
_log.warn("received event %s for invalid device %d: %s", event, event.devnumber, 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:
receiver.handle = _listener.ThreadedHandle(receiver.handle, receiver.path)
receiver.kind = None
rl = ReceiverListener(receiver, status_changed_callback)
rl.start()
return rl

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,7 +1,9 @@
#!/usr/bin/env python
#!/usr/bin/env python -u
from __future__ import absolute_import, division, print_function, unicode_literals
NAME = 'Solaar'
VERSION = '0.7.2'
VERSION = '0.8.3'
__author__ = "Daniel Pavel <daniel.pavel@gmail.com>"
__version__ = VERSION
__license__ = "GPL"
@@ -10,67 +12,53 @@ __license__ = "GPL"
#
#
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('-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',
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('-V', '--version',
action='version',
version='%(prog)s ' + __version__)
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
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)
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 _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)
def _run(args):
import ui
# check if the notifications are available and enabled
# 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 ui.notify.available and ui.notify.init(NAME):
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 receiver import DUMMY
from listener import DUMMY
window = ui.main_window.create(NAME, DUMMY.name, DUMMY.max_devices, args.systray)
if args.systray:
menu_actions = (ui.action.toggle_notifications,
@@ -78,50 +66,63 @@ if __name__ == '__main__':
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)
# initializes the receiver listener
def check_for_listener(notify=False):
# print ("check_for_listener %s" % notify)
global listener
if not listener:
GObject.timeout_add(5000, check_for_listener)
listener = None
listener = None
from receiver import ReceiverListener
def check_for_listener(retry=True):
global listener, notify_missing
from listener import ReceiverListener
try:
listener = ReceiverListener.open(status_changed)
except OSError:
ui.error(window, 'Permissions error',
'Found a possible Unifying Receiver device,\n'
'but did not have permission to open it.')
if listener is None:
try:
listener = ReceiverListener.open(status_changed)
except OSError:
ui.show_permissions_warning(window)
if notify:
status_changed(DUMMY)
else:
return True
if listener is None:
pairing.state = None
if notify_missing:
status_changed(DUMMY, None, True)
notify_missing = False
return retry
from logitech.unifying_receiver import status
# print ("opened receiver", listener, listener.receiver)
notify_missing = True
pairing.state = pairing.State(listener)
status_changed(listener.receiver, None, True)
# callback delivering status events 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)
GObject.timeout_add(100, check_for_listener, False)
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)
Gtk.main()
if listener is not None:
if listener:
listener.stop()
listener.join()
ui.notify.uninit()
if __name__ == '__main__':
_require('pyudev', 'python-pyudev')
_require('gi.repository', 'python-gi')
_require('gi.repository.Gtk', 'gir1.2-gtk-3.0')
args = _parse_arguments()
listener = None
_run(args)

388
app/solaar_cli.py Normal file
View File

@@ -0,0 +1,388 @@
#!/usr/bin/env python -u
from __future__ import absolute_import, division, print_function, unicode_literals
import sys
import solaar
NAME = 'solaar-cli'
__author__ = solaar.__author__
__version__ = solaar.__version__
__license__ = solaar.__license__
del solaar
#
#
#
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):
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 %d" % number)
return dev
if len(name) < 3:
_fail("need at least 3 characters to match a device")
name = name.lower()
if 'receiver'.startswith(name) or name.upper() == receiver.serial:
return receiver
dev = None
for d in receiver:
if name.upper() == d.serial or name in d.name.lower() or name in d.codename.lower():
if dev is None:
dev = d
else:
_fail("'%s' matches multiple devices" % name)
if dev is None:
_fail("no device found matching '%s'" % name)
return dev
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 : %s" % receiver.path)
print (" Serial : %s" % receiver.serial)
for f in receiver.firmware:
print (" %-11s: %s" % (f.kind, f.version))
print (" Has %d paired device(s)." % paired_count)
notifications = receiver.request(0x8100)
if notifications:
notifications = ord(notifications[0:1]) << 16 | ord(notifications[1:2]) << 8
if notifications:
from logitech.unifying_receiver import hidpp10
print (" Enabled notifications: %06X = %s." % (notifications, ', '.join(hidpp10.NOTIFICATION_FLAG.flag_names(notifications))))
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: %s" % ', '.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]%s" % (dev.number, dev.name, dev.codename, dev.serial, state))
return
print ("%d: %s" % (dev.number, dev.name))
print (" Codename : %s" % dev.codename)
print (" Kind : %s" % dev.kind)
if p == 0:
print (" Protocol : unknown (device is inactive)")
else:
print (" Protocol : HID++ %1.1f" % p)
print (" Polling rate : %d ms" % dev.polling_rate)
print (" Wireless PID : %s" % dev.wpid)
print (" Serial number: %s" % dev.serial)
for fw in dev.firmware:
print (" %-11s: %s" % (fw.kind, (fw.name + ' ' + fw.version).strip()))
if dev.power_switch_location:
print (" The power switch is located on the %s" % 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, 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, 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, %s" % (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)
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 _events_handler(event):
if event.devnumber == 0xFF:
r_status.process_event(event)
if not r_status.lock_open:
done[0] = True
elif event.sub_id == 0x41 and event.address == 0x04:
if event.devnumber not in known_devices:
r_status.new_device = receiver[event.devnumber]
from logitech.unifying_receiver import base
base.events_hook = _events_handler
# check if it's necessary to set the notification flags
notifications = receiver.request(0x8100)
if notifications:
notifications = ord(notifications[:1]) + ord(notifications[1:2]) + ord(notifications[2:3])
if not notifications:
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]:
event = base.read(receiver.handle, 2000)
if event:
event = base.make_event(*event)
if event:
_events_handler(event)
if not notifications:
receiver.enable_notifications(False)
base.events_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)
if dev is receiver:
_fail("cannot unpair the receiver from itself!")
# 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 ("[%d:%s:%s]" % (dev.number, dev.name, dev.serial))
for s in dev.settings:
print ("")
print ("# %s" % s.label)
if s.choices:
print ("# possible values: one of [%s], or higher/lower/highest/max/lowest/min" % ', '.join(str(v) for v in s.choices))
else:
print ("# possible values: true/t/yes/y/1 or false/f/no/n/0")
value = s.read()
if value is None:
print ("# ! failed to read '%s'" % s.name)
else:
print ("%s=%s" % (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', 't', 'y']:
value = True
elif value.lower() in ['0', 'false', 'no', '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)
old_index = setting.choices.index(old_value)
if value == 'lower':
if old_index == 0:
sys.stderr.write("'%s' already at the lowest value")
return
value = setting.choices[old_index - 1:old_index][0]
elif value == 'higher':
if old_index == len(setting.choices) - 1:
sys.stderr.write("'%s' already at the highest value")
return
value = setting.choices[old_index + 1:old_index + 2][0]
elif value in ('highest', 'max'):
value = setting.choices[-1:][0]
elif value in ('lowest', 'min'):
value = setting.choices[:1][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' to '%s'" % (setting.name, 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
if __name__ == '__main__':
_require('pyudev', 'python-pyudev')
args = _parse_arguments()
receiver = _receiver()
args.cmd(receiver, args)

View File

@@ -1,44 +1,91 @@
# pass
#
#
#
from . import (notify, status_icon, main_window, pair_window, action)
from __future__ import absolute_import, division, print_function, unicode_literals
from gi.repository import (GObject, Gtk)
from gi.repository import GObject, Gtk
GObject.threads_init()
_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)
from solaar import NAME as _NAME
_APP_ICONS = (_NAME + '-fail', _NAME + '-init', _NAME)
from . import notify, status_icon, main_window, pair_window, action
from solaar import NAME
_APP_ICONS = (NAME + '-init', NAME + '-fail', 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])
return (_APP_ICONS[1] if isinstance(receiver_status, basestring)
else _APP_ICONS[2] if receiver_status
else _APP_ICONS[0])
_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 get_battery_icon(level):
if level < 0:
return 'battery_unknown'
return 'battery_%03d' % (10 * ((level + 5) // 10))
def show_permissions_warning(window):
text = ('Found a possible Unifying Receiver device,\n'
'but did not have permission to open it.')
_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()
def error(window, title, text):
m = Gtk.MessageDialog(window, Gtk.DialogFlags.MODAL, Gtk.MessageType.ERROR, Gtk.ButtonsType.CLOSE, text)
m.set_title('Permissions error')
m.set_title(title)
m.run()
m.destroy()
def find_children(container, *child_names):
assert container is not None
assert isinstance(container, Gtk.Container)
def _iterate_children(widget, names, result, count):
assert isinstance(widget, Gtk.Widget)
wname = widget.get_name()
if wname in names:
index = names.index(wname)
@@ -48,6 +95,7 @@ def find_children(container, *child_names):
if count > 0 and isinstance(widget, Gtk.Container):
for w in widget:
# if isinstance(w, Gtk.Widget):
count = _iterate_children(w, names, result, count)
if count == 0:
break
@@ -59,12 +107,3 @@ def find_children(container, *child_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

@@ -2,11 +2,12 @@
#
#
# from sys import version as PYTTHON_VERSION
from gi.repository import Gtk
from __future__ import absolute_import, division, print_function, unicode_literals
import ui.notify
import ui.pair_window
# from sys import version as PYTHON_VERSION
from gi.repository import Gtk, Gdk
import ui
from solaar import NAME as _NAME
from solaar import VERSION as _VERSION
@@ -40,15 +41,31 @@ toggle_notifications = _toggle_action('notifications', 'Notifications', _toggle_
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_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', ))
about.set_website('http://github.com/pwr/Solaar/wiki')
about.set_authors(('Daniel Pavel http://github.com/pwr',))
try:
about.add_credit_section('Testing', ('Douglas Wagner',))
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 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)
@@ -59,16 +76,15 @@ 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 = ui.pair_window.create(action, frame._device)
pair_dialog.set_transient_for(window)
pair_dialog.set_destroy_with_parent(True)
pair_dialog.set_modal(True)
window.present()
pair_dialog.set_type_hint(Gdk.WindowTypeHint.DIALOG)
pair_dialog.set_position(Gtk.WindowPosition.CENTER)
pair_dialog.present()
def pair(frame):
@@ -77,15 +93,21 @@ def pair(frame):
def _unpair_device(action, frame):
window = frame.get_toplevel()
window.present()
# window.present()
device = frame._device
qdialog = Gtk.MessageDialog(window, 0,
Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
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.YES:
pairing.state.unpair(device)
if choice == Gtk.ResponseType.ACCEPT:
try:
del device.receiver[device.number]
except:
ui.error(window, 'Unpairing failed', 'Failed to unpair device\n%s .' % device.name)
def unpair(frame):
return _action('remove', 'Unpair', _unpair_device, frame)
return _action('edit-delete', 'Unpair', _unpair_device, frame)

183
app/ui/config_panel.py Normal file
View File

@@ -0,0 +1,183 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from gi.repository import Gtk, GObject
import ui
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(8)
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)
if task[0] == 'write':
_, setting, value, sbox = task
GObject.idle_add(_write_start, sbox)
value = setting.write(value)
elif task[0] == 'read':
_, setting, cached, sbox = task
value = setting.read(cached)
GObject.idle_add(_update_setting_item, sbox, value)
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):
_apply_queue.put(('write', setting, switch.get_active() == True, switch.get_parent()))
def _combo_notify(cbbox, setting, spinner):
_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()
if value is None:
control.set_sensitive(False)
failed.set_visible(True)
return
failed.set_visible(False)
control.set_sensitive(True)
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
def update(frame):
box = ui.find_children(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
box.foreach(lambda x, _: box.remove(x), None)
return
if not device.settings:
# nothing to do here
return
if not box.get_visible():
# no point in doing this, is there?
return
force_read = False
items = box.get_children()
if not items:
if device.status:
items = list(_add_settings(box, device))
assert len(device.settings) == len(items)
force_read = True
else:
# don't bother adding settings for offline devices,
# they're useless and might not guess all of them anyway
return
device_active = bool(device.status)
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))

View File

@@ -2,84 +2,94 @@
#
#
from gi.repository import (Gtk, Gdk)
from __future__ import absolute_import, division, print_function, unicode_literals
from gi.repository import Gtk, Gdk, GObject
import ui
from logitech.devices.constants import (STATUS, PROPS)
from logitech.unifying_receiver import status as _status
from . import config_panel as _config_panel
_SMALL_DEVICE_ICON_SIZE = Gtk.IconSize.BUTTON
_RECEIVER_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR
_DEVICE_ICON_SIZE = Gtk.IconSize.DIALOG
_STATUS_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR
_PLACEHOLDER = '~'
_FALLBACK_ICON = 'preferences-desktop-peripherals'
#
#
#
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)
icon_set = ui.device_icon_set(name)
icon = Gtk.Image.new_from_icon_set(icon_set, _RECEIVER_ICON_SIZE)
icon.set_name('icon')
icon.set_padding(2, 2)
label = Gtk.Label('Scanning...')
label.set_name('label')
label.set_alignment(0, 0.5)
pairing_icon = Gtk.Image.new_from_icon_name('network-wireless', _RECEIVER_ICON_SIZE)
pairing_icon.set_name('pairing-icon')
pairing_icon.set_tooltip_text('The pairing lock is open.')
pairing_icon._tick = 0
toolbar = Gtk.Toolbar()
toolbar.set_name('toolbar')
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
toolbar.set_icon_size(Gtk.IconSize.MENU)
toolbar.set_icon_size(Gtk.IconSize.SMALL_TOOLBAR)
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)
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_name('info-label')
info_label.set_alignment(0, 0.5)
info_label.set_padding(8, 2)
info_label.set_property('margin-left', 36)
info_label.set_alignment(0, 0)
info_label.set_selectable(True)
info_box = Gtk.Frame()
info_box.add(info_label)
info_box.set_shadow_type(Gtk.ShadowType.ETCHED_IN)
def _update_info_label():
device = frame._device
if info_label.get_visible() and '\n' not in info_label.get_text():
items = [('Path', device.path), ('Serial', device.serial)] + \
[(f.kind, f.version) for f in device.firmware]
info_label.set_markup('<small><tt>' + '\n'.join('%-13s: %s' % item for item in items) + '</tt></small>')
toggle_info_action = ui.action._toggle_action('info', 'Receiver info', _toggle_info, info_label, info_box, frame)
def _toggle_info_label(action):
active = action.get_active()
for c in vbox.get_children()[1:]:
c.set_visible(active)
if active:
GObject.timeout_add(50, _update_info_label)
toggle_info_action = ui.action._toggle_action('info', 'Details', _toggle_info_label)
toolbar.insert(toggle_info_action.create_tool_item(), 0)
toolbar.insert(ui.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(4)
vbox.set_border_width(2)
vbox.pack_start(hbox, True, True, 0)
vbox.pack_start(info_box, 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()
info_box.set_visible(False)
pairing_icon.set_visible(False)
_toggle_info_label(toggle_info_action)
return frame
@@ -88,17 +98,16 @@ def _make_device_box(index):
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 = Gtk.Image.new_from_icon_name(_FALLBACK_ICON, _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)
label.set_padding(4, 0)
battery_icon = Gtk.Image.new_from_icon_name('battery_unknown', _STATUS_ICON_SIZE)
battery_icon = Gtk.Image.new_from_icon_name(ui.get_battery_icon(-1), _STATUS_ICON_SIZE)
battery_label = Gtk.Label()
battery_label.set_width_chars(6)
@@ -110,71 +119,134 @@ def _make_device_box(index):
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 - 1)
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_name('toolbar')
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
toolbar.set_icon_size(Gtk.IconSize.MENU)
toolbar.set_icon_size(_STATUS_ICON_SIZE - 1)
toolbar.set_show_arrow(False)
status_box = Gtk.HBox(homogeneous=False, spacing=0)
status_box = Gtk.HBox(homogeneous=False, spacing=2)
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)
status_box.pack_end(not_encrypted_icon, False, False, 0)
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_name('info-label')
info_label.set_alignment(0, 0.5)
info_label.set_padding(8, 2)
info_label.set_property('margin-left', 54)
info_label.set_selectable(True)
info_label.set_alignment(0, 0)
info_box = Gtk.Frame()
info_box.add(info_label)
def _update_info_label():
if info_label.get_text().count('\n') < 5:
device = frame._device
assert device
toggle_info_action = ui.action._toggle_action('info', 'Device info', _toggle_info, info_label, info_box, frame)
items = []
hid = device.protocol
if hid:
items += [('Protocol', 'HID++ %1.1f' % device.protocol)]
else:
items += [('Protocol', 'unknown')]
items += [('Polling rate', '%d ms' % device.polling_rate), ('Wireless PID', device.wpid), ('Serial', device.serial)]
firmware = device.firmware
if firmware:
items += [(f.kind, (f.name + ' ' + f.version).strip()) for f in firmware]
info_label.set_markup('<small><tt>' + '\n'.join('%-13s: %s' % item for item in items) + '</tt></small>')
def _toggle_info_label(action, frame):
active = action.get_active()
if active:
# toggle_config_action.set_active(False)
ui.find_children(frame, 'toolbar').get_children()[-1].set_active(False)
vbox = frame.get_child()
children = vbox.get_children()
children[1].set_visible(active) # separator
children[2].set_visible(active) # info label
if active:
GObject.timeout_add(30, _update_info_label)
def _toggle_config(action, frame):
active = action.get_active()
if active:
# toggle_info_action.set_active(False)
ui.find_children(frame, 'toolbar').get_children()[0].set_active(False)
vbox = frame.get_child()
children = vbox.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, frame)
toggle_info_action = ui.action._toggle_action('info', 'Details', _toggle_info_label, frame)
toolbar.insert(toggle_info_action.create_tool_item(), 0)
toolbar.insert(ui.action.unpair(frame).create_tool_item(), -1)
toggle_config_action = ui.action._toggle_action('preferences-system', 'Configuration', _toggle_config, frame)
toolbar.insert(toggle_config_action.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)
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)
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()
config_box = Gtk.VBox(homogeneous=False, spacing=4)
config_box.set_name('config-box')
config_box.set_property('margin', 8)
vbox.pack_start(config_box, False, False, 0)
frame.add(box)
info_box.set_visible(False)
unpair = Gtk.Button('Unpair')
unpair.set_image(Gtk.Image.new_from_icon_name('edit-delete', Gtk.IconSize.BUTTON))
unpair.connect('clicked', ui.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 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 = Gtk.VBox(homogeneous=False, spacing=12)
vbox.set_border_width(4)
rbox = _make_receiver_box(name)
@@ -185,6 +257,7 @@ def create(title, name, max_devices, systray=False):
vbox.set_visible(True)
window.add(vbox)
window._vbox = vbox
geometry = Gdk.Geometry()
geometry.min_width = 320
@@ -192,13 +265,50 @@ def create(title, name, max_devices, systray=False):
window.set_geometry_hints(vbox, geometry, Gdk.WindowHints.MIN_SIZE)
window.set_resizable(False)
window.toggle_visible = lambda i: toggle(window, i)
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
if systray:
window.set_keep_above(True)
window.connect('delete-event', toggle)
else:
window.connect('delete-event', Gtk.main_quit)
def _set_has_systray(w, 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
@@ -207,63 +317,80 @@ def create(title, name, max_devices, systray=False):
#
def _update_receiver_box(frame, receiver):
label, toolbar, info_label = ui.find_children(frame, 'label', 'toolbar', 'info-label')
icon, label, pairing_icon, toolbar = ui.find_children(frame, 'icon', 'label', 'pairing-icon', 'toolbar')
label.set_text(receiver.status_text or '')
if receiver.status < STATUS.CONNECTED:
label.set_text(str(receiver.status))
if receiver:
frame._device = receiver
icon.set_sensitive(True)
if receiver.status.lock_open:
if 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
pairing_icon.set_visible(True)
GObject.timeout_add(1000, _pairing_tick, pairing_icon, receiver.status)
else:
pairing_icon.set_visible(False)
pairing_icon.set_sensitive(True)
pairing_icon._tick = 0
toolbar.set_sensitive(True)
else:
frame._device = None
icon.set_sensitive(False)
pairing_icon.set_visible(False)
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
ui.find_children(frame, 'info-label').set_text('')
def _update_device_box(frame, dev):
frame._device = dev
if dev is None:
frame.set_visible(False)
frame.set_name(_PLACEHOLDER)
frame._device = None
_config_panel.update(frame)
return
icon, label, info_label = ui.find_children(frame, 'icon', 'label', 'info-label')
icon, label, status_icons = ui.find_children(frame, 'icon', 'label', 'status')
if frame.get_name() != dev.name:
first_run = frame.get_name() != dev.name
if first_run:
frame._device = dev
frame.set_name(dev.name)
icon_name = ui.get_icon(dev.name, dev.kind)
icon.set_from_icon_name(icon_name, _DEVICE_ICON_SIZE)
icon_set = ui.device_icon_set(dev.name, dev.kind)
icon.set_from_icon_set(icon_set, _DEVICE_ICON_SIZE)
label.set_markup('<b>' + dev.name + '</b>')
toolbar = ui.find_children(frame, 'toolbar')
for i in toolbar.get_children():
i.set_active(False)
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)
battery_icon, battery_label, light_icon, light_label, not_encrypted_icon, _ = status_icons
battery_level = dev.status.get(_status.BATTERY_LEVEL)
if dev.status:
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)
battery_icon.set_from_icon_name(ui.get_battery_icon(-1), _STATUS_ICON_SIZE)
battery_label.set_markup('<small>no status</small>')
battery_label.set_sensitive(True)
else:
icon_name = 'battery_%03d' % (20 * ((battery_level + 10) // 20))
battery_icon.set_from_icon_name(icon_name, _STATUS_ICON_SIZE)
battery_icon.set_from_icon_name(ui.get_battery_icon(battery_level), _STATUS_ICON_SIZE)
battery_icon.set_sensitive(True)
battery_label.set_text('%d%%' % battery_level)
battery_label.set_visible(True)
battery_label.set_sensitive(True)
battery_status = dev.props.get(PROPS.BATTERY_STATUS)
battery_status = dev.status.get(_status.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)
light_level = dev.status.get(_status.LIGHT_LEVEL)
if light_level is None:
light_icon.set_visible(False)
light_label.set_visible(False)
@@ -274,31 +401,39 @@ def _update_device_box(frame, dev):
light_label.set_text('%d lux' % light_level)
light_label.set_visible(True)
for b in toolbar.get_children()[:-1]:
b.set_sensitive(True)
not_encrypted_icon.set_visible(dev.status.get(_status.ENCRYPTED) == False)
else:
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.set_visible(True)
_config_panel.update(frame)
def update(window, receiver, device=None):
# print ("update", receiver, receiver.status, device)
assert receiver is not None
# print ("update %s %s, %s" % (receiver, receiver.status, device))
window.set_icon_name(ui.appicon(receiver.status))
vbox = window.get_child()
vbox = window._vbox
frames = list(vbox.get_children())
assert len(frames) == 1 + receiver.max_devices, frames
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
if device:
_update_device_box(frames[device.number], None if device.status is None else device)
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)
_update_receiver_box(frames[0], receiver)
if not receiver:
for frame in frames[1:]:
_update_device_box(frame, None)

View File

@@ -2,27 +2,18 @@
# 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
# 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
_notifications = {}
@@ -47,7 +38,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 +48,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, ui.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 +64,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

@@ -2,124 +2,197 @@
#
#
# import logging
from gi.repository import (Gtk, GObject)
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
import ui
from logitech.unifying_receiver import status as _status
#
#
#
_PAIRING_TIMEOUT = 30
def _create_page(assistant, text, kind):
p = Gtk.VBox(False, 12)
p.set_border_width(8)
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)
p.pack_start(label, False, True, 0)
assistant.append_page(p)
assistant.set_page_type(p, kind)
label.set_line_wrap(True)
p.pack_start(label, False, False, 0)
p.show_all()
return p
def _device_confirmed(entry, _2, trigger, assistant, page):
assistant.commit()
assistant.set_page_complete(page, True)
# 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 _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):
def _prepare(assistant, page, receiver):
index = assistant.get_current_page()
# logging.debug("prepare %s %d %s", assistant, index, page)
if _log.isEnabledFor(_DEBUG):
_log.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)
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:
page = _create_page(assistant,
None,
Gtk.AssistantPageType.CONFIRM)
assistant.remove_page(0)
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)
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
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)
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()
def _scan_complete(assistant, device):
GObject.idle_add(_scan_complete_ui, assistant, device)
assistant.commit()
def create(action, state):
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 = ui.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>' + device.name + '</b>')
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(440, 240)
assistant.set_size_request(400, 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)
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, 16)
page_intro.pack_end(spinner, True, True, 24)
assistant.scan_complete = _scan_complete
assistant.connect('prepare', _prepare, state)
assistant.connect('cancel', _cancel, state)
assistant.connect('close', _finish)
assistant.connect('apply', _finish)
assistant.connect('prepare', _prepare, receiver)
assistant.connect('cancel', _finish, receiver)
assistant.connect('close', _finish, receiver)
return assistant

View File

@@ -2,16 +2,28 @@
#
#
from gi.repository import Gtk
from __future__ import absolute_import, division, print_function, unicode_literals
from gi.repository import Gtk, GObject, GdkPixbuf
import ui
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(window.get_title())
icon.set_name(window.get_title())
icon.set_from_icon_name(ui.appicon(0))
icon.set_title(name)
icon.set_name(name)
icon.set_from_icon_name(ui.appicon(False))
icon._devices = list(_NO_DEVICES)
icon.set_tooltip_text(name)
icon.connect('activate', window.toggle_visible)
menu = Gtk.Menu()
@@ -27,29 +39,79 @@ def create(window, menu_actions=None):
menu.popup(None, None, icon.position_menu, icon, button, time),
menu)
# use size-changed to detect if the systray is available or not
def _size_changed(i, size, w):
def _check_systray(i2, w2):
w2.set_has_systray(i2.is_embedded() and i2.get_visible())
GObject.timeout_add(250, _check_systray, i, w)
icon.connect('size-changed', _size_changed, window)
return icon
def update(icon, receiver):
icon.set_from_icon_name(ui.appicon(receiver.status))
_PIXMAPS = {}
def _icon_with_battery(s):
battery_icon = ui.get_battery_icon(s[_status.BATTERY_LEVEL])
if receiver.devices:
lines = []
if receiver.status < 1:
lines += (receiver.status_text, '')
name = '%s-%s' % (battery_icon, bool(s))
if name not in _PIXMAPS:
mask = ui.icon_file(ui.appicon(True) + '-mask', 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)
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 + ')')
battery = ui.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 s:
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)
battery_status = None
if device:
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
lines = [ui.NAME + ': ' + str(receiver.status), '']
for dev in icon._devices:
if dev is None:
continue
lines.append('<b>' + dev.name + '</b>')
assert hasattr(dev, 'status') and dev.status is not None
p = str(dev.status)
if p:
if not dev.status:
p += ' <small>(inactive)</small>'
else:
if dev.status:
p = '<small>no status</small>'
else:
lines.append(name)
if dev.status > 1:
lines.append(' ' + dev.status_text)
lines.append('')
p = '<small>(inactive)</small>'
text = '\n'.join(lines).rstrip('\n')
icon.set_tooltip_markup(text)
lines.append('\t' + p)
lines.append('')
if battery_status is None and dev.status.get(_status.BATTERY_LEVEL):
battery_status = dev.status
icon.set_tooltip_markup('\n'.join(lines).rstrip('\n'))
if battery_status is None:
icon.set_from_icon_name(ui.appicon(receiver.status))
else:
icon.set_tooltip_text(receiver.status_text)
icon.set_from_pixbuf(_icon_with_battery(battery_status))

View File

@@ -2,8 +2,8 @@
Z=`readlink -f "$0"`
LIB=`readlink -f $(dirname "$Z")/../lib`
#export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$LIB/native/`arch`
export PYTHONPATH=$LIB
export PYTHONUNBUFFERED=yes
PYTHON=`which python python2 python3 | head -n 1`
exec $PYTHON -OOu -m hidapi.hidconsole "$@"
PYTHON=${PYTHON:-`which python python2 python3 | head -n 1`}
exec $PYTHON -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

@@ -5,9 +5,8 @@ APP=`readlink -f $(dirname "$Z")/../app`
LIB=`readlink -f $(dirname "$Z")/../lib`
SHARE=`readlink -f $(dirname "$Z")/../share`
#export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$LIB/native/`arch`
export PYTHONPATH=$APP:$LIB
export XDG_DATA_DIRS=$SHARE:$XDG_DATA_DIRS
export XDG_DATA_DIRS=${SHARE}_override:$SHARE:$XDG_DATA_DIRS
PYTHON=`which python python2 python3 | head -n 1`
exec $PYTHON -OOu -m solaar "$@"
PYTHON=${PYTHON:-`which python python2 python3 | head -n 1`}
exec $PYTHON -m solaar "$@"

9
bin/solaar-cli Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
Z=`readlink -f "$0"`
APP=`readlink -f $(dirname "$Z")/../app`
LIB=`readlink -f $(dirname "$Z")/../lib`
export PYTHONPATH=$APP:$LIB
PYTHON=${PYTHON:-`which python python2 python3 | head -n 1`}
exec $PYTHON -m solaar_cli "$@"

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,9 @@
"""Generic Human Interface Device API."""
from __future__ import absolute_import, division, print_function, unicode_literals
__author__ = "Daniel Pavel"
__license__ = "GPL"
__version__ = "0.4"
__version__ = "0.5"
try:
from hidapi.udev import *
except ImportError:
from hidapi.native import *
from hidapi.udev import *

View File

@@ -1,109 +1,206 @@
#!/usr/bin/env python
#!/usr/bin/env python -u
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()
#
#
#
interactive = os.isatty(0)
start_time = 0
try:
try: # python3 support
read_packet = raw_input
except:
read_packet = input
prompt = '?? Input: ' if interactive else ''
strhex = lambda d: hexlify(d).decode('ascii').upper()
#
#
#
from threading import Lock
print_lock = 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 %s" % device)
handle = hidapi.open_path(device)
if not handle:
sys.exit("!! Failed to open %s, aborting." % device)
print (".. Opened handle %s, vendor %s product %s serial %s." % (
repr(handle),
repr(hidapi.get_manufacturer(handle)),
repr(hidapi.get_product(handle)),
repr(hidapi.get_serial(handle))))
if args.hidpp:
if hidapi.get_manufacturer(handle) != 'Logitech':
sys.exit("!! Only Logitech devices support the HID++ protocol.")
print (".. HID++ validation enabled.")
return handle
#
#
#
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')
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')
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.")
handle = _open(args.device, args.hidpp)
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()
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:
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:
readline.read_history_file(args.history)
except:
# file may not exist yet
pass
except Exception as e:
print ('%s: %s' % (type(e).__name__, e))
print (".. Closing handle %X" % handle)
hidapi.close(handle)
start_time = time.time()
try:
from threading import Thread
t = Thread(target=_continuous_read, args=(handle,))
t.daemon = True
t.start()
if interactive:
readline.write_history_file(args.history)
else:
print ("!! Failed to open %s, aborting" % args.device)
# 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 %s" % repr(handle))
hidapi.close(handle)
if interactive:
readline.write_history_file(args.history)

View File

@@ -16,6 +16,10 @@ 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'
@@ -310,7 +314,7 @@ def send_feature_report(device_handle, data, report_number=None):
:returns: ``True`` if the report was successfully written to the device.
"""
if report_number is not None:
data = _pack('!B', report_number) + data
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
@@ -326,7 +330,7 @@ def get_feature_report(device_handle, bytes_count, report_number=None):
"""
out_buffer = _C.create_string_buffer('\x00' * (bytes_count + 2))
if report_number is not None:
out_buffer[0] = _pack('!B', report_number)
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]

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,9 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
__author__ = "Daniel Pavel"
__license__ = "GPL"
__version__ = "0.5"
__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,17 +104,21 @@ 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 %s", repr(handle))
return True
except:
_log.exception("closing receiver handle %X", handle)
# _log.exception("closing receiver handle %s", repr(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.
@@ -159,61 +131,122 @@ def write(handle, devnumber, data):
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 %s no longer available", repr(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.
(code, devnumber, message data).
: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):
try:
data = _hid.read(int(handle), _MAX_READ_SIZE, timeout)
except Exception as reason:
_log.error("read failed, assuming handle %s no longer available", repr(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:]
def _skip_incoming(handle):
"""Read anything already in the input buffer."""
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 events.
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 events from the device (e.g. special keys being pressed,
battery charge events, etc), at least not in a timely manner.
"""
events_hook = None
def _unhandled(report_id, devnumber, data):
"""Deliver a possible event to the unhandled_hook (if any)."""
if events_hook:
event = make_event(devnumber, data)
if event:
events_hook(event)
_MAX_READ_TIMES = 3
request_context = None
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)
_Event = namedtuple('_Event', ['devnumber', 'sub_id', 'address', 'data'])
_Event.__str__ = lambda self: 'Event(%d,%02X,%02X,%s)' % (self.devnumber, self.sub_id, self.address, _strhex(self.data))
_Event.__unicode__ = _Event.__str__
del namedtuple
def request(handle, devnumber, feature_index_function, params=b'', features=None):
def make_event(devnumber, data):
sub_id = ord(data[:1])
if devnumber == 0xFF:
if sub_id == 0x4A: # receiver lock event
return _Event(devnumber, sub_id, ord(data[1:2]), data[2:])
elif sub_id & 0x80 != 0x80:
address = ord(data[1:2])
if sub_id >= 0x40 or address & 0x01 == 0:
return _Event(devnumber, sub_id, address, data[2:])
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
@@ -222,83 +255,86 @@ def request(handle, devnumber, feature_index_function, params=b'', features=None
: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))
if request_context is None or handle != request_context.handle:
context = _DEFAULT_REQUEST_CONTEXT
_unhandled = unhandled_hook
assert type(request_id) == int
if devnumber != 0xFF and request_id < 0x8000:
timeout = _DEVICE_REQUEST_TIMEOUT
# for HID++ 2.0 feature request, randomize the swid to make it easier to
# recognize the reply for this request. also, always set the last bit
# (0) in swid, to make events easier to identify
request_id = (request_id & 0xFFF0) | _random_bits(4) | 0x01
else:
context = request_context
_unhandled = getattr(context, 'unhandled_hook')
timeout = _RECEIVER_REQUEST_TIMEOUT
request_str = _pack(b'!H', request_id)
context.write(handle, devnumber, feature_index_function + params)
params = b''.join(_pack(b'B', p) if type(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))
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_str + params)
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_str:
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 %d 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_str:
# 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_str:
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 an event
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 +342,49 @@ 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
_skip_incoming(handle)
ihandle = int(handle)
if not reply:
# keep waiting...
continue
# randomize the swid and mark byte to positively identify the ping reply,
# and set the last (0) bit in swid to make it easier to distinguish requests
# from events
request_id = 0x0010 | _random_bits(4) | 0x01
request_str = _pack(b'!H', request_id)
ping_mark = _pack(b'B', _random_bits(8))
write(ihandle, devnumber, request_str + b'\x00\x00' + ping_mark)
reply_code, reply_devnumber, reply_data = reply
while True:
now = _timestamp()
reply = _read(ihandle, _PING_TIMEOUT)
delta = _timestamp() - now
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 reply:
report_id, number, data = reply
if number == devnumber:
if data[:2] == request_str and data[4:5] == ping_mark:
# HID++ 2.0+ device, currently connected
return ord(data[2:3]) + ord(data[3:4]) / 10.0
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 report_id == 0x10 and data[:1] == b'\x8F' and data[1:3] == request_str:
assert data[-1:] == b'\x00'
error = ord(data[3:4])
if reply_code == 0x10 and reply_data == b'\x8F\x00\x10\x01\x00':
return 1.0
if error == _hidpp10.ERROR.invalid_SubID__command: # a valid reply from a HID++ 1.0 device
return 1.0
if reply_code == 0x10 and reply_data[:3] == b'\x8F\x00\x10':
return None
if error == _hidpp10.ERROR.resource_error: # device unreachable
# raise DeviceUnreachable(number=devnumber, request=request_id)
break
_log.warn("don't know how to interpret ping reply %s", reply)
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,153 @@
# 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):
Careful when using this, because
"""
def __new__(cls, value, name):
obj = int.__new__(cls, value)
obj.name = str(name)
return obj
def bytes(self, count=2):
value = int(self)
if value.bit_length() > count * 8:
raise ValueError('cannot fit %X into %d bytes' % (value, count))
return _pack(b'!L', value)[-count:]
def __hash__(self):
return int(self)
def __eq__(self, other):
if isinstance(other, int):
return int(self) == int(other)
if isinstance(other, basestring):
return self.name.lower() == other.lower()
def __ne__(self, other):
if isinstance(other, int):
return int(self) != int(other)
if isinstance(other, basestring):
return self.name.lower() != other.lower()
def __lt__(self, other):
if not isinstance(other, int):
raise TypeError('unorderable types: %s < %s' % (type(self), type(other)))
return int(self) < int(other)
def __le__(self, other):
if not isinstance(other, int):
raise TypeError('unorderable types: %s <= %s' % (type(self), type(other)))
return int(self) <= int(other)
def __gt__(self, other):
if not isinstance(other, int):
raise TypeError('unorderable types: %s > %s' % (type(self), type(other)))
return int(self) > int(other)
def __ge__(self, other):
if not isinstance(other, int):
raise TypeError('unorderable types: %s >= %s' % (type(self), type(other)))
return int(self) >= int(other)
def __str__(self):
return self.name
__unicode__ = __str__
def __repr__(self):
return 'NamedInt(%d, %s)' % (int(self), repr(self.name))
class NamedInts(object):
__slots__ = ['__dict__', '_values', '_indexed', '_fallback']
def __init__(self, **kwargs):
values = dict((k, NamedInt(v, k.lstrip('_') if k == k.upper() else
k.replace('__', '/').replace('_', ' '))) for (k, v) in kwargs.items())
self.__dict__ = values
self._values = sorted(list(values.values()))
self._indexed = dict((int(v), v) for v in self._values)
self._fallback = None
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 index(self, value):
if value in self._values:
return self._values.index(value)
raise IndexError('%s not found' % value)
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 type(index) == slice:
return self._values[index]
else:
if index in self._values:
index = self._values.index(index)
return self._values[index]
def __contains__(self, value):
return value in self._values
def __iter__(self):
return iter(sorted(self._values))
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 +157,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,68 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from collections import namedtuple
from .common import NamedInts as _NamedInts
from . import hidpp10
#
#
#
_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',
registers=_NamedInts(battery=0x0D),
settings=[hidpp10.SmoothScroll_Setting(0x01)],
)
_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',
registers=_NamedInts(battery=0x0D),
)
_D('Performance Mouse MX', codename='Performance MX',
registers=_NamedInts(battery=0x0D),
settings=[
hidpp10.MouseDPI_Setting(0x63, _NamedInts(**dict((str(x * 100), 0x80 + x) for x in range(1, 16)))),
],
)
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,171 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
from .common import (strhex as _strhex,
NamedInts as _NamedInts,
FirmwareInfo as _FirmwareInfo)
from . import settings as _settings
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)
#
#
#
class SmoothScroll_Setting(_settings.Setting):
def __init__(self, register):
super(SmoothScroll_Setting, self).__init__('smooth-scroll', _settings.KIND.toggle,
'Smooth Scrolling', 'High-sensitivity mode for vertical scroll with the wheel.')
assert register is not None
self.register = register
def read(self, cached=True):
if (self._value is None or not cached) and self._device:
ss = self.read_register()
if ss:
self._value = (ss[:1] == b'\x40')
return self._value
def write(self, value):
if self._device:
reply = self.write_register(0x40 if bool(value) else 0x00)
self._value = None
if reply:
return self.read()
class MouseDPI_Setting(_settings.Setting):
def __init__(self, register, choices):
super(MouseDPI_Setting, self).__init__('dpi', _settings.KIND.choice,
'Sensitivity (DPI)', choices=choices)
assert choices
assert isinstance(choices, _NamedInts)
assert register is not None
self.register = register
def read(self, cached=True):
if (self._value is None or not cached) and self._device:
dpi = self.read_register()
if dpi:
value = ord(dpi[:1])
self._value = self.choices[value]
assert self._value is not None
return self._value
def write(self, value):
if self._device:
choice = self.choices[value]
if choice is None:
raise ValueError(repr(value))
reply = self.write_register(value)
self._value = None
if reply:
return self.read()
#
# functions
#
def get_battery(device):
"""Reads a device's battery level, if provided by the HID++ 1.0 protocol."""
if 'battery' in device.registers:
register = device.registers['battery']
reply = device.request(0x8100 + (register & 0xFF))
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
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,413 @@
#
# 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 . import settings as _settings
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')
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 ("%s check" % self.device)
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(int(FEATURE.ROOT), _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():
assert type(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]
def __contains__(self, value):
if self._check():
may_have = False
for f in self.features:
if f is None:
may_have = True
elif int(value) == int(f):
return True
elif int(value) < int(f):
break
if may_have:
reply = self.device.request(int(FEATURE.ROOT), _pack(b'!H', value))
if reply:
index = ord(reply[0:1])
if index:
self.features[index] = FEATURE[int(value)]
return True
def index(self, value):
if self._check():
may_have = False
for index, f in enumerate(self.features):
if f is None:
may_have = True
elif int(value) == int(f):
return index
elif int(value) < int(f):
raise ValueError("%s not in list" % repr(value))
if may_have:
reply = self.device.request(int(FEATURE.ROOT), _pack(b'!H', value))
if reply:
index = ord(reply[0:1])
self.features[index] = FEATURE[int(value)]
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):
assert type(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]
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)
#
#
#
class ToggleFN_Setting(_settings.Setting):
def __init__(self):
super(ToggleFN_Setting, self).__init__('fn-swap', _settings.KIND.toggle, '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 read(self, cached=True):
if (self._value is None or not cached) and self._device:
fn = self._device.feature_request(FEATURE.FN_STATUS)
if fn:
self._value = (fn[:1] == b'\x01')
return self._value
def write(self, value):
if self._device:
reply = self._device.feature_request(FEATURE.FN_STATUS, 0x10, 0x01 if value else 0x00)
self._value = (reply[:1] == b'\x01') if reply else None
return self._value
#
#
#
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,171 @@ 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."""
__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 %s' % repr(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__
#
#
#
_EVENT_READ_TIMEOUT = 500
_IDLE_READS = 4
class EventsListener(_Thread):
class EventsListener(_threading.Thread):
"""Listener thread for events from the Unifying Receiver.
Incoming packets will be passed to the callback function in sequence, by a
separate thread.
Incoming packets will be passed to the callback function in sequence.
"""
def __init__(self, receiver_handle, events_callback):
super(EventsListener, self).__init__(group='Unifying Receiver', name=self.__class__.__name__)
def __init__(self, receiver, events_callback):
super(EventsListener, self).__init__(name=self.__class__.__name__)
self.daemon = True
self._active = False
self._handle = receiver_handle
self.receiver = receiver
self._queued_events = _Queue(32)
self._events_callback = events_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
_base.events_hook = self._events_hook
ihandle = int(self.receiver.handle)
_log.info("started with %s (%d)", self.receiver, ihandle)
self._dispatcher.start()
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_events.empty():
try:
# _log.debug("read next event")
event = _base.read(ihandle, _EVENT_READ_TIMEOUT)
except _base.NoReceiver:
_log.warning("receiver disconnected")
self.receiver.close()
break
if event:
event = _base.make_event(*event)
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 events
event = self._queued_events.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 event:
# if _log.isEnabledFor(_DEBUG):
# _log.debug("processing event %s", event)
try:
self._events_callback(event)
except:
_log.exception("processing event %s", event)
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.unhandled_hook = None
del self._queued_events
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."""
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, if set."""
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 _events_hook(self, event):
# only consider unhandled events that were sent from this thread,
# i.e. triggered during a callback of a previous event
if self._active and _threading.current_thread() == self:
if _log.isEnabledFor(_DEBUG):
_log.debug("queueing unhandled event %s", event)
self._queued_events.put(event)
def __bool__(self):
return bool(self._active and self._handle)
return bool(self._active and self.receiver)
__nonzero__ = __bool__

View File

@@ -0,0 +1,372 @@
#
#
#
from __future__ import absolute_import, division, print_function, unicode_literals
import errno as _errno
from weakref import proxy as _proxy
from collections import defaultdict as _defaultdict
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
from .descriptors import DEVICES as _DEVICES
#
#
#
"""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 _DEVICES:
self._name, self._kind = _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 _DEVICES:
self._name, self._kind = _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 = _DEVICES.get(self.codename)
if descriptor is None or descriptor.registers is None:
self._registers = _defaultdict(lambda: None)
else:
self._registers = descriptor.registers
return self._registers
@property
def settings(self):
if self._settings is None:
descriptor = _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 _hidpp20.FEATURE.FN_STATUS in self.features:
tfn = _hidpp20.ToggleFN_Setting()
self._settings.insert(0, tfn(self))
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 __lt__(self, other):
return self.number < other.number
def __le__(self, other):
return self.number <= other.number
def __gt__(self, other):
return self.number > other.number
def __ge__(self, other):
return self.number >= other.number
def __eq__(self, other):
return self.receiver == other.receiver and self.number == other.number
def __ne__(self, other):
return self.receiver != other.receiver or self.number != other.number
def __hash__(self):
return self.number
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.
"""
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.number = 0xFF
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 events 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 events")
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,49 @@
#
#
#
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 NamedInts as _NamedInts
#
#
#
KIND = _NamedInts(toggle=0x1, choice=0x02, range=0x03)
class Setting(object):
__slots__ = ['name', 'kind', 'label', 'description', 'choices', '_device', '_value', 'register']
def __init__(self, name, kind, label, description=None, choices=None):
self.name = name
self.kind = kind
self.label = label
self.description = description
self.choices = choices
self.register = None
def __call__(self, device):
o = _copy(self)
o._value = None
o._device = _proxy(device)
return o
def read_register(self):
return self._device.request(0x8100 | (self.register & 0x2FF))
def write_register(self, value, value2=0):
return self._device.request(0x8000 | (self.register & 0x2FF), int(value) & 0xFF, int(value2) & 0xFF)
def read(self, cached=True):
raise NotImplemented
def write(self, value):
raise NotImplemented
def __str__(self):
return '<%s(%s=%s)>' % (self.__class__.__name__, self.name, self._value)
__unicode__ = __repr__ = __str__

View File

@@ -0,0 +1,297 @@
#
#
#
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_event(self, event):
if event.sub_id == 0x4A:
self.lock_open = bool(event.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(event.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):
t = []
if self.get(BATTERY_LEVEL) is not None:
b = 'Battery: %d%%' % self[BATTERY_LEVEL]
if self.get(BATTERY_STATUS):
b += ' (' + self[BATTERY_STATUS] + ')'
t.append(b)
if self.get(LIGHT_LEVEL) is not None:
t.append('Light: %d lux' % self[LIGHT_LEVEL])
return ', '.join(t)
__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
# read these 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)
# 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)
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, alert=ALERT.LOW, timestamp=timestamp)
def process_event(self, event):
assert event.sub_id < 0x80
if event.sub_id == 0x40:
if event.address == 0x02:
# device un-paired
self.clear()
self._device.status = None
self._changed(False, ALERT.HIGH, 'unpaired')
else:
_log.warn("device %d disconnection notification %s with unknown type %02X", self._device.number, event, event.address)
return True
if event.sub_id == 0x41:
if event.address == 0x04: # unifying protocol
# wpid = _strhex(event.data[4:5] + event.data[3:4])
# assert wpid == device.wpid
flags = ord(event.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("device %d connection notification: software=%s, encrypted=%s, link=%s, payload=%s",
self._device.number, sw_present, link_encrypyed, link_established, has_payload)
self[ENCRYPTED] = link_encrypyed
self._changed(link_established)
elif event.address == 0x03:
_log.warn("device %d connection notification %s with eQuad protocol, ignored", self._device.number, event)
else:
_log.warn("device %d connection notification %s with unknown protocol %02X", self._device.number, event, event.address)
return True
if event.sub_id == 0x49:
# raw input event? just ignore it
# if event.address == 0x01, no idea what it is
# if event.address == 0x03, it's an actual input event
return True
if event.sub_id == 0x4B:
if event.address == 0x01:
_log.debug("device came online %d", event.devnumber)
self._changed(alert=ALERT.LOW, reason='powered on')
else:
_log.warn("unknown event %s", event)
return True
if event.sub_id >= 0x40:
_log.warn("don't know how to handle event %s", event)
return False
# this must be a feature event, assuming no device has more than 0x40 features
if event.sub_id >= len(self._device.features):
_log.warn("device %d got event from unknown feature index %02X", self._device.number, event.sub_id)
return False
try:
feature = self._device.features[event.sub_id]
except IndexError:
_log.warn("don't know how to handle event %s for feature with invalid index %02X", event, event.sub_id)
return False
if feature == _hidpp20.FEATURE.BATTERY:
if event.address == 0x00:
discharge = ord(event.data[:1])
battery_status = ord(event.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
else:
alert = ALERT.MED
reason = self[ERROR] = self[BATTERY_STATUS]
self._changed(alert=alert, reason=reason)
else:
_log.warn("don't know how to handle BATTERY event %s", event)
return True
if feature == _hidpp20.FEATURE.REPROGRAMMABLE_KEYS:
if event.address == 0x00:
_log.debug('reprogrammable key: %s', event)
else:
_log.warn("don't know how to handle REPROGRAMMABLE KEYS event %s", event)
return True
if feature == _hidpp20.FEATURE.WIRELESS:
if event.address == 0x00:
_log.debug("wireless status: %s", event)
if event.data[0:3] == b'\x01\x01\x01':
self._changed(alert=ALERT.LOW, reason='powered on')
else:
_log.warn("don't know how to handle WIRELESS event %s", event)
return True
if feature == _hidpp20.FEATURE.SOLAR_CHARGE:
if event.data[5:9] == b'GOOD':
charge, lux, adc = _unpack(b'!BHH', event.data[:5])
self[BATTERY_LEVEL] = charge
# guesstimate the battery voltage, emphasis on 'guess'
self[BATTERY_STATUS] = '%1.2fV' % (adc * 2.67793237653 / 0x0672)
if event.address == 0x00:
self[LIGHT_LEVEL] = None
self._changed()
elif event.address == 0x10:
self[LIGHT_LEVEL] = lux
if lux > 200: # guesstimate
self[BATTERY_STATUS] += ', charging'
self._changed()
elif event.address == 0x20:
_log.debug("Solar key pressed")
# first cancel any reporting
self._device.feature_request(_hidpp20.FEATURE.SOLAR_CHARGE)
reports_count = 15
reports_period = 2 # seconds
self._changed(alert=ALERT.MED)
# trigger a new report chain
self._device.feature_request(_hidpp20.FEATURE.SOLAR_CHARGE, 0x00, reports_count, reports_period)
else:
self._changed()
else:
_log.warn("SOLAR CHARGE event not GOOD? %s", event)
return True
if feature == _hidpp20.FEATURE.TOUCH_MOUSE:
if event.address == 0x00:
_log.debug("TOUCH MOUSE points event: %s", event)
elif event.address == 0x10:
touch = ord(event.data[:1])
button_down = bool(touch & 0x02)
mouse_lifted = bool(touch & 0x01)
_log.debug("TOUCH MOUSE status: button_down=%s mouse_lifted=%s", button_down, mouse_lifted)
return True
_log.warn("don't know how to handle event %s for feature %s (%02X)", event, feature, event.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()

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 "$@"

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."

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB