hidpp20: add full support for version 2 of REPROG_CONTROLS_V4

This commit is contained in:
Wojciech Nawrocki 2020-07-18 15:39:56 +02:00 committed by Peter F. Patel-Schneider
parent e7047f4606
commit 95dc973748
5 changed files with 289 additions and 53 deletions

View File

@ -40,7 +40,7 @@ try:
# and not any((chr(k) in d for k in range(0x80, 0xFF))) \
# )
except Exception:
# this is certanly Python 3
# this is certainly Python 3
# In Py3, unicode and str are equal (the unicode object does not exist)
is_string = lambda d: isinstance(d, str)
@ -50,7 +50,7 @@ except Exception:
class NamedInt(int):
"""An reqular Python integer with an attached name.
"""A regular Python integer with an attached name.
Caution: comparison with strings will also match this NamedInt's name
(case-insensitive)."""
@ -269,11 +269,5 @@ class KwException(Exception):
"""Firmware information."""
FirmwareInfo = namedtuple('FirmwareInfo', ['kind', 'name', 'version', 'extras'])
"""Reprogrammable keys information."""
ReprogrammableKeyInfo = namedtuple('ReprogrammableKeyInfo', ['index', 'key', 'task', 'flags'])
ReprogrammableKeyInfoV4 = namedtuple(
'ReprogrammableKeyInfoV4', ['index', 'key', 'task', 'flags', 'pos', 'group', 'group_mask', 'remapped']
)
del namedtuple

View File

@ -22,14 +22,16 @@
from __future__ import absolute_import, division, print_function, unicode_literals
from logging import DEBUG as _DEBUG
from logging import ERROR as _ERROR
from logging import WARNING as _WARNING
from logging import getLogger
from typing import List
from . import special_keys
from .common import FirmwareInfo as _FirmwareInfo
from .common import KwException as _KwException
from .common import NamedInt as _NamedInt
from .common import NamedInts as _NamedInts
from .common import ReprogrammableKeyInfo as _ReprogrammableKeyInfo
from .common import ReprogrammableKeyInfoV4 as _ReprogrammableKeyInfoV4
from .common import pack as _pack
from .common import unpack as _unpack
@ -143,7 +145,7 @@ FEATURE = _NamedInts(
BRIGHTNESS_CONTROL=0x8040,
REPORT_RATE=0x8060,
COLOR_LED_EFFECTS=0x8070,
RGB_EFFECTS=0X8071,
RGB_EFFECTS=0x8071,
PER_KEY_LIGHTING=0x8080,
PER_KEY_LIGHTING_V2=0x8081,
MODE_STATUS=0x8090,
@ -357,53 +359,274 @@ class FeaturesArray(object):
#
class ReprogrammableKey(object):
"""Information about a control present on a device with the `REPROG_CONTROLS` feature.
Ref: https://lekensteyn.nl/files/logitech/logitech_hidpp_2.0_specification_draft_2012-06-04.pdf
Read-only properties:
- index {int} -- index in the control ID table
- key {_NamedInt} -- the name of this control
- default_task {_NamedInt} -- the native function of this control
- flags {List[str]} -- flags set on the control
"""
def __init__(self, device, index, cid, tid, flags):
self._device = device
self.index = index
self._cid = cid
self._tid = tid
self._flags = flags
@property
def key(self) -> _NamedInt:
return special_keys.CONTROL[self._cid]
@property
def default_task(self) -> _NamedInt:
"""NOTE: This NamedInt is a bit mixed up, because its value is the Control ID
while the name is the Control ID's native task. But this makes more sense
than presenting details of controls vs tasks in the interface. The same
convention applies to `mapped_to`, `remappable_to`, `remap` in `ReprogrammableKeyV4`."""
task = str(special_keys.TASK[self._tid])
return _NamedInt(self._cid, task)
@property
def flags(self) -> List[str]:
return special_keys.KEY_FLAG.flag_names(self._flags)
class ReprogrammableKeyV4(ReprogrammableKey):
"""Information about a control present on a device with the `REPROG_CONTROLS_V4` feature.
Ref: https://lekensteyn.nl/files/logitech/x1b04_specialkeysmsebuttons.html
Contains all the functionality of `ReprogrammableKey` plus remapping keys and /diverting/ them
in order to handle keypresses in a custom way.
Additional read-only properties:
- pos {int} -- position of this control on the device; 1-16 for FN-keys, otherwise 0
- group {int} -- the group this control belongs to; other controls with this group in their
`group_mask` can be remapped to this control
- group_mask {List[str]} -- this control can be remapped to any control ID in these groups
- rawXY_reportable {bool} -- whether the control can be diverted to report raw XY events
- mapped_to {_NamedInt} -- which action this control is mapped to; usually itself
- remappable_to {List[_NamedInt]} -- list of actions which this control can be remapped to
- mapping_flags {List[str]} -- mapping flags set on the control
"""
def __init__(self, device, index, cid, tid, flags, pos, group, gmask, rawxy):
ReprogrammableKey.__init__(self, device, index, cid, tid, flags)
self.pos = pos
self.group = group
self._gmask = gmask
self.rawXY_reportable = rawxy
self._mapping_flags = None
self._mapped_to = None
@property
def group_mask(self):
return special_keys.CID_GROUP_BIT.flag_names(self._gmask)
@property
def mapped_to(self) -> _NamedInt:
if self._mapped_to is None:
self._getCidReporting()
self._device.keys._ensure_all_keys_queried()
task = str(special_keys.TASK[self._device.keys.cid_to_tid[self._mapped_to]])
return _NamedInt(self._mapped_to, task)
@property
def remappable_to(self) -> List[_NamedInt]:
# this flag is only to show in UI, ignore in Solaar
# if special_keys.KEY_FLAG.reprogrammable not in self.flags:
# return []
self._device.keys._ensure_all_keys_queried()
ret = []
if self.group_mask != []: # only keys with a non-zero gmask are remappable
ret = [self.default_task] # it should always be possible to map the key to itself
for g in self.group_mask:
g = special_keys.CID_GROUP[str(g)]
for tgt_cid in self._device.keys.group_cids[g]:
tgt_task = str(special_keys.TASK[self._device.keys.cid_to_tid[tgt_cid]])
tgt_task = _NamedInt(tgt_cid, tgt_task)
if tgt_task != self.default_task: # don't put itself in twice
ret.append(tgt_task)
return ret
@property
def mapping_flags(self) -> List[str]:
if self._mapping_flags is None:
self._getCidReporting()
return special_keys.MAPPING_FLAG.flag_names(self._mapping_flags)
def set_diverted(self, value: bool):
"""If set, the control is diverted temporarily and reports presses as HID++ events
until a HID++ configuration reset occurs."""
self._setCidReporting(divert=value)
def set_persistently_diverted(self, value: bool):
"""If set, the control is diverted permanently and reports presses as HID++ events."""
self._setCidReporting(persist=value)
def set_rawXY_reporting(self, value: bool):
"""If set, the mouse reports all its raw XY events while this control is pressed
as HID++ events. Gets cleared on a HID++ configuration reset."""
self._setCidReporting(rawXY=value)
def remap(self, to: _NamedInt):
"""Remaps this control to another action."""
self._setCidReporting(remap=int(to))
def _getCidReporting(self):
try:
mapped_data = feature_request(
self._device,
FEATURE.REPROG_CONTROLS_V4,
0x20,
*tuple(_pack('!H', self._cid)),
)
if mapped_data:
cid, mapping_flags, mapped_to = _unpack('!HBH', mapped_data[:5])
if cid != self._cid and _log.isEnabledFor(_WARNING):
_log.warn(
f'REPROG_CONTROLS_V4 endpoint getCidReporting on device {self._device} replied ' +
f'with a different control ID ({cid}) than requested ({self._cid}).'
)
self._mapping_flags = mapping_flags
self._mapped_to = mapped_to if mapped_to != 0 else self._cid
else:
raise FeatureCallError('No reply from device.')
except Exception:
if _log.isEnabledFor(_ERROR):
_log.error(f'Exception in _getCidReporting on device {self._device}: ', exc_info=1)
# Clear flags and set mapping target to self as fallback
self._mapping_flags = 0
self._mapped_to = self._cid
def _setCidReporting(self, divert=None, persist=None, rawXY=None, remap=0):
"""Sends a `setCidReporting` request with the given parameters to the control. Raises
an exception if the parameters are invalid.
"""
if rawXY:
# We need diversion to report raw XY, so divert temporarily
# (since XY reporting is also temporary)
divert = True
if divert is not None and special_keys.KEY_FLAG.divertable not in self.flags:
raise FeatureNotSupported(f'Tried to divert non-divertable control {self.key} on device {self._device}.')
if persist is not None and special_keys.KEY_FLAG.persistently_divertable not in self.flags:
raise FeatureNotSupported(
'Tried to persistently divert non-persistently-divertable control {self.key} on device {self._device}.'
)
if rawXY is not None and not self.rawXY_reportable:
raise FeatureNotSupported(
f'Tried to request raw XY reports from control {self.key} with no raw XY capability on device {self._device}.'
)
if remap != 0 and remap not in self.remappable_to:
raise FeatureNotSupported(
f'Tried to remap control {self.key} to a control ID {remap} which it is not remappable to ' +
f'on device {self._device}.'
)
mkbit = lambda v: 1 if v else 0
isset = lambda v: mkbit(v is not None)
pkt = tuple(
_pack(
'!HBH',
self._cid,
(isset(rawXY) << 5)
| (mkbit(rawXY) << 4)
| (isset(persist) << 3)
| (mkbit(persist) << 2)
| (isset(divert) << 1)
| mkbit(divert),
remap,
)
)
ret = feature_request(self._device, FEATURE.REPROG_CONTROLS_V4, 0x30, *pkt)
if _unpack('!BBBBB', ret[:5]) != pkt and _log.isEnabledFor(_WARNING):
_log.warn(
f"REPROG_CONTROLS_v4 endpoint setCidReporting on device {self._device} should echo request packet, but didn't."
)
# update knowledge of mapping
self._getCidReporting()
class KeysArray(object):
"""A sequence of key mappings supported by a HID++ 2.0 device."""
__slots__ = ('device', 'keys', 'keyversion')
__slots__ = ('device', 'keys', 'keyversion', 'cid_to_tid', 'group_cids')
def __init__(self, device, count):
assert device is not None
self.device = device
self.keyversion = 0
if FEATURE.REPROG_CONTROLS in self.device.features:
self.keyversion = 1
elif FEATURE.REPROG_CONTROLS_V4 in self.device.features:
self.keyversion = 4
else:
if _log.isEnabledFor(_ERROR):
_log.error(f'Trying to read keys on device {device} which has no REPROG_CONTROLS(_VX) support.')
self.keyversion = None
self.keys = [None] * count
"""The mapping from Control IDs to their native Task IDs.
For example, Control "Left Button" is mapped to Task "Left Click".
When remapping controls, we point the control we want to remap
at a target Control ID rather than a target Task ID. This has the
effect of performing the native task of the target control,
even if the target itself is also remapped. So remapping
is not recursive."""
self.cid_to_tid = {}
"""The mapping from Control ID groups to Controls IDs that belong to it.
A key k can only be remapped to targets in groups within k.group_mask."""
self.group_cids = {g: [] for g in special_keys.CID_GROUP}
def _query_key(self, index: int):
"""Queries the device for a given key and stores it in self.keys."""
if index < 0 or index >= len(self.keys):
raise IndexError(index)
# TODO: add here additional variants for other REPROG_CONTROLS
if self.keyversion == 1:
keydata = feature_request(self.device, FEATURE.REPROG_CONTROLS, 0x10, index)
if keydata:
cid, tid, flags = _unpack('!HHB', keydata[:5])
self.keys[index] = ReprogrammableKey(self.device, index, cid, tid, flags)
self.cid_to_tid[cid] = tid
elif self.keyversion == 4:
keydata = feature_request(self.device, FEATURE.REPROG_CONTROLS_V4, 0x10, index)
if keydata:
cid, tid, flags, pos, group, gmask, rawxy = _unpack('!HHBBBBB', keydata[:9])
self.keys[index] = ReprogrammableKeyV4(
self.device,
index,
cid,
tid,
flags,
pos,
group,
gmask,
(rawxy & 0x1) == 0x1,
)
self.cid_to_tid[cid] = tid
if group != 0: # 0 = does not belong to a group
self.group_cids[special_keys.CID_GROUP[group]].append(cid)
elif _log.isEnabledFor(_WARNING):
_log.warn(f"Key with index {index} was expected to exist but device doesn't report it.")
def _ensure_all_keys_queried(self):
"""The retrieval of key information is lazy, but for certain functionality
we need to know all keys. This function makes sure that's the case."""
for (i, k) in enumerate(self.keys):
if k is None:
self._query_key(i)
def __getitem__(self, index):
if isinstance(index, int):
if index < 0 or index >= len(self.keys):
raise IndexError(index)
# TODO: add here additional variants for other REPROG_CONTROLS
if self.keys[index] is None:
keydata = feature_request(self.device, FEATURE.REPROG_CONTROLS, 0x10, index)
self.keyversion = 1
if keydata is None:
keydata = feature_request(self.device, FEATURE.REPROG_CONTROLS_V4, 0x10, index)
self.keyversion = 4
if keydata:
key, key_task, flags, pos, group, gmask = _unpack('!HHBBBB', keydata[:8])
ctrl_id_text = special_keys.CONTROL[key]
ctrl_task_text = special_keys.TASK[key_task]
if self.keyversion == 1:
self.keys[index] = _ReprogrammableKeyInfo(index, ctrl_id_text, ctrl_task_text, flags)
if self.keyversion == 4:
try:
mapped_data = feature_request(
self.device, FEATURE.REPROG_CONTROLS_V4, 0x20, key & 0xff00, key & 0xff
)
if mapped_data:
remap_key, remap_flag, remapped = _unpack('!HBH', mapped_data[:5])
# if key not mapped map it to itself for display
if remapped == 0:
remapped = key
except Exception:
remapped = key
# remap_key = key
# remap_flag = 0
remapped_text = special_keys.CONTROL[remapped]
self.keys[index] = _ReprogrammableKeyInfoV4(
index, ctrl_id_text, ctrl_task_text, flags, pos, group, gmask, remapped_text
)
self._query_key(index)
return self.keys[index]
@ -566,8 +789,10 @@ def decipher_voltage(voltage_report):
def get_keys(device):
# TODO: add here additional variants for other REPROG_CONTROLS
count = feature_request(device, FEATURE.REPROG_CONTROLS)
if count is None:
count = None
if FEATURE.REPROG_CONTROLS in device.features:
count = feature_request(device, FEATURE.REPROG_CONTROLS)
elif FEATURE.REPROG_CONTROLS_V4 in device.features:
count = feature_request(device, FEATURE.REPROG_CONTROLS_V4)
if count:
return KeysArray(device, ord(count[:1]))

