Solaar/lib/logitech/ur_eventqueue.py

364 lines
11 KiB
Python

"""Low-level interface for devices connected through a Logitech Universal
Receiver (UR).
Uses the HID api exposed through hidapi.py.
Incomplete. Based on a bit of documentation, trial-and-error, and guesswork.
In the context of this API, 'device' is the number (1..6 according to the
documentation) of the device attached to the UR.
References:
http://julien.danjou.info/blog/2012/logitech-k750-linux-support
http://6xq.net/git/lars/lshidpp.git/plain/doc/
"""
#
# Logging set-up.
# Add a new logging level for tracing low-level writes and reads.
#
import logging
import threading
from . import hidapi
LOG_LEVEL = 1
def _urll_trace(self, msg, *args):
if self.isEnabledFor(LOG_LEVEL):
args = (None if x is None
else x.encode('hex') if type(x) == str and any(c < '\x20' or c > '\x7E' for c in x)
else x
for x in args)
self.log(LOG_LEVEL, msg, *args)
logging.addLevelName(LOG_LEVEL, 'trace1')
logging.Logger.trace1 = _urll_trace
_log = logging.getLogger('logitech.ur_lowlevel')
_log.setLevel(LOG_LEVEL)
#
#
#
"""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_STATUS=b'\x1D\x4B',
# UNKNOWN_1=b'\x1D\xF3',
# UNKNOWN_2=b'\x40\xA0',
# UNKNOWN_3=b'\x41\x00',
SOLAR_CHARGE=b'\x43\x01',
# UNKNOWN_4=b'\x45\x20',
))
"""Possible types of devices connected to an UR."""
DEVICE_TYPES = ("Keyboard", "Remote Control", "NUMPAD", "Mouse",
"Touchpad", "Trackball", "Presenter", "Receiver")
"""Default timeout on read (in ms)."""
DEFAULT_TIMEOUT = 1000
"""Minimum size of a reply data packet."""
_MIN_REPLY_SIZE = 7
"""Maximum size of a reply data packet."""
_MAX_REPLY_SIZE = 32
class NoReceiver(Exception):
"""May be raised when trying to talk through a previously connected
receiver that is no longer available."""
pass
#
#
#
class Receiver(threading.Thread):
def __init__(self, handle, path, timeout=DEFAULT_TIMEOUT):
super(Receiver, self).__init__(name='Unifying_Receiver_' + path)
self.handle = handle
self.path = path
self.timeout = timeout
self.read_data = None
self.data_available = threading.Event()
self.devices = {}
self.hooks = {}
self.active = True
self.start()
def __del__(self):
self.close()
def close(self):
self.active = False
try:
hidapi.close(self.handle)
_log.trace1("|%s:| closed", self.path)
return True
except Exception as e:
_log.warn("|%s:| closing: %s", self.path, e)
self.hooks = None
self.devices = None
def run(self):
while self.active:
data = hidapi.read(self.handle, _MAX_REPLY_SIZE, self.timeout)
if self.active and data:
_log.trace1("|%s|*| => r[%s]", self.path, data)
if len(data) < _MIN_REPLY_SIZE:
_log.trace1("|%s|*| => r[%s] short read", self.path, data)
if len(data) > _MAX_REPLY_SIZE:
_log.trace1("|%s|*| => r[%s] long read", self.path, data)
if not self._dispatch_to_hooks(data):
self.read_data = data
self.data_available.set()
def _dispatch_to_hooks(self, data):
if data[0] == b'\x11':
for key in self.hooks:
if key == data[1:3]:
self.hooks[key].__call__(data[3:])
return True
def set_hook(self, device, feature_index, function=b'\x00', callback=None):
key = '%c%s%c' % (device, feature_index, function)
if callback is None:
if key in self.hooks:
del self.hooks[key]
else:
self.hooks[key] = callback
return True
def _write(self, device, data):
wdata = b'\x10' + chr(device) + data + b'\x00' * (5 - len(data))
if hidapi.write(self.handle, wdata):
_log.trace1("|%s|%d| <= w[%s]", self.path, device, wdata)
return True
else:
_log.trace1("|%s|%d| <= w[%s] failed ", self.path, device, wdata)
raise NoReceiver()
def _read(self, device, feature_index=None, function=None, timeout=DEFAULT_TIMEOUT):
while True:
self.data_available.wait()
data = self.data
self.data_available.clear()
if data[1] == chr(device):
if feature_index is None or data[2] == feature_index:
if function is None or data[3] == function:
return data
_log.trace1("|%s:| ignoring read data [%s]", self.path, data)
def _request(self, device, feature_index, function=b'\x00', data=b''):
self._write(device, feature_index + function + data)
return self._read(device, feature_index, function)
def _request_direct(self, device, feature_index, function=b'\x00', data=b''):
self._write(device, feature_index + function + data)
while True:
data = hidapi.read(self.handle, _MAX_REPLY_SIZE, self.timeout)
if not data:
continue
if data[1] == chr(device) and data[2] == feature_index and data[3] == function:
return data
def ping(self, device):
"""Pings a device to check if it is attached to the UR.
:returns: True if the device is connected to the UR, False if the device is
not attached, None if no conclusive reply is received.
"""
if self._write(device, b'\x00\x10\x00\x00\xAA'):
while True:
reply = self._read(device, timeout=DEFAULT_TIMEOUT*3)
# ping ok
if reply[0] == b'\0x11' and reply[1] == chr(device):
if reply[2:4] == b'\x00\x10' and reply[6] == b'\xAA':
_log.trace1("|%s|%d| ping: ok %s", self.path, device, reply[2])
return True
# ping failed
if reply[0] == b'\0x10' and reply[1] == chr(device):
if reply[2:4] == b'\x8F\x00':
_log.trace1("|%s|%d| ping: device not present", self.path, device)
return False
_log.trace1("|%s|%d| ping: unknown reply", self.path, device, reply)
def scan_devices(self):
for device in range(1, 7):
self.get_device(device)
return self.devices.values()
def get_device(self, device, query=True):
if device in self.devices:
value = self.devices[device]
_log.trace1("|%s:%d| device info %s", self.path, device, value)
return value
if query and self.ping(device):
d_type = self.get_type(device)
d_name = self.get_name(device)
features_array = self._get_features(device)
value = (d_type, d_name, features_array)
self.devices[device] = value
_log.trace1("|%s:%d| device info %s", self.path, device, value)
return value
_log.trace1("|%s:%d| device not found", self.path, device)
def _get_feature_index(self, device, feature):
"""Reads the index of a device's feature.
:returns: An int, or None if the feature is not available.
"""
_log.trace1("|%s|%d| get feature index <%s>", self.path, device, feature)
reply = self._request(device, b'\x00', b'\x00', feature)
# only consider active and supported features
if ord(reply[4]) and ord(reply[5]) & 0xA0 == 0:
_log.trace1("|%s|%d| feature <%s> has index %s", self.path, device, feature, reply[4])
return ord(reply[4])
_log.trace1("|%s|%d| feature <%s> not available", self.path, device, feature)
def _get_features(self, device):
"""Returns an array of feature ids.
Their position in the array is the index to be used when accessing that
feature on the device.
Only call this function in the initial set-up of the device, because
other messages and events not related to querying the feature set
will be ignored.
"""
_log.trace1("|%s|%d| get device features", self.path, device)
# get the index of the FEATURE_SET
fs_index = self._get_feature_index(device, FEATURE.FEATURE_SET)
fs_index = chr(fs_index)
# Query all the available features on the device, even if unknown.
# get the number of active features the device has
features_count = self._request(device, fs_index)
features_count = ord(features_count[4])
_log.trace1("|%s|%d| found %d features", self.path, device, features_count)
# a device may have a maximum of 15 features
features = [None] * 0x10
for index in range(1, 1 + features_count):
# for each index, get the feature residing at that index
feature = self._request(device, fs_index, b'\x10', chr(index))
features[index] = feature[4:6].upper()
_log.trace1("|%s|%d| feature <%s> at index %d", self.path, device, features[index], index)
return None if all(c is None for c in features) else features
def get_type(self, device):
if device in self.devices:
return self.devices[device][0]
dnt_index = self._get_feature_index(device, FEATURE.NAME)
dnt_index = chr(dnt_index)
d_type = self._request(device, dnt_index, b'\x20')
d_type = ord(d_type[4])
return DEVICE_TYPES[d_type]
def get_name(self, device):
if device in self.devices:
return self.devices[device][1]
dnt_index = self._get_feature_index(device, FEATURE.NAME)
dnt_index = chr(dnt_index)
self._write(device, dnt_index)
name_length = self._read(device, dnt_index, b'\x00')
name_length = ord(name_length[4])
d_name = ''
while len(d_name) < name_length:
name_index = len(d_name)
name_fragment = self._request(device, dnt_index, b'\x10', chr(name_index))
name_fragment = name_fragment[:name_length - len(d_name)]
d_name += name_fragment
return d_name
def open():
"""Opens the first Logitech UR found attached to the machine.
:returns: A Receiver object for the found receiver, or ``None``.
"""
# USB ids for (Logitech, Unifying Receiver)
# interface 2 if the actual receiver interface
for rawdevice in hidapi.enumerate(0x046d, 0xc52b, 2):
_log.trace1("checking %s", rawdevice)
receiver = hidapi.open_path(rawdevice.path)
if not receiver:
# could be a file permissions issue
# in any case, unreachable
_log.trace1("[%s] open failed", rawdevice.path)
continue
_log.trace1("[%s] receiver handle %d", rawdevice.path, receiver)
# ping on device id 0 (always an error)
hidapi.write(receiver, b'\x10\x00\x00\x10\x00\x00\xAA')
# if this is the right hidraw device, we'll receive a 'bad subdevice'
# otherwise, the read should produce nothing
reply = hidapi.read(receiver, _MAX_REPLY_SIZE, DEFAULT_TIMEOUT)
if reply:
_log.trace1("[%s] receiver %d exploratory ping reply [%s]", rawdevice.path, receiver, reply)
if reply[:4] == b'\x10\x00\x8F\x00':
# 'device 0 unreachable' is the expected reply from a valid receiver handle
_log.trace1("[%s] success: found receiver with handle %d", rawdevice.path, receiver)
return Receiver(receiver, rawdevice.path)
if reply == b'\x01\x00\x00\x00\x00\x00\x00\x00':
# no idea what this is, but it comes up occasionally
_log.trace1("[%s] receiver %d mistery reply", rawdevice.path, receiver)
else:
_log.trace1("[%s] receiver %d unknown reply", rawdevice.path, receiver)
else:
_log.trace1("[%s] receiver %d no reply", rawdevice.path, receiver)
pass
# ignore
hidapi.close(receiver)