364 lines
11 KiB
Python
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)
|