View File

@ -24,6 +24,7 @@ from __future__ import absolute_import, division, print_function, unicode_litera
from logging import DEBUG as _DEBUG
from logging import INFO as _INFO
from logging import WARNING as _WARNING
from logging import getLogger
from . import hidpp10 as _hidpp10
@ -270,9 +271,23 @@ def _process_feature_notification(device, status, n, feature):
if _log.isEnabledFor(_INFO):
_log.info('%s: reprogrammable key: %s', device, n)
else:
_log.warn('%s: unknown REPROGRAMMABLE KEYS %s', device, n)
_log.warn('%s: unknown REPROG_CONTROLS %s', device, n)
return True
if feature == _F.REPROG_CONTROLS_V4:
if n.address == 0x00:
if _log.isEnabledFor(_DEBUG):
cid1, cid2, cid3, cid4 = _unpack('!HHHH', n.data[:8])
_log.debug('%s: diverted controls pressed: %i, %i, %i, %i', device, cid1, cid2, cid3, cid4)
return True
elif n.address == 0x10:
if _log.isEnabledFor(_DEBUG):
dx, dy = _unpack('!hh', n.data[:4])
_log.debug('%s: rawXY dx=%i dy=%i', device, dx, dy)
return True
elif _log.isEnabledFor(_WARNING):
_log.warn('%s: unknown REPROG_CONTROLS_V4 %s', device, n)
if feature == _F.WIRELESS_DEVICE_STATUS:
if n.address == 0x00:
if _log.isEnabledFor(_DEBUG):

