534 lines
17 KiB
Python
534 lines
17 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.
|
|
|
|
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.
|
|
|
|
In the context of this API, 'handle' is the open handle of UR attached to
|
|
the machine, and '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
|
|
|
|
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
|
|
_l = logging.getLogger('ur_lowlevel')
|
|
_l.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',
|
|
))
|
|
FEATURE_NAMES = { FEATURE.ROOT: 'ROOT',
|
|
FEATURE.FEATURE_SET: 'FEATURE_SET',
|
|
FEATURE.FIRMWARE: 'FIRMWARE',
|
|
FEATURE.NAME: 'NAME',
|
|
FEATURE.BATTERY: 'BATTERY',
|
|
FEATURE.REPROGRAMMABLE_KEYS: 'REPROGRAMMABLE_KEYS',
|
|
FEATURE.WIRELESS_STATUS: 'WIRELESS_STATUS',
|
|
FEATURE.SOLAR_CHARGE: 'SOLAR_CHARGE',
|
|
}
|
|
|
|
|
|
"""Possible types of devices connected to an UR."""
|
|
DEVICE_TYPES = ("Keyboard", "Remote Control", "NUMPAD", "Mouse",
|
|
"Touchpad", "Trackball", "Presenter", "Receiver")
|
|
|
|
|
|
FIRMWARE_TYPES = ("Main (HID)", "Bootloader", "Hardware", "Other")
|
|
|
|
BATTERY_STATUSES = ("Discharging (in use)", "Recharging", "Almost full", "Full",
|
|
"Slow recharge", "Invalid battery", "Thermal error",
|
|
"Charging error")
|
|
|
|
ERROR_CODES = ("Ok", "Unknown", "Invalid argument", "Out of range",
|
|
"Hardware error", "Logitech internal", "Invalid feature index",
|
|
"Invalid function", "Busy", "Usupported")
|
|
|
|
"""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
|
|
|
|
|
|
#
|
|
# Exceptions that may be raised by this API.
|
|
#
|
|
|
|
class NoReceiver(Exception):
|
|
"""May be raised when trying to talk through a previously connected
|
|
receiver that is no longer available."""
|
|
pass
|
|
|
|
class FeatureNotSupported(Exception):
|
|
"""Raised when trying to request a feature not supported by the device."""
|
|
def __init__(self, device, feature):
|
|
super(FeatureNotSupported, self).__init__(device, feature, FEATURE_NAMES[feature])
|
|
self.device = device
|
|
self.feature = feature
|
|
self.feature_name = FEATURE_NAMES[feature]
|
|
|
|
|
|
#
|
|
#
|
|
#
|
|
|
|
|
|
def _default_event_hook(reply_code, device, data):
|
|
_l.trace1("EVENT_HOOK (,%d) code %d status [%s]", device, reply_code, data)
|
|
|
|
|
|
"""A function that will be called on incoming events.
|
|
|
|
It must be a function with the signature: ``_(int, int, str)``, where the
|
|
parameters are: (reply code, device number, data).
|
|
|
|
This function will be called by the request() function, when it receives replies
|
|
that do not match the write ca
|
|
"""
|
|
event_hook = _default_event_hook
|
|
|
|
def _publish_event(reply_code, device, data):
|
|
if event_hook is not None:
|
|
event_hook.__call__(reply_code, device, data)
|
|
|
|
|
|
#
|
|
# Low-level functions.
|
|
#
|
|
|
|
|
|
from . import hidapi
|
|
|
|
|
|
def open():
|
|
"""Opens the first Logitech UR found attached to the machine.
|
|
|
|
:returns: An open file handle 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):
|
|
|
|
_l.trace1("checking %s", rawdevice)
|
|
receiver = hidapi.open_path(rawdevice.path)
|
|
if not receiver:
|
|
# could be a file permissions issue
|
|
# in any case, unreachable
|
|
_l.trace1("[%s] open failed", rawdevice.path)
|
|
continue
|
|
|
|
_l.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:
|
|
if reply[:4] == b'\x10\x00\x8F\x00':
|
|
# 'device 0 unreachable' is the expected reply from a valid receiver handle
|
|
_l.trace1("[%s] success: handle (%d,)", rawdevice.path, receiver)
|
|
return receiver
|
|
|
|
if reply == b'\x01\x00\x00\x00\x00\x00\x00\x00':
|
|
# no idea what this is, but it comes up occasionally
|
|
_l.trace1("[%s] (%d,) mistery reply [%s]", rawdevice.path, receiver, reply)
|
|
else:
|
|
_l.trace1("[%s] (%d,) unknown reply [%s]", rawdevice.path, receiver, reply)
|
|
else:
|
|
_l.trace1("[%s] (%d,) no reply", rawdevice.path, receiver)
|
|
|
|
# ignore
|
|
close(receiver)
|
|
# hidapi.close(receiver)
|
|
|
|
return None
|
|
|
|
|
|
def close(handle):
|
|
"""Closes a HID device handle."""
|
|
if handle:
|
|
try:
|
|
hidapi.close(handle)
|
|
_l.trace1("(%d,) closed", handle)
|
|
return True
|
|
except Exception as e:
|
|
_l.debug("(%d,) closing: %s", handle, e)
|
|
|
|
return False
|
|
|
|
|
|
def write(handle, device, feature_index, function=b'\x00', param1=b'\x00', param2=b'\x00', param3=b'\x00'):
|
|
"""Write a feature call to the receiver.
|
|
|
|
:param handle: UR handle obtained with open().
|
|
:param device: attached device number
|
|
:param feature_index: index in the
|
|
"""
|
|
if type(feature_index) == int:
|
|
feature_index = chr(feature_index)
|
|
data = feature_index + function + param1 + param2 + param3
|
|
return _write(handle, device, data)
|
|
|
|
|
|
def _write(handle, device, data):
|
|
"""Writes some data to a certain device.
|
|
|
|
The first two (required) bytes of data must be the feature index for the
|
|
device, and a function code for that feature.
|
|
|
|
If the receiver is no longer available (e.g. has been physically removed
|
|
from the machine), raises NoReceiver.
|
|
"""
|
|
wdata = b'\x10' + chr(device) + data + b'\x00' * (5 - len(data))
|
|
_l.trace1("(%d,%d) <= w[%s]", handle, device, wdata)
|
|
# return hidapi.write(handle, wdata)
|
|
if not hidapi.write(handle, wdata):
|
|
_l.trace1("(%d,%d) write failed, assuming receiver has been removed", handle, device)
|
|
raise NoReceiver()
|
|
|
|
|
|
def read(handle, timeout=DEFAULT_TIMEOUT):
|
|
"""Read some data from the receiver. Usually called after a write (feature
|
|
call), to get the reply.
|
|
|
|
If any data was read in the given timeout, returns a tuple of
|
|
(reply_code, device, message data). The reply code should be ``0x11`` for a
|
|
successful feature call, or ``0x10`` to indicate some error, e.g. the device
|
|
is no longer available.
|
|
"""
|
|
data = hidapi.read(handle, _MAX_REPLY_SIZE, timeout)
|
|
if data:
|
|
_l.trace1("(%d,*) => r[%s]", handle, data)
|
|
if len(data) < _MIN_REPLY_SIZE:
|
|
_l.trace1("(%d,*) => r[%s] read short reply", handle, data)
|
|
if len(data) > _MAX_REPLY_SIZE:
|
|
_l.trace1("(%d,*) => r[%s] read long reply", handle, data)
|
|
return ord(data[0]), ord(data[1]), data[2:]
|
|
else:
|
|
_l.trace1("(%d,*) => r[]", handle)
|
|
|
|
|
|
def request(handle, device, feature, function=b'\x00', data=b'', features_array=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 indefinetly.
|
|
|
|
Incoming data packets not matching the feature and function will be
|
|
delivered to the event_hook (if any), and then ignored.
|
|
|
|
The optional ``features_array`` 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
|
|
calling get_device_features.
|
|
|
|
If the feature is not supported, returns None.
|
|
"""
|
|
if features_array is None:
|
|
features_array = get_device_features(handle, device)
|
|
if features_array is None:
|
|
_l.trace1("(%d,%d) no features array available", handle, device)
|
|
return None
|
|
|
|
if feature in features_array:
|
|
feature_index = chr(features_array.index(feature))
|
|
return _request(handle, device, feature_index + function, data)
|
|
else:
|
|
_l.warn("(%d,%d) feature <%s:%s> not supported", handle, device, feature.encode('hex'), FEATURE_NAMES[feature])
|
|
raise FeatureNotSupported(device, feature)
|
|
|
|
|
|
|
|
def _request(handle, device, feature_function, data=b''):
|
|
"""Makes a feature call device and waits for a matching reply.
|
|
|
|
Only call this in the initial set-up of the device.
|
|
|
|
This function will skip all incoming messages and events not related to the
|
|
device we're requesting for, or the feature specified in the initial
|
|
request; it will also wait for a matching reply indefinetly.
|
|
|
|
:param feature_function: a two-byte string of (feature_index, function).
|
|
:param data: additional data to send, up to 5 bytes.
|
|
:returns:
|
|
"""
|
|
_l.trace1("(%d,%d) request feature %s data %s", handle, device, feature_function, data)
|
|
_write(handle, device, feature_function + data)
|
|
while True:
|
|
reply = read(handle)
|
|
|
|
if not reply:
|
|
# keep waiting...
|
|
continue
|
|
|
|
if reply[1] != device:
|
|
# this message not for the device we're interested in
|
|
_l.trace1("(%d,%d) request reply for unexpected device %s", handle, device, reply)
|
|
_publish_event(*reply)
|
|
continue
|
|
|
|
if reply[0] == 0x10 and reply[2][0] == b'\x8F':
|
|
# device not present
|
|
_l.trace1("(%d,%d) request ping failed %s", handle, device, reply)
|
|
return None
|
|
|
|
if reply[0] == 0x11 and reply[2][0] == b'\xFF' and reply[2][1:3] == feature_function:
|
|
# an error returned from the device
|
|
error = ord(reply[2][3])
|
|
_l.trace1("(%d,%d) request feature call error %d = %s: %s", handle, device, error, ERROR_CODES[error], reply)
|
|
return None
|
|
|
|
if reply[0] == 0x11 and reply[2][:2] == feature_function:
|
|
# a matching reply
|
|
_l.trace1("(%d,%d) matched reply with data [%s]", handle, device, reply[2][2:])
|
|
return reply[2][2:]
|
|
|
|
_l.trace1("(%d,%d) unmatched reply %s (expected %s)", handle, device, reply[2][:2], feature_function)
|
|
_publish_event(*reply)
|
|
|
|
|
|
def ping(handle, 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.
|
|
"""
|
|
def _status(reply):
|
|
if not reply:
|
|
return None
|
|
|
|
if reply[1] != device:
|
|
# oops
|
|
_l.trace1("(%d,%d) ping: reply for another device: %s", handle, device, reply)
|
|
_publish_event(*reply)
|
|
return _status(read(handle))
|
|
|
|
if (reply[0] == 0x11 and reply[2][:2] == b'\x00\x10' and reply[2][4] == b'\xAA'):
|
|
# ping ok
|
|
_l.trace1("(%d,%d) ping: ok %s", handle, device, reply[2])
|
|
return True
|
|
|
|
if (reply[0] == 0x10 and reply[2][:2] == b'\x8F\x00'):
|
|
# ping failed
|
|
_l.trace1("(%d,%d) ping: device not present", handle, device)
|
|
return False
|
|
|
|
if (reply[0] == 0x11 and reply[2][:2] == b'\x09\x00' and reply[2][7:11] == b'GOOD'):
|
|
# some devices may reply with a SOLAR_STATUS packet before the
|
|
# ping_ok reply, especially right after the device connected to the
|
|
# receiver
|
|
_l.trace1("(%d,%d) ping: solar status %s", handle, device, reply[2])
|
|
_publish_event(*reply)
|
|
return _status(read(handle))
|
|
|
|
# ugh
|
|
_l.trace1("(%d,%d) ping: unknown reply for this device", handle, device, reply)
|
|
_publish_event(*reply)
|
|
return None
|
|
|
|
_l.trace1("(%d,%d) pinging", handle, device)
|
|
_write(handle, device, b'\x00\x10\x00\x00\xAA')
|
|
return _status(read(handle, DEFAULT_TIMEOUT * 3))
|
|
|
|
|
|
def get_feature_index(handle, device, feature):
|
|
"""Reads the index of a device's feature.
|
|
|
|
:returns: An int, or ``None`` if the feature is not available.
|
|
"""
|
|
_l.trace1("(%d,%d) get feature index <%s>", handle, device, feature.encode('hex'))
|
|
feature_index = _request(handle, device, FEATURE.ROOT, feature)
|
|
if feature_index:
|
|
# only consider active and supported features
|
|
if ord(feature_index[0]) and ord(feature_index[1]) & 0xA0 == 0:
|
|
_l.trace1("(%d,%d) feature <%s> index %s", handle, device, feature.encode('hex'), feature_index[0])
|
|
return ord(feature_index[0])
|
|
|
|
_l.warn("(%d,%d) feature <%s:%s> not supported", handle, device, feature.encode('hex'), FEATURE_NAMES[feature])
|
|
raise FeatureNotSupported(device, feature)
|
|
|
|
|
|
def get_device_features(handle, 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.
|
|
"""
|
|
_l.trace1("(%d,%d) get device features", handle, device)
|
|
|
|
# get the index of the FEATURE_SET
|
|
fs_index = _request(handle, device, FEATURE.ROOT, FEATURE.FEATURE_SET)
|
|
if not fs_index:
|
|
_l.trace1("(%d,%d) FEATURE_SET not available", handle, device)
|
|
return None
|
|
fs_index = fs_index[0]
|
|
|
|
# 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 = _request(handle, device, fs_index + b'\x00')
|
|
if not features_count:
|
|
# this can happen if the device disappeard since the fs_index call
|
|
_l.trace1("(%d,%d) no features available?!", handle, device)
|
|
return None
|
|
features_count = ord(features_count[0])
|
|
|
|
# a device may have a maximum of 15 features
|
|
features = [None] * 0x10
|
|
_l.trace1("(%d,%d) found %d features", handle, device, features_count)
|
|
|
|
for index in range(1, 1 + features_count):
|
|
# for each index, get the feature residing at that index
|
|
feature = _request(handle, device, fs_index + b'\x10', chr(index))
|
|
if feature:
|
|
features[index] = feature[0:2].upper()
|
|
_l.trace1("(%d,%d) feature <%s> at index %d", handle, device, features[index].encode('hex'), index)
|
|
|
|
return None if all(c == None for c in features) else features
|
|
|
|
|
|
def get_device_firmware(handle, device, features_array=None):
|
|
"""Reads a device's firmware info.
|
|
|
|
Returns an list of tuples [ (firmware_type, firmware_version, ...), ... ],
|
|
ordered by firmware layer.
|
|
"""
|
|
fw_count = request(handle, device, FEATURE.FIRMWARE, features_array=features_array)
|
|
if fw_count:
|
|
fw_count = ord(fw_count[0])
|
|
|
|
fw = []
|
|
for index in range(0, fw_count):
|
|
fw_info = request(handle, device, FEATURE.FIRMWARE, function=b'\x10', data=chr(index), features_array=features_array)
|
|
if fw_info:
|
|
fw_type = ord(fw_info[0]) & 0x0F
|
|
if fw_type == 0 or fw_type == 1:
|
|
prefix = str(fw_info[1:4])
|
|
version = ( str((ord(fw_info[4]) & 0xF0) >> 4) +
|
|
str(ord(fw_info[4]) & 0x0F) +
|
|
'.' +
|
|
str((ord(fw_info[5]) & 0xF0) >> 4) +
|
|
str(ord(fw_info[5]) & 0x0F))
|
|
name = prefix + ' ' + version
|
|
build = 256 * ord(fw_info[6]) + ord(fw_info[7])
|
|
if build:
|
|
name += ' b' + str(build)
|
|
extras = fw_info[9:].rstrip('\x00')
|
|
_l.trace1("(%d:%d) firmware %d = %s %s extras=%s", handle, device, fw_type, FIRMWARE_TYPES[fw_type], name, extras.encode('hex'))
|
|
fw.append((fw_type, name, build, extras))
|
|
elif fw_type == 2:
|
|
version = ord(fw_info[1])
|
|
_l.trace1("(%d:%d) firmware 2 = Hardware v%x", handle, device, version)
|
|
fw.append((2, version))
|
|
else:
|
|
_l.trace1("(%d:%d) firmware other", handle, device)
|
|
fw.append((fw_type, ))
|
|
return fw
|
|
|
|
|
|
def get_device_type(handle, device, features_array=None):
|
|
"""Reads a device's type.
|
|
|
|
:see DEVICE_TYPES:
|
|
:returns: a string describing the device type, or ``None`` if the device is
|
|
not available or does not support the ``NAME`` feature.
|
|
"""
|
|
d_type = request(handle, device, FEATURE.NAME, function=b'\x20', features_array=features_array)
|
|
if d_type:
|
|
d_type = ord(d_type[0])
|
|
_l.trace1("(%d,%d) device type %d = %s", handle, device, d_type, DEVICE_TYPES[d_type])
|
|
return DEVICE_TYPES[d_type]
|
|
|
|
|
|
def get_device_name(handle, device, features_array=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_length = request(handle, device, FEATURE.NAME, features_array=features_array)
|
|
if name_length:
|
|
name_length = ord(name_length[0])
|
|
|
|
d_name = ''
|
|
while len(d_name) < name_length:
|
|
name_index = len(d_name)
|
|
name_fragment = request(handle, device, FEATURE.NAME, function=b'\x10', data=chr(name_index), features_array=features_array)
|
|
name_fragment = name_fragment[:name_length - len(d_name)]
|
|
d_name += name_fragment
|
|
|
|
_l.trace1("(%d,%d) device name %s", handle, device, d_name)
|
|
return d_name
|
|
|
|
def get_device_battery_level(handle, device, features_array=None):
|
|
"""Reads a device's battery level.
|
|
"""
|
|
battery = request(handle, device, FEATURE.BATTERY, features_array=features_array)
|
|
if battery:
|
|
discharge = ord(battery[0])
|
|
dischargeNext = ord(battery[1])
|
|
status = ord(battery[2])
|
|
_l.trace1("(%d:%d) battery %d%% charged, next level %d%% charge, status %d = %s", discharge, dischargeNext, status, BATTERY_STATUSES[status])
|
|
return (discharge, dischargeNext, status)
|