Compare commits

...

21 Commits
0.7.4 ... 0.8.2

Author SHA1 Message Date
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
37 changed files with 2554 additions and 2041 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

83
README.md Normal file
View File

@@ -0,0 +1,83 @@
**Solaar** is a Linux device manager for Logitech's
[Unifying Receiver](http://www.logitech.com/en-us/66/6079) peripherals.
It comes in two flavours, 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.
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.
In the `rules.d/` folder of Solaar you'll find a udev rule file, to be copied in
`/etc/udev/rules.d/` (as the root user).
In its current form it 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.
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 10 seconds and re-insert it.
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. Changing various settings
of the devices (like mouse DPI) is currently not supported, but implementation
is planned.
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, similar to Logitech's Solar app for Windows.
Extended support for other devices will be added in the future, depending on the
documentation available, but the K750 keyboard is the only device I have and can
test on right now.
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)

150
app/listener.py Normal file
View File

@@ -0,0 +1,150 @@
#
#
#
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
__str__ = lambda self: 'DUMMY'
DUMMY = _DUMMY_RECEIVER()
from collections import namedtuple
_GHOST_DEVICE = namedtuple('_GHOST_DEVICE', ['number', 'name', 'kind', 'status'])
del namedtuple
#
#
#
_POLL_TICK = 30 # 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:
assert 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:
assert 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", event, event.devnumber)
def __str__(self):
return '<ReceiverListener(%s,%s)>' % (self.receiver.path, self.receiver.handle)
@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,83 +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
# self.success = True
# self.detected_device = self.listener.receiver.devices[1]
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):
return self.listener.unpair_device(device)

View File

@@ -1,365 +0,0 @@
#
#
#
from logging import getLogger as _Logger
from struct import pack as _pack
from time import time as _timestamp
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):
assert device is not None
self.device = device
self.features = None
self.supported = True
def __del__(self):
self.supported = False
self.device = None
def _check(self):
# print ("%s check" % self.device)
if self.supported:
if self.features is not None:
return True
if self.device.protocol < 2.0:
return False
if self.device.status >= STATUS.CONNECTED:
handle = int(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:
# print ("features getitem at %d" % index)
fs_index = self.features.index(_api.FEATURE.FEATURE_SET)
# technically fs_function is 0x10 for this call, but we add the index to differentiate possibly conflicting requests
fs_function = 0x10 | (index & 0x0F)
feature = _base.request(self.device.handle, self.device.number, _pack('!BB', fs_index, fs_function), _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
# print ("features contains %s" % repr(value))
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
# we know the features are ordered by value
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, handle, number, status_changed_callback, status=STATUS.BOOTING):
super(DeviceInfo, self).__init__(handle, number)
self.LOG = _Logger("Device[%d]" % (number))
assert status_changed_callback
self.status_changed_callback = status_changed_callback
self._status = status
self.status_updated = _timestamp()
self.props = {}
self._features = _FeaturesArray(self)
def __del__(self):
super(ReceiverListener, self).__del__()
self._features.supported = False
self._features.device = None
@property
def status(self):
return self._status
@status.setter
def status(self, new_status):
if new_status < STATUS.CONNECTED:
for p in list(self.props):
if p != PROPS.BATTERY_LEVEL:
del self.props[p]
else:
self._features._check()
self.protocol, self.codename, self.name, self.kind
self.status_updated = _timestamp()
old_status = self._status
if new_status != old_status and not (new_status == STATUS.CONNECTED and old_status > new_status):
self.LOG.debug("status %d => %d", old_status, new_status)
self._status = new_status
ui_flags = STATUS.UI_NOTIFY if new_status == STATUS.UNPAIRED else 0
self.status_changed_callback(self, ui_flags)
@property
def status_text(self):
if self._status < STATUS.CONNECTED:
return STATUS_NAME[self._status]
return STATUS_NAME[STATUS.CONNECTED]
@property
def properties_text(self):
t = []
if self.props.get(PROPS.BATTERY_LEVEL) is not None:
t.append('Battery: %d%%' % self.props[PROPS.BATTERY_LEVEL])
if self.props.get(PROPS.BATTERY_STATUS) is not None:
t.append(self.props[PROPS.BATTERY_STATUS])
if self.props.get(PROPS.LIGHT_LEVEL) is not None:
t.append('Light: %d lux' % self.props[PROPS.LIGHT_LEVEL])
return ', '.join(t)
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:
new_status, new_props = status
ui_flags = new_props.pop(PROPS.UI_FLAGS, 0)
old_props = dict(self.props)
self.props.update(new_props)
self.status = new_status
if ui_flags or old_props != self.props:
self.status_changed_callback(self, ui_flags)
return True
self.LOG.warn("don't know how to handle processed event status %s", status)
return False
def __str__(self):
return '<DeviceInfo(%s,%d,%s,%d)>' % (self.handle, self.number, self.codename or '?', self._status)
#
#
#
_RECEIVER_STATUS_NAME = _FallbackDict(
lambda x:
'1 device found' if x == STATUS.CONNECTED + 1 else
('%d devices found' % x) 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.LOG = _Logger("Receiver[%s]" % receiver.path)
self.receiver = receiver
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")
self.LOG.info("reports %d device(s) paired", len(receiver))
def __del__(self):
super(ReceiverListener, self).__del__()
self.receiver = None
def trigger_device_events(self):
if _base.request(int(self._handle), 0xFF, b'\x80\x02', b'\x02'):
self.LOG.info("triggered device events")
return True
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, STATUS.UI_NOTIFY)
def status_changed(self, device=None, ui_flags=0):
if self.status_changed_callback:
self.status_changed_callback(self.receiver, device, ui_flags)
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("failed to identify status of device %d from 0x%02X: %s", event.devnumber, 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:
self.make_device(event)
return
if event.devnumber == 0xFF:
if event.code == 0xFF and event.data is None:
self.LOG.warn("disconnected")
self.receiver.devices = {}
self.change_status(STATUS.UNAVAILABLE)
self.receiver = None
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.handle, event.devnumber, self.status_changed, status)
self.LOG.info("new device %s", dev)
dev.status = status
self.status_changed(dev, STATUS.UI_NOTIFY)
self.receiver.devices[event.devnumber] = dev
self.change_status(STATUS.CONNECTED + len(self.receiver.devices))
if status == STATUS.CONNECTED:
dev.serial, dev.firmware
return dev
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,%d)>' % (self.receiver.path, int(self.handle), self.receiver.status)
@classmethod
def open(self, status_changed_callback=None):
receiver = _api.Receiver.open()
if receiver:
handle = receiver.handle
receiver.handle = _api.ThreadedHandle(handle, receiver.path)
rl = ReceiverListener(receiver, status_changed_callback)
rl.start()
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,7 @@
#!/usr/bin/env python -u
NAME = 'Solaar'
VERSION = '0.7.4'
VERSION = '0.8.2'
__author__ = "Daniel Pavel <daniel.pavel@gmail.com>"
__version__ = VERSION
__license__ = "GPL"
@@ -10,65 +10,58 @@ __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('-q', '--quiet',
action='store_true',
help='disable all logging, takes precedence over --verbose')
arg_parser.add_argument('-S', '--no-systray',
action='store_false', dest='systray',
help='don\'t embed the application window into the systray')
arg_parser.add_argument('-N', '--no-notifications',
action='store_false', dest='notifications',
help='disable desktop notifications (shown only when in systray)')
arg_parser.add_argument('-v', '--verbose',
action='count', default=0,
help='increase the logger verbosity (may be repeated)')
arg_parser.add_argument('-S', '--no-systray',
action='store_false',
dest='systray',
help='don\'t embed the application window into the systray')
arg_parser.add_argument('-N', '--no-notifications',
action='store_false',
dest='notifications',
help='disable desktop notifications (shown only when in systray)')
arg_parser.add_argument('-V', '--version',
action='version',
version='%(prog)s ' + __version__)
args = arg_parser.parse_args()
import logging
if args.quiet:
logging.root.addHandler(logging.NullHandler())
logging.root.setLevel(logging.CRITICAL)
else:
if args.verbose > 0:
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)
else:
logging.root.addHandler(logging.NullHandler())
logging.root.setLevel(logging.CRITICAL)
return args
def _require(module, package):
try:
__import__(module)
except ImportError:
import sys
sys.exit("%s: missing required package '%s'" % (NAME, package))
if __name__ == '__main__':
_require('pyudev', 'python-pyudev')
_require('gi.repository', 'python-gi')
_require('gi.repository.Gtk', 'gir1.2-gtk-3.0')
args = _parse_arguments()
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,81 +71,62 @@ if __name__ == '__main__':
icon = None
window.present()
import pairing
from logitech.devices.constants import STATUS
from gi.repository import Gtk, GObject
listener = None
notify_missing = True
# initializes the receiver listener
def check_for_listener(notify=False):
# print ("check_for_listener %s" % notify)
global listener
listener = None
def status_changed(receiver, device=None, ui_flags=0):
assert receiver is not None
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:
if notify:
status_changed(DUMMY)
else:
return True
from logitech.unifying_receiver import status
# callback delivering status events from the receiver/devices to the UI
def status_changed(receiver, device=None, alert=status.ALERT.NONE, reason=None):
if window:
GObject.idle_add(ui.main_window.update, window, receiver, device)
if icon:
GObject.idle_add(ui.status_icon.update, icon, receiver)
if ui_flags & STATUS.UI_POPUP:
GObject.idle_add(ui.status_icon.update, icon, receiver, device)
if alert & status.ALERT.MED:
GObject.idle_add(window.popup, icon)
if device is None:
if ui.notify.available:
# always notify on receiver updates
ui_flags |= STATUS.UI_NOTIFY
if ui_flags & STATUS.UI_NOTIFY and ui.notify.available:
GObject.idle_add(ui.notify.show, device or receiver)
if device is None or alert & status.ALERT.LOW:
GObject.idle_add(ui.notify.show, device or receiver, reason)
global listener
if not listener:
GObject.timeout_add(5000, check_for_listener)
listener = None
if receiver is DUMMY:
GObject.timeout_add(3000, check_for_listener)
from receiver import ReceiverListener
def check_for_listener(retry=True):
def _check_still_scanning(listener):
if listener.receiver.status == STATUS.BOOTING:
listener.change_status(STATUS.CONNECTED)
global listener, notify_missing
if listener is None:
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:
pairing.state = None
if notify_missing:
status_changed(DUMMY, None, STATUS.UI_NOTIFY)
notify_missing = False
return retry
# print ("opened receiver", listener, listener.receiver)
notify_missing = True
status_changed(listener.receiver, None, STATUS.UI_NOTIFY)
GObject.timeout_add(3 * 1000, _check_still_scanning, listener)
pairing.state = pairing.State(listener)
listener.trigger_device_events()
_DEVICE_TIMEOUT = 3 * 60 # seconds
_DEVICE_STATUS_CHECK = 30 # seconds
from time import time as _timestamp
def check_for_inactive_devices():
if listener and listener.receiver:
for dev in listener.receiver.devices.values():
if (dev.status < STATUS.CONNECTED and
dev.props and
_timestamp() - dev.status_updated > _DEVICE_TIMEOUT):
dev.props.clear()
status_changed(listener.receiver, dev)
return True
GObject.timeout_add(50, check_for_listener, False)
GObject.timeout_add(_DEVICE_STATUS_CHECK * 1000, check_for_inactive_devices)
GObject.timeout_add(0, 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)