View File

@ -499,7 +499,9 @@ KEY_FLAG = _NamedInts(
is_FN=0x02,
mse=0x01
)
MAPPING_FLAG = _NamedInts(rawXY_diverted=0x10, persistently_diverted=0x04, diverted=0x01)
CID_GROUP_BIT = _NamedInts(g8=0x80, g7=0x40, g6=0x20, g5=0x10, g4=0x08, g3=0x04, g2=0x02, g1=0x01)
CID_GROUP = _NamedInts(g8=8, g7=7, g6=6, g5=5, g4=4, g3=3, g2=2, g1=1)
DISABLE = _NamedInts(
Caps_Lock=0x01,
Num_Lock=0x02,

View File

@ -22,7 +22,6 @@ from __future__ import absolute_import, division, print_function, unicode_litera
from logitech_receiver import hidpp10 as _hidpp10
from logitech_receiver import hidpp20 as _hidpp20
from logitech_receiver import settings_templates as _settings_templates
from logitech_receiver import special_keys as _special_keys
from logitech_receiver.common import NamedInt as _NamedInt
@ -191,13 +190,14 @@ def _print_device(dev):
if dev.online and dev.keys:
print(' Has %d reprogrammable keys:' % len(dev.keys))
for k in dev.keys:
flags = _special_keys.KEY_FLAG.flag_names(k.flags)
# TODO: add here additional variants for other REPROG_CONTROLS
if dev.keys.keyversion == 1:
print(' %2d: %-26s => %-27s %s' % (k.index, k.key, k.task, ', '.join(flags)))
print(' %2d: %-26s => %-27s %s' % (k.index, k.key, k.default_task, ', '.join(k.flags)))
if dev.keys.keyversion == 4:
print(' %2d: %-26s, default: %-27s => %-26s' % (k.index, k.key, k.task, k.remapped))
print(' %s, pos:%d, group:%1d, gmask:%d' % (', '.join(flags), k.pos, k.group, k.group_mask))
print(' %2d: %-26s, default: %-27s => %-26s' % (k.index, k.key, k.default_task, k.mapped_to))
gmask_fmt = ','.join(k.group_mask)
gmask_fmt = gmask_fmt if gmask_fmt else 'empty'
print(' %s, pos:%d, group:%1d, group mask:%s' % (', '.join(k.flags), k.pos, k.group, gmask_fmt))
if dev.online:
battery = _hidpp20.get_battery(dev)
if battery is None: