Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support color feature for many devices #2245

Merged
merged 6 commits into from
Feb 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ Some mice store one or more profiles, which control aspects of the behavior of t

Profiles can control the rate at which the mouse reports movement, the resolution of the the movement reports, what the mouse buttons do, and its LED effects. Solaar can dump the entire set of profiles into a YAML file can load an entire set of profiles from a file. Users can edit the file to effect changes to the profiles. Solaar has a setting that switches between profiles or disables all profiles. When switching between profiles or using a button to change resolution Solaar keeps track of the changes in the settings for these features.

When profiles are active changes cannot be made to the Report Rate setting. Changes can be made to the Sensitivity setting and to LED settings. To keep the profile values make these setting ignored.

A profile file has some bookkeeping information, including profile version and the name of the device, and a sequence of profiles.

Each profile has the following fields:
Expand Down
36 changes: 28 additions & 8 deletions lib/logitech_receiver/device.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
# -*- python-mode -*-

## Copyright (C) 2012-2013 Daniel Pavel
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

import errno as _errno
import threading as _threading

Expand Down Expand Up @@ -60,10 +78,9 @@ def __init__(
if receiver:
assert number > 0 and number <= 15 # some receivers have devices past their max # of devices
self.number = number # will be None at this point for directly connected devices
self.online = None
self.online = self.descriptor = None

self.wpid = None # the Wireless PID is unique per device model
self.descriptor = None
self._kind = None # mouse, keyboard, etc (see _hidpp10.DEVICE_KIND)
self._codename = None # Unifying peripherals report a codename.
self._name = None # the full name of the model
Expand All @@ -74,16 +91,13 @@ def __init__(
self._tid_map = None # map from transports to product identifiers
self._persister = None # persister holds settings

self._firmware = None
self._keys = None
self._remap_keys = None
self._gestures = None
self._firmware = self._keys = self._remap_keys = self._gestures = None
self._polling_rate = self._power_switch = self._led_effects = None

self._gestures_lock = _threading.Lock()
self._profiles = self._backlight = self._registers = self._settings = None
self._feature_settings_checked = False
self._settings_lock = _threading.Lock()
self._polling_rate = None
self._power_switch = None

# See `add_notification_handler`
self._notification_handlers = {}
Expand Down Expand Up @@ -291,6 +305,12 @@ def polling_rate(self):
self._polling_rate = rate if rate else self._polling_rate
return self._polling_rate

@property
def led_effects(self):
if not self._led_effects and self.online and self.protocol >= 2.0:
self._led_effects = _hidpp20.LEDEffectsInfo(self)
return self._led_effects

@property
def keys(self):
if not self._keys:
Expand Down
172 changes: 112 additions & 60 deletions lib/logitech_receiver/hidpp20.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from .common import bytes2int as _bytes2int
from .common import crc16 as _crc16
from .common import int2bytes as _int2bytes
from .i18n import _

_log = getLogger(__name__)
del getLogger
Expand Down Expand Up @@ -1151,94 +1152,145 @@ def write(self):
self.device.feature_request(FEATURE.BACKLIGHT2, 0x10, data_bytes)


LEDParam = _NamedInts(color=0, speed=1, period=2, intensity=3, ramp=4, form=5)
LEDParamSize = {
LEDParam.color: 3,
LEDParam.speed: 1,
LEDParam.period: 2,
LEDParam.intensity: 1,
LEDParam.ramp: 1,
LEDParam.form: 1
}
LEDEffects = _NamedInts(
Disable=0x00,
Fixed=0x01,
Pulse=0x02,
Cycle=0x03,
# Wave=0x04, Stars=0x05, Press=0x06, Audio=0x07, # not implemented
Boot=0x08,
Demo=0x09,
Breathe=0x0A,
Ripple=0x0B
)
LEDEffectsParams = {
LEDEffects.Disable: {},
LEDEffects.Fixed: {
LEDParam.color: 0,
LEDParam.ramp: 3
},
LEDEffects.Pulse: {
LEDParam.color: 0,
LEDParam.speed: 3
},
LEDEffects.Cycle: {
LEDParam.period: 5,
LEDParam.intensity: 7
},
LEDEffects.Boot: {},
LEDEffects.Demo: {},
LEDEffects.Breathe: {
LEDParam.color: 0,
LEDParam.period: 3,
LEDParam.form: 5,
LEDParam.intensity: 6
},
LEDEffects.Ripple: {
LEDParam.color: 0,
LEDParam.period: 4
}
}
class LEDParam:
color = 'color'
speed = 'speed'
period = 'period'
intensity = 'intensity'
ramp = 'ramp'
form = 'form'


LEDRampChoices = _NamedInts(default=0, yes=1, no=2)
LEDFormChoices = _NamedInts(default=0, sine=1, square=2, triangle=3, sawtooth=4, sharkfin=5, exponential=6)
LEDParamSize = {LEDParam.color: 3, LEDParam.speed: 1, LEDParam.period: 2,
LEDParam.intensity: 1, LEDParam.ramp: 1, LEDParam.form: 1} # yapf: disable
LEDEffects = { # Wave=0x04, Stars=0x05, Press=0x06, Audio=0x07, # not implemented
0x0: [_NamedInt(0x0, _('Disabled')), {}],
0x1: [_NamedInt(0x1, _('Static')), {LEDParam.color: 0, LEDParam.ramp: 3}],
0x2: [_NamedInt(0x2, _('Pulse')), {LEDParam.color: 0, LEDParam.speed: 3}],
0x3: [_NamedInt(0x3, _('Cycle')), {LEDParam.period: 5, LEDParam.intensity: 7}],
0x8: [_NamedInt(0x8, _('Boot')), {}],
0x9: [_NamedInt(0x9, _('Demo')), {}],
0xA: [_NamedInt(0xA, _('Breathe')), {LEDParam.color: 0, LEDParam.period: 3,
LEDParam.form: 5, LEDParam.intensity: 6}],
0xB: [_NamedInt(0xB, _('Ripple')), {LEDParam.color: 0, LEDParam.period: 4}]
} # yapf: disable

class LEDEffectSetting:

class LEDEffectSetting: # an effect plus its parameters

def __init__(self, **kwargs):
self.ID = None
for key, val in kwargs.items():
setattr(self, key, val)

@classmethod
def from_bytes(cls, bytes):
args = {'ID': LEDEffects[bytes[0]]}
if args['ID'] in LEDEffectsParams:
for p, b in LEDEffectsParams[args['ID']].items():
def from_bytes(cls, bytes, options=None):
ID = next((ze.ID for ze in options if ze.index == bytes[0]), None) if options is not None else bytes[0]
effect = LEDEffects[ID] if ID in LEDEffects else None
args = {'ID': effect[0] if effect else None}
if effect:
for p, b in effect[1].items():
args[str(p)] = _bytes2int(bytes[1 + b:1 + b + LEDParamSize[p]])
else:
args['bytes'] = bytes
return cls(**args)

def to_bytes(self):
if self.ID is None:
return self.bytes if self.bytes else b'\xff' * 11
def to_bytes(self, options=None):
ID = self.ID
if ID is None:
return self.bytes if hasattr(self, 'bytes') else b'\xff' * 11
else:
bs = [0] * 10
for p, b in LEDEffectsParams[self.ID].items():
bs[b:b + LEDParamSize[p]] = _int2bytes(getattr(self, str(p)), LEDParamSize[p])
return _int2bytes(self.ID, 1) + bytes(bs)
for p, b in LEDEffects[ID][1].items():
bs[b:b + LEDParamSize[p]] = _int2bytes(getattr(self, str(p), 0), LEDParamSize[p])
if options is not None:
ID = next((ze.index for ze in options if ze.ID == ID), None)
result = _int2bytes(ID, 1) + bytes(bs)
return result

@classmethod
def from_yaml(cls, loader, node):
args = loader.construct_mapping(node)
return cls(**args)
return cls(**loader.construct_mapping(node))

@classmethod
def to_yaml(cls, dumper, data):
return dumper.represent_mapping('!LEDEffectSetting', data.__dict__, flow_style=True)

def __str__(self):
return _yaml.dump(self, width=float('inf')).rstrip('\n')


_yaml.SafeLoader.add_constructor('!LEDEffectSetting', LEDEffectSetting.from_yaml)
_yaml.add_representer(LEDEffectSetting, LEDEffectSetting.to_yaml)


class LEDEffectInfo: # an effect that a zone can do

def __init__(self, device, zindex, eindex):
info = device.feature_request(FEATURE.COLOR_LED_EFFECTS, 0x20, zindex, eindex)
self.zindex, self.index, self.ID, self.capabilities, self.period = _unpack('!BBHHH', info[0:8])

def __str__(self):
return f'LEDEffectInfo({self.zindex}, {self.index}, {self.ID}, {self.capabilities: x}, {self.period})'


LEDZoneLocations = _NamedInts()
LEDZoneLocations[0x00] = _('Unknown Location')
LEDZoneLocations[0x01] = _('Primary')
LEDZoneLocations[0x02] = _('Logo')
LEDZoneLocations[0x03] = _('Left Side')
LEDZoneLocations[0x04] = _('Right Side')
LEDZoneLocations[0x05] = _('Combined')
LEDZoneLocations[0x06] = _('Primary 1')
LEDZoneLocations[0x07] = _('Primary 2')
LEDZoneLocations[0x08] = _('Primary 3')
LEDZoneLocations[0x09] = _('Primary 4')
LEDZoneLocations[0x0A] = _('Primary 5')
LEDZoneLocations[0x0B] = _('Primary 6')


class LEDZoneInfo: # effects that a zone can do

def __init__(self, device, index):
info = device.feature_request(FEATURE.COLOR_LED_EFFECTS, 0x10, index)
self.index, self.location, self.count = _unpack('!BHB', info[0:4])
self.location = LEDZoneLocations[self.location] if LEDZoneLocations[self.location] else self.location
self.effects = []
for i in range(0, self.count):
self.effects.append(LEDEffectInfo(device, index, i))

def to_command(self, setting):
for i in range(0, len(self.effects)):
e = self.effects[i]
if e.ID == setting.ID:
return _int2bytes(self.index) + _int2bytes(i) + setting.to_bytes()[1:]
return None

def __str__(self):
return f'LEDZoneInfo({self.index}, {self.location}, {[str(z) for z in self.effects]}'


class LEDEffectsInfo: # effects that the LEDs can do

def __init__(self, device):
info = device.feature_request(FEATURE.COLOR_LED_EFFECTS, 0x00)
self.device = device
self.count, _, capabilities = _unpack('!BHH', info[0:5])
self.readable = capabilities & 0x1
self.zones = []
for i in range(0, self.count):
self.zones.append(LEDZoneInfo(device, i))

def to_command(self, index, setting):
return self.zones[index].to_command(setting)

def __str__(self):
zones = '\n'.join([str(z) for z in self.zones])
return f'LEDEffectsInfo({self.device}, readable {self.readable}\n{zones})'


ButtonBehaviors = _NamedInts(MacroExecute=0x0, MacroStop=0x1, MacroStopAll=0x2, Send=0x8, Function=0x9)
ButtonMappingTypes = _NamedInts(No_Action=0x0, Button=0x1, Modifier_And_Key=0x2, Consumer_Key=0x3)
ButtonFunctions = _NamedInts(
Expand Down
44 changes: 40 additions & 4 deletions lib/logitech_receiver/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@

SENSITIVITY_IGNORE = 'ignore'
KIND = _NamedInts(
toggle=0x01, choice=0x02, range=0x04, map_choice=0x0A, multiple_toggle=0x10, packed_range=0x20, multiple_range=0x40
toggle=0x01,
choice=0x02,
range=0x04,
map_choice=0x0A,
multiple_toggle=0x10,
packed_range=0x20,
multiple_range=0x40,
hetero=0x80
)


Expand Down Expand Up @@ -279,7 +286,7 @@ def read(self, cached=True):
reply = self._rw.read(self._device)
if reply:
self._value = self._validator.validate_read(reply)
if self._device.persister and self.name not in self._device.persister:
if self._value is not None and self._device.persister and self.name not in self._device.persister:
# Don't update the persister if it already has a value,
# otherwise the first read might overwrite the value we wanted.
self._device.persister[self.name] = self._value if self.persist else None
Expand Down Expand Up @@ -344,9 +351,11 @@ def apply(self):
if self.persist and value is not None: # If setting doesn't persist no need to write value just read
try:
self.write(value, save=False)
except Exception:
except Exception as e:
if _log.isEnabledFor(_WARNING):
_log.warn('%s: error applying value %s so ignore it (%s)', self.name, self._value, self._device)
_log.warn(
'%s: error applying value %s so ignore it (%s): %s', self.name, self._value, self._device, repr(e)
)

def __str__(self):
if hasattr(self, '_value'):
Expand Down Expand Up @@ -1200,6 +1209,33 @@ def compare(self, args, current):
return False


class HeteroValidator(Validator):
kind = KIND.hetero

@classmethod
def build(cls, setting_class, device, **kwargs):
return cls(**kwargs)

def __init__(self, data_class=None, options=None, readable=True):
assert data_class is not None and options is not None
self.data_class = data_class
self.options = options
self.readable = readable
self.needs_current_value = False

def validate_read(self, reply_bytes):
if self.readable:
reply_value = self.data_class.from_bytes(reply_bytes, options=self.options)
return reply_value

def prepare_write(self, new_value, current_value=None):
to_write = new_value.to_bytes(options=self.options)
return to_write

def acceptable(self, args, current): # should this actually do some checking?
return True


class PackedRangeValidator(Validator):
kind = KIND.packed_range
"""Several range values, all the same size, all the same min and max"""
Expand Down
Loading
Loading