276
app/solaar_cli.py Normal file
View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python -u
import sys
import solaar
NAME = 'solaar-cli'
__author__ = solaar.__author__
__version__ = solaar.__version__
__license__ = solaar.__license__
#
#
#
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 the device")
if name in 'receiver':
return receiver
dev = None
for d in receiver:
if 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, short=True):
if short:
print ("-: Unifying Receiver [%s:%s]" % (receiver.path, receiver.serial))
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))
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: %s." % hidpp10.NOTIFICATION_FLAG.flag_names(notifications))
else:
print (" All notifications disabled.")
print (" Reported %d paired device(s)." % receiver.count())
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, short=True):
p = dev.protocol
state = '' if p > 0 else ' inactive'
if short:
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)
print (" Serial number: %s" % dev.serial)
print (" Wireless PID : %s" % dev.wpid)
if p == 0:
print (" Protocol : unknown (device is inactive)")
else:
print (" Protocol : HID++ %1.1f" % p)
for fw in dev.firmware:
print (" %-11s: %s %s" % (fw.kind, fw.name, fw.version))
if dev.power_switch_location:
print (" The power switch is located on the %s" % dev.power_switch_location)
if p == 0:
return
from logitech.unifying_receiver import hidpp10, hidpp20
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, hidpp20.KEY[k.key], hidpp20.KEY[k.task], flags))
battery = hidpp10.get_battery(dev) or hidpp20.get_battery(dev)
if battery:
charge, status = battery
print (" Battery: %d%% charged, %s" % (charge, status))
else:
print (" Battery report not supported.")
def list_devices(receiver, args):
_print_receiver(receiver, args.short)
for dev in receiver:
if not args.short:
print ("")
_print_device(dev, args.short)
def show_device(receiver, args):
dev = _find_device(receiver, args.device)
if dev is receiver:
_print_receiver(receiver, False)
else:
_print_device(dev, False)
def pair_device(receiver, args):
# get all current devices
known_devices = [dev.number for dev in receiver]
from threading import Event
done = Event()
from logitech.unifying_receiver import status
r_status = status.ReceiverStatus(receiver, lambda *args, **kwargs: None)
def _events_handler(event):
if event.devnumber == 0xFF:
r_status.process_event(event)
if not r_status.lock_open:
done.set()
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.is_set():
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")
try:
del receiver[dev.number]
print ("Unpaired %d: %s [%s:%s]" % (dev.number, dev.name, dev.codename, dev.serial))
except Exception as e:
_fail("failed to unpair device %s: %s" % (dev.name, e))
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('-V', '--version',
action='version',
version='%(prog)s ' + __version__)
subparsers = arg_parser.add_subparsers(title='sub-commands')
list_p = subparsers.add_parser('list', help='list paired devices')
list_p.add_argument('--full', action='store_false', dest='short',
help='print full info about each device')
list_p.set_defaults(cmd=list_devices)
show_p = subparsers.add_parser('show', help='show info about a single device',
epilog='The <device> argument may be a device number (1..6),'
' at least 3 characters of a device\'s name,'
' or "receiver".')
show_p.add_argument('device', help='device to show information about')
show_p.set_defaults(cmd=show_device)
pair_p = subparsers.add_parser('pair', help='pair a new device')
pair_p.set_defaults(cmd=pair_device)
unpair_p = subparsers.add_parser('unpair', help='unpair a device',
epilog='The <device> argument may be a device number (1..6),'
' or at least 3 characters of a device\'s name.')
unpair_p.add_argument('device', help='device to unpair')
unpair_p.set_defaults(cmd=unpair_device)
args = arg_parser.parse_args()
import logging
if args.verbose > 0:
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)
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,33 +1,76 @@
# pass
#
#
#
from . import (notify, status_icon, main_window, pair_window, action)
from gi.repository import (GObject, Gtk)
# from gi import pygtkcompat
# pygtkcompat.enable_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 . import notify, status_icon, main_window, pair_window, action
from solaar import NAME
_APP_ICONS = (NAME + '-fail', NAME + '-init', 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 type(receiver_status) == str
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 get_battery_icon(level):
if level < 0:
return 'battery_unknown'
return 'battery_%03d' % (10 * ((level + 5) // 10))
def icon_file(name):
if name and _ICON_THEME.has_icon(name):
return _ICON_THEME.lookup_icon(name, 0, 0).get_filename()
return None
_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):

View File

@@ -2,8 +2,8 @@
#
#
# from sys import version as PYTTHON_VERSION
from gi.repository import (Gtk, Gdk)
# from sys import version as PYTHON_VERSION
from gi.repository import Gtk, Gdk
import ui
from solaar import NAME as _NAME
@@ -39,16 +39,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.add_credit_section('Testing', 'Douglas Wagner')
about.set_website('http://github.com/pwr/Solaar/wiki')
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,17 +74,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)
# window.present()
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)
pair_dialog.set_type_hint(Gdk.WindowTypeHint.DIALOG)
pair_dialog.set_position(Gtk.WindowPosition.CENTER)
pair_dialog.present()
def pair(frame):
@@ -89,7 +102,9 @@ def _unpair_device(action, frame):
choice = qdialog.run()
qdialog.destroy()
if choice == Gtk.ResponseType.ACCEPT:
if not pairing.state.unpair(device):
try:
del device.receiver[device.number]
except:
ui.error(window, 'Unpairing failed', 'Failed to unpair device\n%s .' % device.name)
def unpair(frame):

View File

@@ -2,16 +2,17 @@
#
#
from gi.repository import (Gtk, Gdk, GObject)
from gi.repository import Gtk, Gdk, GObject
import ui
from logitech.devices.constants import (STATUS, PROPS)
from logitech.unifying_receiver import status as _status
_SMALL_DEVICE_ICON_SIZE = Gtk.IconSize.BUTTON
_RECEIVER_ICON_SIZE = Gtk.IconSize.BUTTON
_DEVICE_ICON_SIZE = Gtk.IconSize.DIALOG
_STATUS_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR
_PLACEHOLDER = '~'
_FALLBACK_ICON = 'preferences-desktop-peripherals'
#
#
@@ -22,13 +23,20 @@ def _make_receiver_box(name):
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', Gtk.IconSize.MENU)
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)
@@ -38,9 +46,10 @@ def _make_receiver_box(name):
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 = Gtk.Label('Querying ...')
info_label.set_name('info-label')
info_label.set_alignment(0, 0.5)
info_label.set_padding(8, 2)
@@ -50,7 +59,8 @@ def _make_receiver_box(name):
info_box.add(info_label)
info_box.set_shadow_type(Gtk.ShadowType.ETCHED_IN)
toggle_info_action = ui.action._toggle_action('info', 'Receiver info', _toggle_info_box, info_label, info_box, frame, _update_receiver_info_label)
toggle_info_action = ui.action._toggle_action('info', 'Receiver info',
_toggle_info_box, info_box, frame, _update_receiver_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)
@@ -62,7 +72,9 @@ def _make_receiver_box(name):
frame.add(vbox)
frame.show_all()
info_box.set_visible(False)
pairing_icon.set_visible(False)
return frame
@@ -71,8 +83,7 @@ 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)
@@ -81,7 +92,7 @@ def _make_device_box(index):
label.set_alignment(0, 0.5)
label.set_padding(4, 4)
battery_icon = Gtk.Image.new_from_icon_name('battery_unknown', _STATUS_ICON_SIZE)
battery_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)
@@ -93,11 +104,16 @@ 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)
not_encrypted_icon.set_name('not-encrypted')
not_encrypted_icon.set_tooltip_text('The link is not encrypted!')
toolbar = Gtk.Toolbar()
toolbar.set_name('toolbar')
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
toolbar.set_icon_size(Gtk.IconSize.MENU)
toolbar.set_show_arrow(False)
toolbar.set_border_width(0)
status_box = Gtk.HBox(homogeneous=False, spacing=0)
status_box.set_name('status')
@@ -106,18 +122,21 @@ def _make_device_box(index):
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)
info_label = Gtk.Label()
info_label = Gtk.Label('Querying ...')
info_label.set_name('info-label')
info_label.set_alignment(0, 0.5)
info_label.set_padding(8, 2)
info_label.set_selectable(True)
info_label.fields = {}
info_label._fields = {}
info_box = Gtk.Frame()
info_box.add(info_label)
info_box.set_shadow_type(Gtk.ShadowType.ETCHED_IN)
toggle_info_action = ui.action._toggle_action('info', 'Device info', _toggle_info_box, info_label, info_box, frame, _update_device_info_label)
toggle_info_action = ui.action._toggle_action('info', 'Device info',
_toggle_info_box, info_box, frame, _update_device_info_label)
toolbar.insert(toggle_info_action.create_tool_item(), 0)
toolbar.insert(ui.action.unpair(frame).create_tool_item(), -1)
@@ -172,6 +191,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
@@ -184,6 +204,10 @@ def create(title, name, max_devices, systray=False):
if systray:
window.set_keep_above(True)
# window.set_decorated(False)
# window.set_type_hint(Gdk.WindowTypeHint.TOOLTIP)
# window.set_skip_taskbar_hint(True)
# window.set_skip_pager_hint(True)
window.connect('delete-event', toggle)
else:
window.connect('delete-event', Gtk.main_quit)
@@ -195,108 +219,103 @@ def create(title, name, max_devices, systray=False):
#
def _update_device_info_label(label, dev):
need_update = False
items = [('Wireless PID', dev.wpid), ('Serial', dev.serial)]
hid = dev.protocol
if hid:
items += [('Protocol', 'HID++ %1.1f' % dev.protocol)]
firmware = dev.firmware
if firmware:
items += [(f.kind, f.name + ' ' + f.version) for f in firmware]
if 'serial' in label.fields:
serial = label.fields['serial']
else:
serial = label.fields['serial'] = dev.serial
need_update = True
if 'firmware' in label.fields:
firmware = label.fields['firmware']
else:
if dev.status >= STATUS.CONNECTED:
firmware = label.fields['firmware'] = dev.firmware
need_update = True
else:
firmware = None
if 'hid' in label.fields:
hid = label.fields['hid']
else:
if dev.status >= STATUS.CONNECTED:
hid = label.fields['hid'] = dev.protocol
need_update = True
else:
hid = None
if need_update:
items = [('Serial', serial)]
if firmware:
items += [(f.kind, f.name + ' ' + f.version) for f in firmware]
if hid:
items += [('HID', hid)]
label.set_markup('<small><tt>%s</tt></small>' % '\n'.join('%-10s: %s' % (item[0], str(item[1])) for item in items))
label.set_markup('<small><tt>' + '\n'.join('%-12s: %s' % item for item in items) + '</tt></small>')
def _update_receiver_info_label(label, dev):
if label.get_visible() and label.get_text() == '':
if label.get_visible() and '\n' not in label.get_text():
items = [('Serial', dev.serial)] + \
[(f.kind, f.version) for f in dev.firmware]
label.set_markup('<small><tt>%s</tt></small>' % '\n'.join('%-10s: %s' % (item[0], str(item[1])) for item in items))
label.set_markup('<small><tt>' + '\n'.join('%-10s: %s' % item for item in items) + '</tt></small>')
def _toggle_info_box(action, label_widget, box_widget, frame, update_function):
def _toggle_info_box(action, box, frame, update_function):
if action.get_active():
box_widget.set_visible(True)
update_function(label_widget, frame._device)
box.set_visible(True)
GObject.timeout_add(50, update_function, box.get_child(), frame._device)
else:
box_widget.set_visible(False)
box.set_visible(False)
def _update_receiver_box(frame, receiver):
label, toolbar, info_label = ui.find_children(frame, 'label', 'toolbar', 'info-label')
icon, label, pairing_icon, toolbar, info_label = ui.find_children(frame, 'icon', 'label', 'pairing-icon', 'toolbar', 'info-label')
label.set_text(receiver.status_text or '')
if receiver.status < STATUS.CONNECTED:
toolbar.set_sensitive(False)
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_visible(True)
else:
frame._device = None
icon.set_sensitive(False)
pairing_icon.set_visible(False)
toolbar.set_visible(False)
toolbar.get_children()[0].set_active(False)
info_label.set_text('')
frame._device = None
else:
toolbar.set_sensitive(True)
frame._device = receiver
def _update_device_box(frame, dev):
frame._device = dev
# print (dev.name, dev.kind)
icon, label, info_label = ui.find_children(frame, 'icon', 'label', 'info-label')
icon, label, toolbar, info_label = ui.find_children(frame, 'icon', 'label', 'toolbar', 'info-label')
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.get_children()[0].set_active(False)
status = ui.find_children(frame, 'status')
status_icons = status.get_children()
status_icons = ui.find_children(frame, 'status').get_children()
battery_icon, battery_label, light_icon, light_label, not_encrypted_icon = status_icons[0:5]
if dev.status < STATUS.CONNECTED:
battery_level = dev.status.get(_status.BATTERY_LEVEL)
if not dev.status:
label.set_sensitive(False)
for c in status_icons[2:-1]:
c.set_visible(False)
battery_icon, battery_label = status_icons[0:2]
battery_icon.set_sensitive(False)
battery_label.set_sensitive(False)
battery_level = dev.props.get(PROPS.BATTERY_LEVEL)
if battery_level is None:
battery_label.set_markup('<small>%s</small>' % dev.status_text)
battery_label.set_markup('<small>inactive</small>')
else:
battery_label.set_markup('%d%%' % battery_level)
for c in status_icons[2:-1]:
c.set_visible(False)
else:
label.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_sensitive(False)
battery_icon.set_from_icon_name('battery_unknown', _STATUS_ICON_SIZE)
battery_icon.set_from_icon_name(ui.get_battery_icon(-1), _STATUS_ICON_SIZE)
text = 'no status' if dev.protocol < 2.0 else 'waiting for status...'
battery_label.set_markup('<small>%s</small>' % text)
battery_label.set_sensitive(True)
@@ -306,11 +325,10 @@ def _update_device_box(frame, dev):
battery_label.set_text('%d%%' % battery_level)
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)
@@ -321,30 +339,34 @@ def _update_device_box(frame, dev):
light_label.set_text('%d lux' % light_level)
light_label.set_visible(True)
not_encrypted_icon.set_visible(dev.status.get(_status.ENCRYPTED) == False)
if first_run:
frame.set_visible(True)
GObject.timeout_add(2000, _update_device_info_label, info_label, dev)
GObject.timeout_add(5000, _update_device_info_label, info_label, dev)
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
else:
if device:
frame = frames[device.number]
if device.status == STATUS.UNPAIRED:
if device.status is None:
frame.set_visible(False)
frame.set_name(_PLACEHOLDER)
frame._device = None
else:
_update_device_box(frame, device)
else:
_update_receiver_box(frames[0], receiver)
if not receiver:
for frame in frames[1:]:
frame.set_visible(False)
frame.set_name(_PLACEHOLDER)
frame._device = None

View File

@@ -9,20 +9,9 @@ try:
from gi.repository import Notify
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 +36,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 +46,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 +62,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,133 +2,187 @@
#
#
# import logging
from gi.repository import (Gtk, GObject)
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, icon_name=None):
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 text:
item = Gtk.HBox(homogeneous=False, spacing=16)
if header:
item = Gtk.HBox(False, 16)
p.pack_start(item, False, True, 0)
label = Gtk.Label(text)
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)
assistant.append_page(p)
assistant.set_page_type(p, kind)
if text:
label = Gtk.Label(text)
label.set_alignment(0, 0)
label.set_line_wrap(True)
p.pack_start(label, False, False, 0)
p.show_all()
return p
def _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 the\nreceiver\'s range, and it has\na decent battery charge.\n',
Gtk.AssistantPageType.CONFIRM,
'dialog-error')
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(500, _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)
def _finish(assistant, receiver):
if _log.isEnabledFor(_DEBUG):
_log.debug("finish %s", assistant)
assistant.destroy()
receiver.status.new_device = None
if receiver.status.lock_open:
receiver.set_lock()
else:
receiver.status[_status.ERROR] = None
def _pairing_failed(assistant, receiver, error):
if _log.isEnabledFor(_DEBUG):
_log.debug("%s fail: %s", receiver, error)
assistant.commit()
header = 'Pairing failed: %s.' % error
if 'timeout' in str(error):
text = 'Make sure your device is within range,\nand it has a decent battery charge.'
else:
text = None
_create_page(assistant, Gtk.AssistantPageType.SUMMARY, header, 'dialog-error', text)
assistant.next_page()
assistant.commit()
def _pairing_succeeded(assistant, receiver, device):
assert device
if _log.isEnabledFor(_DEBUG):
_log.debug("%s success: %s", receiver, device)
page = _create_page(assistant, Gtk.AssistantPageType.SUMMARY)
header = Gtk.Label('Found a new device:')
header.set_alignment(0.5, 0)
page.pack_start(header, False, False, 0)
device_icon = Gtk.Image()
icon_set = 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, False, False, 0)
if device.status.get('encrypted') == False:
hbox = Gtk.HBox(False, 8)
hbox.pack_start(Gtk.Image.new_from_icon_name('dialog-warning', Gtk.IconSize.MENU), False, False, 0)
hbox.pack_start(Gtk.Label('The wireless link is not encrypted!'), False, False, 0)
halign = Gtk.Alignment.new(0.5, 0, 0, 0)
halign.add(hbox)
page.pack_start(halign, False, False, 0)
entry_info = Gtk.Label('Use the controls above to confirm\n'
'this is the device you want to pair.')
entry_info.set_sensitive(False)
page.pack_start(entry_info, False, False, 0)
page.pack_start(Gtk.Label(), True, True, 0)
page.show_all()
assistant.set_page_complete(page, True)
page.show_all()
assistant.next_page()
def _scan_complete(assistant, device):
GObject.idle_add(_scan_complete_ui, assistant, device)
assistant.commit()
def create(action, state):
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,
'preferences-desktop-peripherals')
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,17 +2,21 @@
#
#
from gi.repository import Gtk
from gi.repository import Gtk, GdkPixbuf
import ui
from logitech.devices.constants import (STATUS, PROPS)
from logitech.unifying_receiver import status as _status
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 = {}
icon.set_tooltip_text(name)
icon.connect('activate', window.toggle_visible)
menu = Gtk.Menu()
@@ -31,38 +35,72 @@ def create(window, menu_actions=None):
return icon
def update(icon, receiver):
battery_level = None
_PIXMAPS = {}
def _icon_with_battery(s):
battery_icon = ui.get_battery_icon(s[_status.BATTERY_LEVEL])
lines = [ui.NAME + ': ' + 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)
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
lines = [ui.NAME + ': ' + str(receiver.status), '']
if receiver:
for k in range(1, 1 + receiver.max_devices):
dev = icon._devices.get(k)
if dev is None:
continue
if receiver.status > STATUS.CONNECTED:
devlist = sorted(receiver.devices.values(), key=lambda x: x.number)
for dev in devlist:
lines.append('<b>' + dev.name + '</b>')
p = dev.properties_text
assert hasattr(dev, 'status') and dev.status is not None
p = str(dev.status)
if p:
p = '\t' + p
if dev.status < STATUS.CONNECTED:
p += ' <small>(' + dev.status_text + ')</small>'
lines.append(p)
elif dev.status < STATUS.CONNECTED:
lines.append('\t<small>(' + dev.status_text + ')</small>')
elif dev.protocol < 2.0:
lines.append('\t' + '<small>no status</small>')
if not dev.status:
p += ' <small>(inactive)</small>'
else:
lines.append('\t' + '<small>waiting for status...</small>')
if dev.status:
if dev.protocol < 2.0:
p = '<small>no status</small>'
else:
p = '<small>waiting for status...</small>'
else:
p = '<small>(inactive)</small>'
lines.append('\t' + p)
lines.append('')
if battery_level is None:
if PROPS.BATTERY_LEVEL in dev.props:
battery_level = dev.props[PROPS.BATTERY_LEVEL]
if battery_status is None and dev.status.get(_status.BATTERY_LEVEL):
battery_status = dev.status
else:
icon._devices.clear()
icon.set_tooltip_markup('\n'.join(lines).rstrip('\n'))
if battery_level is None:
if battery_status is None:
icon.set_from_icon_name(ui.appicon(receiver.status))
else:
icon.set_from_icon_name(ui.get_battery_icon(battery_level))
icon.set_from_pixbuf(_icon_with_battery(battery_status))

View File

@@ -4,5 +4,5 @@ Z=`readlink -f "$0"`
LIB=`readlink -f $(dirname "$Z")/../lib`
export PYTHONPATH=$LIB
PYTHON=`which python python2 python3 | head -n 1`
exec $PYTHON -u -m hidapi.hidconsole "$@"
PYTHON=${PYTHON:-`which python python2 python3 | head -n 1`}
exec $PYTHON -m hidapi.hidconsole "$@"

View File

@@ -4,5 +4,5 @@ Z=`readlink -f "$0"`
LIB=`readlink -f $(dirname "$Z")/../lib`
export PYTHONPATH=$LIB
PYTHON=`which python python2 python3 | head -n 1`
exec $PYTHON -u -m logitech.scanner "$@"
PYTHON=${PYTHON:-`which python python2 python3 | head -n 1`}
exec $PYTHON -m logitech.scanner "$@"

View File

@@ -6,7 +6,7 @@ LIB=`readlink -f $(dirname "$Z")/../lib`
SHARE=`readlink -f $(dirname "$Z")/../share`
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 -u -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 "$@"

View File

@@ -5,7 +5,7 @@ import sys
from select import select as _select
import time
from binascii import hexlify, unhexlify
_hex = lambda d: hexlify(d).decode('ascii').upper()
strhex = lambda d: hexlify(d).decode('ascii').upper()
interactive = os.isatty(0)
@@ -24,7 +24,7 @@ def _print(marker, data, scroll=False):
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)
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))
sys.stdout.write(s)
@@ -34,7 +34,7 @@ def _print(marker, data, scroll=False):
sys.stdout.write('\n')
def _continuous_read(handle, timeout=1000):
def _continuous_read(handle, timeout=2000):
while True:
reply = hidapi.read(handle, 128, timeout)
if reply is None:
@@ -95,8 +95,13 @@ if __name__ == '__main__':
hidapi.write(handle, data)
# wait for some kind of reply
if not interactive:
rlist, wlist, xlist = _select([handle], [], [], 1)
time.sleep(0.1)
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(0.050)
except EOFError:
pass
except Exception as e:

View File

@@ -10,8 +10,7 @@ necessary.
import os as _os
import errno as _errno
from select import select as _select
from pyudev import (Context as _Context,
Device as _Device)
from pyudev import Context as _Context, Device as _Device
native_implementation = 'udev'
@@ -32,6 +31,7 @@ DeviceInfo = namedtuple('DeviceInfo', [
])
del namedtuple
#
# exposed API
# docstrings mostly copied from hidapi.h

View File

@@ -1,107 +0,0 @@
#
#
#
import logging
from .constants import (STATUS, PROPS)
from ..unifying_receiver.constants import (FEATURE, BATTERY_STATUS, BATTERY_OK)
from ..unifying_receiver import api as _api
#
#
#
_DEVICE_MODULES = {}
def _module(device):
shortname = device.codename.lower().replace(' ', '_')
if shortname not in _DEVICE_MODULES:
try:
m = __import__(shortname, globals(), level=1)
_DEVICE_MODULES[shortname] = m
except:
# logging.exception(shortname)
_DEVICE_MODULES[shortname] = None
return _DEVICE_MODULES[shortname]
#
#
#
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:
b_discharge, dischargeNext, b_status = reply
return STATUS.CONNECTED, {
PROPS.BATTERY_LEVEL: b_discharge,
PROPS.BATTERY_STATUS: b_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 == 0x00:
b_discharge = ord(data[2:3])
b_status = ord(data[3:4])
return STATUS.CONNECTED, {
PROPS.BATTERY_LEVEL: b_discharge,
PROPS.BATTERY_STATUS: BATTERY_STATUS[b_status],
PROPS.UI_FLAGS: 0 if BATTERY_OK(b_status) else STATUS.UI_NOTIFY,
}
# ?
elif feature == FEATURE.REPROGRAMMABLE_KEYS:
if feature_function == 0x00:
logging.debug('reprogrammable key: %s', repr(data))
# TODO
pass
# ?
elif feature == FEATURE.WIRELESS:
if feature_function == 0x00:
logging.debug("wireless status: %s", repr(data))
if data[2:5] == b'\x01\x01\x01':
return STATUS.CONNECTED, {PROPS.UI_FLAGS: STATUS.UI_NOTIFY}
# 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)
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)
if m and 'process_event' in m.__dict__:
return m.process_event(devinfo, data)

View File

@@ -1,50 +0,0 @@
#
#
#
STATUS = type('STATUS', (),
dict(
UI_NOTIFY=0x01,
UI_POPUP=0x02,
UNKNOWN=-0xFFFF,
UNPAIRED=-0x1000,
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',
UI_FLAGS='ui_flags',
))
# 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,55 +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'\x06', 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")
if request_status(devinfo) == STATUS.UNAVAILABLE:
return STATUS.UNAVAILABLE, {PROPS.UI_FLAGS: STATUS.UI_POPUP | STATUS.UI_NOTIFY}
code, props = _charge_status(data)
props[PROPS.UI_FLAGS] = STATUS.UI_POPUP
return code, props

View File

@@ -7,54 +7,59 @@ def print_receiver(receiver):
print (" Serial : %s" % receiver.serial)
for f in receiver.firmware:
print (" %-10s: %s" % (f.kind, f.version))
print (" Reported %d paired device(s)" % len(receiver))
notifications = receiver.request(0x8100)
if notifications:
notifications = ord(notifications[0:1]) << 16 | ord(notifications[1:2]) << 8
if notifications:
print (" Enabled notifications: %s." % lur.hidpp10.NOTIFICATION_FLAG.flag_names(notifications))
else:
print (" All notifications disabled.")
print (" Reported %d paired device(s)." % receiver.count())
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 scan_devices(receiver):
for number in range(1, 1 + receiver.max_devices):
dev = receiver[number]
if dev is None:
dev = api.PairedDevice(receiver.handle, number)
if dev.codename is None:
continue
for dev in receiver:
print ("--------")
print (str(dev))
print ("Codename : %s" % dev.codename)
print ("Name : %s" % dev.name)
print ("Kind : %s" % dev.kind)
print ("Name : %s" % dev.name)
print ("Device number: %d" % dev.number)
print ("Wireless PID : %s" % dev.wpid)
print ("Serial number: %s" % dev.serial)
print ("Power switch : on the %s" % dev.power_switch_location)
if not dev.protocol:
if not dev.ping():
print ("Device is not connected at this time, no further info available.")
continue
print ("HID protocol : HID %01.1f" % dev.protocol)
if dev.protocol < 2.0:
print ("Features query not supported by this device")
print ("HID protocol : HID++ %01.1f" % dev.protocol)
if not dev.features:
print ("Features query not supported by this device.")
continue
firmware = dev.firmware
for fw in firmware:
for fw in dev.firmware:
print (" %-11s: %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]
print (" %d features:" % len(dev.features))
for index, feature in enumerate(dev.features):
feature = dev.features[index]
if feature:
print (" ~ Feature %-20s (%s) at index %02X" % (FEATURE_NAME[feature], api._hex(feature), index))
flags = dev.request(0x0000, feature.bytes(2))
flags = 0 if flags is None else ord(flags[1:2])
flags = lur.hidpp20.FEATURE_FLAG.flag_names(flags)
print (" %2d: %-20s {%04X} %s" % (index, feature, feature, flags))
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))
if dev.keys:
print (" %d reprogrammable keys:" % len(dev.keys))
for k in dev.keys:
flags = lur.hidpp20.KEY_FLAG.flag_names(k.flags)
print (" %2d: %-20s => %-20s %s" % (k.index, lur.hidpp20.KEY[k.key], lur.hidpp20.KEY[k.task], flags))
if __name__ == '__main__':
@@ -65,12 +70,12 @@ if __name__ == '__main__':
args = arg_parser.parse_args()
import logging
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING)
log_format='%(asctime)s %(levelname)8s %(name)s: %(message)s'
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING, format=log_format)
from .unifying_receiver import api
from .unifying_receiver.constants import *
from . import unifying_receiver as lur
receiver = api.Receiver.open()
receiver = lur.Receiver.open()
if receiver is None:
print ("Logitech Unifying Receiver not found.")
else:

View File

@@ -6,15 +6,6 @@ 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/
@@ -22,14 +13,21 @@ http://6xq.net/git/lars/lshidpp.git/plain/doc/
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 .devices import DEVICES
from . import listener
from . import status

View File

@@ -1,566 +0,0 @@
#
# Logitech Unifying Receiver API.
#
from struct import pack as _pack
from struct import unpack as _unpack
import errno as _errno
from threading import local as _local
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 ThreadedHandle(object):
__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 = _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):
self._local = None
handles, self._handles = self._handles, []
_log.debug("%s closing %s", repr(self), handles)
for h in handles:
_base.close(h)
def __del__(self):
self.close()
def __int__(self):
if self._local:
try:
return self._local.handle
except:
return self._open()
def __str__(self):
return str(int(self))
def __repr__(self):
return '<LocalHandle[%s]>' % self.path
def __bool__(self):
return bool(self._handles)
__nonzero__ = __bool__
class PairedDevice(object):
def __init__(self, handle, number):
assert handle
self.handle = handle
assert number > 0 and number <= MAX_ATTACHED_DEVICES
self.number = number
self._protocol = None
self._features = None
self._codename = None
self._name = None
self._kind = None
self._serial = None
self._firmware = None
def __del__(self):
self.handle = None
@property
def protocol(self):
if self._protocol is None:
self._protocol = _base.ping(self.handle, self.number)
# _log.debug("device %d protocol %s", self.number, self._protocol)
return self._protocol or 0
@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')
# _log.debug("device %d codename %s", self.number, self._codename)
return self._codename
@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 or '?'
@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)
# _log.debug("device %d firmware %s", self.number, self._firmware)
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])
# _log.debug("device %d serial %s", self.number, self._serial)
return self._serial or '?'
def ping(self):
return _base.ping(self.handle, self.number) is not None
def __str__(self):
return '<PairedDevice(%s,%d,%s)>' % (self.handle, self.number, self.codename or '?')
class Receiver(object):
name = 'Unifying Receiver'
max_devices = MAX_ATTACHED_DEVICES
def __init__(self, handle, path=None):
assert handle
self.handle = handle
assert path
self.path = path
self._serial = None
self._firmware = None
def close(self):
handle, self.handle = self.handle, None
return (handle and _base.close(handle))
def __del__(self):
self.close()
@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 not self.handle:
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 not self.handle 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 not self.handle 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 not self.handle:
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):
# print (self, "contains", 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(%s,%s)>' % (self.handle, self.path)
__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.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
#
#
#
def request(handle, devnumber, feature, function=b'\x04', 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:
try:
if len(features) <= index:
features += [None] * (index + 1 - len(features))
features[index] = feature
except:
pass
# _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'\x05')
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'\x15', _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, 0x05))
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, 0x15), 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, 0x25))
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, 0x05))
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, 0x15), 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, 0x05))
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, 0x05))
if count:
keys = []
count = ord(count[:1])
for index in range(0, count):
keydata = _base.request(handle, devnumber, _pack('!BB', rk_fi, 0x15), 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,59 +3,74 @@
# Unlikely to be used directly unless you're expanding the API.
#
import os as _os
from time import time as _timestamp
from struct import pack as _pack
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 = 2000
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 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 == '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
def open_path(path):
"""Checks if the given Linux device path points to the right UR device.
@@ -77,7 +92,7 @@ def open():
:returns: An open file handle for the found receiver, or ``None``.
"""
for rawdevice in list_receiver_devices():
for rawdevice in receivers():
handle = open_path(rawdevice.path)
if handle:
return handle
@@ -101,7 +116,7 @@ def close(handle):
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.
@@ -114,70 +129,77 @@ 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("(%s) <= w[10 %02X %s %s]", handle, devnumber, _hex(wdata[2:4]), _hex(wdata[4:]))
if len(data) > _SHORT_MESSAGE_SIZE - 2 or data[:1] == b'\x82':
wdata = _pack('!BB18s', 0x11, devnumber, data)
else:
wdata = _pack('!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(reason)
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.
"""
reply = _read(handle, timeout)
if reply:
return reply[1:]
def _read(handle, timeout):
try:
data = _hid.read(int(handle), _MAX_REPLY_SIZE, timeout)
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(reason)
raise NoReceiver(reason=reason)
if data:
if len(data) < _MIN_REPLY_SIZE:
_log.warn("(%s) => r[%s] read packet too short: %d bytes", handle, _hex(data), len(data))
data += b'\x00' * (_MIN_REPLY_SIZE - len(data))
if len(data) > _MAX_REPLY_SIZE:
_log.warn("(%s) => r[%s] read packet too long: %d bytes", handle, _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("(%s) => r[%02X %02X %s %s]", handle, 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_REPLY_SIZE, 0)
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)
raise NoReceiver(reason=reason)
if data:
if unhandled_hook:
unhandled_hook(ord(data[:1]), ord(data[1:2]), data[2:])
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
@@ -185,22 +207,43 @@ def _skip_incoming(handle):
#
#
"""The function that will be called on unhandled incoming events.
"""The function that may be called on incoming events.
The hook must be a function with the signature: ``_(int, int, str)``, where
the parameters are: (reply_code, devnumber, data).
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() 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.
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.
"""
unhandled_hook = None
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)
def request(handle, devnumber, feature_index_function, params=b'', features=None):
from collections import namedtuple
_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))
del namedtuple
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:])
else:
address = ord(data[1:2])
if sub_id > 0x00 and sub_id < 0x80 and (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
@@ -209,68 +252,83 @@ 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)
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:
timeout = _RECEIVER_REQUEST_TIMEOUT
request_str = _pack('!H', request_id)
# _log.debug("%s device %d request {%s} params [%s]", handle, 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))
params = b''.join(_pack('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))
_skip_incoming(handle)
ihandle = int(handle)
write(ihandle, devnumber, feature_index_function + params)
write(ihandle, devnumber, request_str + params)
while True:
now = _timestamp()
reply = read(ihandle, DEFAULT_TIMEOUT)
reply = _read(handle, timeout)
delta = _timestamp() - now
if reply:
reply_code, reply_devnumber, reply_data = reply
report_id, reply_devnumber, reply_data = reply
if reply_devnumber == devnumber:
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 report_id == 0x10 and reply_data[:1] == b'\x8F' and reply_data[1:3] == request_str:
error = ord(reply_data[3:4])
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
# 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 == 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 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 == 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:]
_log.debug("(%s) device %d error on request {%04X}: %d = %s",
handle, devnumber, request_id, error, _hidpp10.ERROR[error])
break
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 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 unhandled_hook:
unhandled_hook(reply_code, reply_devnumber, reply_data)
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 delta >= DEFAULT_TIMEOUT:
_log.warn("timeout on device %d request {%s} params[%s]", devnumber, _hex(feature_index_function), _hex(params))
return None
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:
return reply_data[2:]
else:
return reply_data[2:]
_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):
@@ -278,35 +336,49 @@ def ping(handle, devnumber):
:returns: The HID protocol supported by the device, as a floating point number, if the device is active.
"""
_log.debug("%s pinging device %d", handle, devnumber)
if _log.isEnabledFor(_DEBUG):
_log.debug("(%s) pinging device %d", handle, devnumber)
_skip_incoming(handle)
ihandle = int(handle)
write(ihandle, devnumber, b'\x00\x11\x00\x00\xAA')
# 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('!H', request_id)
ping_mark = _pack('B', _random_bits(8))
write(ihandle, devnumber, request_str + b'\x00\x00' + ping_mark)
while True:
now = _timestamp()
reply = read(ihandle, DEFAULT_TIMEOUT)
reply = _read(ihandle, _PING_TIMEOUT)
delta = _timestamp() - now
if reply:
reply_code, reply_devnumber, reply_data = reply
if reply_devnumber == devnumber:
if reply_code == 0x11 and reply_data[:2] == b'\x00\x11' and reply_data[4:5] == b'\xAA':
# HID 2.0+ device, currently connected
return ord(reply_data[2:3]) + ord(reply_data[3:4]) / 10.0
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 == 0x10 and reply_data == b'\x8F\x00\x11\x01\x00':
# HID 1.0 device, currently connected
return 1.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[:3] == b'\x8F\x00\x11':
# a disconnected device
return None
if error == _hidpp10.ERROR.invalid_SubID__command: # a valid reply from a HID++ 1.0 device
return 1.0
if unhandled_hook:
unhandled_hook(reply_code, reply_devnumber, reply_data)
if error == _hidpp10.ERROR.resource_error: # device unreachable
# raise DeviceUnreachable(number=devnumber, request=request_id)
break
if delta >= DEFAULT_TIMEOUT:
_log.warn("timeout on device %d ping", devnumber)
return None
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,78 @@
# Some common functions and types.
#
from collections import namedtuple
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 integer with an attached name."""
# __slots__ = ['name']
def __getitem__(self, key):
def __new__(cls, value, name):
obj = int.__new__(cls, value)
obj.name = 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('!L', value)[-count:]
def __str__(self):
return self.name
def __repr__(self):
return 'NamedInt(%d, %s)' % (int(self), repr(self.name))
class NamedInts(object):
def __init__(self, **kwargs):
values = dict((k, NamedInt(v, k if k == k.upper() else k.replace('__', '/').replace('_', ' '))) for (k, v) in kwargs.items())
self.__dict__.update(values)
self._indexed = dict((int(v), v) for v in values.values())
self._fallback = None
def __getitem__(self, index):
if index in self._indexed:
return self._indexed[index]
if self._fallback:
value = NamedInt(index, self._fallback(index))
self._indexed[index] = value
return value
def __contains__(self, value):
return int(value) in self._indexed
def __len__(self):
return len(self.values)
def flag_names(self, value):
return ', '.join(str(self._indexed[k]) for k in self._indexed if k & value == k)
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 +82,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')
BATTERY_OK = lambda status: status < 5
"""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,30 @@
#
#
#
from collections import namedtuple
_D = namedtuple('_DeviceDescriptor', ['codename', 'name', 'kind'])
del namedtuple
DEVICES = ( _D('M315', 'Wireless Mouse M315', 'mouse'),
_D('M325', 'Wireless Mouse M325', 'mouse'),
_D('M505', 'Wireless Mouse M505', 'mouse'),
_D('M510', 'Wireless Mouse M510', 'mouse'),
_D('M515', 'Couch Mouse M515', 'mouse'),
_D('M525', 'Wireless Mouse M525', 'mouse'),
_D('M570', 'Wireless Trackball M570', 'trackball'),
_D('M600', 'Touch Mouse M600', 'mouse'),
_D('M705', 'Marathon Mouse M705', 'mouse'),
_D('K270', 'Wireless Keyboard K270', 'keyboard'),
_D('K350', 'Wireless Keyboard K350', 'keyboard'),
_D('K360', 'Wireless Keyboard K360', 'keyboard'),
_D('K400', 'Wireless Touch Keyboard K400', 'keyboard'),
_D('K750', 'Wireless Solar Keyboard K750', 'keyboard'),
_D('K800', 'Wireless Illuminated Keyboard K800', 'keyboard'),
_D('T400', 'Zone Touch Mouse T400', 'mouse'),
_D('T650', 'Wireless Rechargeable Touchpad T650', 'touchpad'),
_D('Cube', 'Logitech Cube', 'mouse'),
_D('Anywhere MX', 'Anywhere Mouse MX', 'mouse'),
_D('Performance MX', 'Performance Mouse MX', 'mouse'),
)
DEVICES = { d.codename: d for d in DEVICES }

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,100 @@
#
#
#
from .common import (strhex as _strhex,
NamedInts as _NamedInts,
FirmwareInfo as _FirmwareInfo)
from .hidpp20 import FIRMWARE_KIND
#
# constants
#
DEVICE_KIND = _NamedInts(
keyboard=0x01,
mouse=0x02,
numpad=0x03,
presenter=0x04,
trackball=0x08,
touchpad=0x09)
POWER_SWITCH_LOCATION = _NamedInts(
base=0x01,
top_case=0x02,
edge_of_top_right_corner=0x03,
top_left_corner=0x05,
bottom_left_corner=0x06,
top_right_corner=0x07,
bottom_right_corner=0x08,
top_edge=0x09,
right_edge=0x0A,
left_edge=0x0B,
bottom_edge=0x0C)
NOTIFICATION_FLAG = _NamedInts(
battery_status=0x00100000,
wireless=0x00000100,
software_present=0x000000800)
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)
REGISTERS = _NamedInts(
battery=0x0D,
dpi=0x63,
leds=0x51)
#
# functions
#
def get_battery(device):
"""Reads a device's battery level, if provided by the HID++ 1.0 protocol."""
reply = device.request(0x810D)
if reply:
charge = ord(reply[:1])
return charge, None
def get_receiver_serial(receiver):
serial = receiver.request(0x83B5, 0x03)
if serial:
return _strhex(serial[1:5])
def get_receiver_firmware(receiver):
firmware = []
reply = receiver.request(0x83B5, 0x02)
if reply:
fw_version = _strhex(reply[1:5])
fw_version = '%s.%s.B%s' % (fw_version[0:2], fw_version[2:4], fw_version[4:8])
fw = _FirmwareInfo(FIRMWARE_KIND.Firmware, '', fw_version, None)
firmware.append(fw)
reply = receiver.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,383 @@
#
# Logitech Unifying Receiver API.
#
from struct import pack as _pack, unpack as _unpack
from weakref import proxy as _proxy
from logging import getLogger, DEBUG as _DEBUG
_log = getLogger('LUR').getChild('hidpp20')
del getLogger
from .common import (FirmwareInfo as _FirmwareInfo,
ReprogrammableKeyInfo as _ReprogrammableKeyInfo,
KwException as _KwException,
NamedInts as _NamedInts)
#
#
#
"""Possible features available on a Logitech device.
A particular device might not support all these features, and may support other
unknown features as well.
"""
FEATURE = _NamedInts(
ROOT=0x0000,
FEATURE_SET=0x0001,
FIRMWARE=0x0003,
NAME=0x0005,
BATTERY=0x1000,
REPROGRAMMABLE_KEYS=0x1B00,
WIRELESS=0x1D4B,
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,
Tools=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('!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('!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('!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('!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('!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)
#
#
#
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('!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('!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

@@ -3,10 +3,7 @@
#
import threading as _threading
from . import base as _base
from .exceptions import NoReceiver as _NoReceiver
from .common import Packet as _Packet
from time import time as _timestamp
# for both Python 2 and 3
try:
@@ -14,84 +11,170 @@ 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
#
#
#
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))
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(_threading.Thread):
"""Listener thread for events from the Unifying Receiver.
Incoming packets will be passed to the callback function in sequence.
"""
def __init__(self, receiver_handle, events_callback):
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.tick_period = 0
def run(self):
self._active = True
_base.unhandled_hook = self._unhandled_hook
ihandle = int(self._handle)
_log.info("started with %s (%d)", repr(self._handle), ihandle)
_base.events_hook = self._events_hook
ihandle = int(self.receiver.handle)
_log.info("started with %s (%d)", self.receiver, ihandle)
self.has_started()
last_tick = 0
idle_reads = 0
while self._active:
if self._queued_events.empty():
try:
# _log.debug("read next event")
event = _base.read(ihandle)
# shortcut: we should only be looking at events for proper device numbers
except _NoReceiver:
self._active = False
self._handle = None
event = _base.read(ihandle, _EVENT_READ_TIMEOUT)
except _base.NoReceiver:
_log.warning("receiver disconnected")
event = (0xFF, 0xFF, None)
self.receiver.close()
break
if event:
event = _base.make_event(*event)
else:
# deliver any queued events
event = self._queued_events.get()
if event:
event = _Packet(*event)
# _log.debug("processing event %s", 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.unhandled_hook = None
handle, self._handle = self._handle, None
if handle:
_base.close(handle)
_log.info("stopped %s", repr(handle))
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
handle, self._handle = self._handle, None
if handle:
_base.close(handle)
_log.info("stopped %s", repr(handle))
self._active = False
@property
def handle(self):
return self._handle
def has_started(self):
"""Called right after the thread has started."""
pass
def _unhandled_hook(self, reply_code, devnumber, data):
def has_stopped(self):
"""Called right before the thread stops."""
pass
def tick(self, timestamp):
"""Called about every tick_period seconds, if set."""
pass
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 _threading.current_thread() == self:
event = _Packet(reply_code, devnumber, data)
_log.info("queueing unhandled event %s", 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,320 @@
#
#
#
import errno as _errno
from weakref import proxy as _proxy
from logging import getLogger
_log = getLogger('LUR').getChild('receiver')
del getLogger
from . import base as _base
from . import hidpp10 as _hidpp10
from . import hidpp20 as _hidpp20
from .common import strhex as _strhex
from .devices 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._codename = None
self._name = None
self._kind = None
self._serial = None
self._firmware = None
self._keys = None
self.features = _hidpp20.FeaturesArray(self)
@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]
return self._wpid
@property
def power_switch_location(self):
if self._power_switch is None:
self.serial
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]
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]
elif self.protocol >= 2.0:
self._kind = _hidpp20.get_kind(self)
return self._kind or '?'
@property
def firmware(self):
if self._firmware is None and self.protocol >= 2.0:
self._firmware = _hidpp20.get_firmware(self)
# _log.debug("device %d firmware %s", self.number, self._firmware)
return self._firmware or ()
@property
def serial(self):
if self._serial is None:
serial = self.receiver.request(0x83B5, 0x30 + self.number - 1)
if serial:
self._serial = _strhex(serial[1:5])
# _log.debug("device %d serial %s", self.number, self._serial)
ps_location = ord(serial[9:10]) & 0x0F
self._power_switch = _hidpp10.POWER_SWITCH_LOCATION[ps_location]
return self._serial or '?'
@property
def keys(self):
if self._keys is None:
self._keys = _hidpp20.get_keys(self) or ()
return self._keys
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 __hash__(self):
return self.number
def __cmp__(self, other):
return self.number - other.number
def __eq__(self, other):
return self.receiver == other.receiver and self.number == other.number
def __str__(self):
return '<PairedDevice(%d,%s)>' % (self.number, self.codename or '?')
__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_receiver_serial(self)
return self._serial
@property
def firmware(self):
if self._firmware is None and self.handle:
self._firmware = _hidpp10.get_receiver_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) # and self.request(0x8002, 0x02)
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 reduce(lambda partial, item: partial if item is None else partial + 1, self._devices.values(), 0)
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)
__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,267 @@
#
#
#
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'
# make sure we try to update the device status at least once a minute
_STATUS_TIMEOUT = 60 # 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)
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)
def __bool__(self):
return self.updated and 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:
if d.protocol >= 2.0:
battery = _hidpp20.get_battery(d)
else:
battery = _hidpp10.get_battery(d)
if battery:
self[BATTERY_LEVEL], self[BATTERY_STATUS] = battery
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):
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 >= 0x40:
# this can't possibly be an event, can it?
if _log.isEnabledFor(_DEBUG):
_log.debug("ignoring non-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
feature = self._device.features[event.sub_id]
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('!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 = 10
reports_period = 3 # 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", event, feature)

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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B