From 73d091c86fb378633a0a43cf07ea6860fc40a8bf Mon Sep 17 00:00:00 2001 From: "Peter F. Patel-Schneider" Date: Wed, 7 Feb 2024 10:37:51 -0500 Subject: [PATCH] ui: add UI for LED Zone control --- lib/logitech_receiver/hidpp20.py | 67 +++++++-------- lib/logitech_receiver/settings.py | 10 +-- lib/logitech_receiver/settings_templates.py | 35 +++++--- lib/solaar/ui/config_panel.py | 91 ++++++++++++++++++++- setup.py | 1 + 5 files changed, 152 insertions(+), 52 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 18783241..ca13b2c1 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -1152,7 +1152,17 @@ class Backlight: self.device.feature_request(FEATURE.BACKLIGHT2, 0x10, data_bytes) -LEDParam = _NamedInts(color=0, speed=1, period=2, intensity=3, ramp=4, form=5) +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, @@ -1161,44 +1171,34 @@ LEDParamSize = { 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: { +LEDEffects = { + 0x0: [_NamedInt(0x0, _('Disable')), {}], + 0x1: [_NamedInt(0x1, _('Fixed')), { LEDParam.color: 0, LEDParam.ramp: 3 - }, - LEDEffects.Pulse: { + }], + 0x2: [_NamedInt(0x2, _('Pulse')), { LEDParam.color: 0, LEDParam.speed: 3 - }, - LEDEffects.Cycle: { + }], + 0x3: [_NamedInt(0x3, _('Cycle')), { LEDParam.period: 5, LEDParam.intensity: 7 - }, - LEDEffects.Boot: {}, - LEDEffects.Demo: {}, - LEDEffects.Breathe: { + }], + 0x8: [_NamedInt(0x8, _('Boot')), {}], + 0x9: [_NamedInt(0x9, _('Demo')), {}], + 0xA: [_NamedInt(0xA, _('Breathe')), { LEDParam.color: 0, LEDParam.period: 3, LEDParam.form: 5, LEDParam.intensity: 6 - }, - LEDEffects.Ripple: { + }], + 0xB: [_NamedInt(0xB, _('Ripple')), { LEDParam.color: 0, LEDParam.period: 4 - } + }] } +# Wave=0x04, Stars=0x05, Press=0x06, Audio=0x07, # not implemented class LEDEffectSetting: # an effect plus its parameters @@ -1210,9 +1210,10 @@ class LEDEffectSetting: # an effect plus its parameters @classmethod def from_bytes(cls, bytes): - args = {'ID': LEDEffects[bytes[0]]} - if args['ID'] in LEDEffectsParams: - for p, b in LEDEffectsParams[args['ID']].items(): + effect = LEDEffects[bytes[0]] if bytes[0] 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 @@ -1224,7 +1225,7 @@ class LEDEffectSetting: # an effect plus its parameters return self.bytes if self.bytes else b'\xff' * 11 else: bs = [0] * 10 - for p, b in LEDEffectsParams[self.ID].items(): + for p, b in LEDEffects[self.ID][1].items(): bs[b:b + LEDParamSize[p]] = _int2bytes(getattr(self, str(p), 0), LEDParamSize[p]) return _int2bytes(ID, 1) + bytes(bs) @@ -1238,7 +1239,7 @@ class LEDEffectSetting: # an effect plus its parameters return dumper.represent_mapping('!LEDEffectSetting', data.__dict__, flow_style=True) def __str__(self): - return _yaml.dump(self).rstrip('\n') + return _yaml.dump(self, width=float('inf')).rstrip('\n') _yaml.SafeLoader.add_constructor('!LEDEffectSetting', LEDEffectSetting.from_yaml) @@ -1253,8 +1254,8 @@ class LEDEffectIndexed(LEDEffectSetting): # an effect plus its parameters, usin args = {'ID': next((ze.ID for ze in options if ze.index == bytes[0]), None)} else: args = {'ID': None} - if args['ID'] in LEDEffectsParams: - for p, b in LEDEffectsParams[args['ID']].items(): + if args['ID'] in LEDEffects: + for p, b in LEDEffects[args['ID']][1].items(): args[str(p)] = _bytes2int(bytes[1 + b:1 + b + LEDParamSize[p]]) else: args['bytes'] = bytes diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index 5a2d2031..39e19618 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -1231,14 +1231,8 @@ class HeteroValidator(Validator): to_write = new_value.to_bytes() return to_write - def acceptable(self, args, current): # FIXME - if len(args) != 2: - return None - item = self.items[args[0]] if args[0] in self.items else None - if item.kind == KIND.range: - return None if args[1] < item.min_value or args[1] > item.max_value else args - elif item.kind == KIND.choice: - return args if args[1] in item.choices else None + def acceptable(self, args, current): # should this actually do some checking? + return True class PackedRangeValidator(Validator): diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 91d018c1..e01acf0c 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -24,6 +24,8 @@ from struct import pack as _pack from struct import unpack as _unpack from time import time as _time +import webcolors as _webcolors + from . import hidpp10 as _hidpp10 from . import hidpp20 as _hidpp20 from . import special_keys as _special_keys @@ -32,6 +34,7 @@ from .common import NamedInts as _NamedInts from .common import bytes2int as _bytes2int from .common import int2bytes as _int2bytes from .i18n import _ +from .settings import KIND as _KIND from .settings import ActionSettingRW as _ActionSettingRW from .settings import BitFieldSetting as _BitFieldSetting from .settings import BitFieldValidator as _BitFieldV @@ -1430,20 +1433,28 @@ class LEDControl(_Setting): validator_options = {'choices': choices_universe} -# an LED Zone has an index, a set of possible LED effects, and an LED effect setting with parameters -# the parameters are different for each effect +colors = _NamedInts() +for c, v in _webcolors.CSS3_NAMES_TO_HEX.items(): + v = int(v[1:], 16) + if v not in colors: + colors[v] = c +_LEDP = _hidpp20.LEDParam + + +# an LED Zone has an index, a set of possible LED effects, and an LED effect setting # reading the current setting for a zone returns zeros on some devices class LEDZoneSetting(_Setting): name = 'led_zone_' label = _('LED Zone Effects') description = _('Set effect for LED Zone') feature = _F.COLOR_LED_EFFECTS - - class validator_class(_HeteroV): - - @classmethod - def build(cls, setting_class, device, effects): - return cls(data_class=_hidpp20.LEDEffectIndexed, options=effects) + color_field = {'name': _LEDP.color, 'kind': _KIND.choice, 'label': None, 'choices': colors} + speed_field = {'name': _LEDP.speed, 'kind': _KIND.range, 'label': _('Speed'), 'min': 0, 'max': 255} + period_field = {'name': _LEDP.period, 'kind': _KIND.range, 'label': _('Period'), 'min': 0, 'max': 5000} + intensity_field = {'name': _LEDP.intensity, 'kind': _KIND.range, 'label': _('Intensity'), 'min': 0, 'max': 100} + ramp_field = {'name': _LEDP.ramp, 'kind': _KIND.choice, 'label': _('Ramp'), 'choices': _hidpp20.LEDRampChoices} + # form_field = { 'name': _LEDP.form, 'kind': _KIND.choice, 'label': _('Form'), 'choices': _hidpp20.LEDFormChoices } + possible_fields = [color_field, speed_field, period_field, intensity_field, ramp_field] @classmethod def build(cls, device): @@ -1452,10 +1463,14 @@ class LEDZoneSetting(_Setting): for zone in zone_infos: prefix = zone.index.to_bytes(1) rw = _FeatureRW(_F.COLOR_LED_EFFECTS, read_fnid=0xE0, write_fnid=0x30, prefix=prefix) - validator = cls.validator_class.build(cls, device, zone.effects) + validator = _HeteroV(data_class=_hidpp20.LEDEffectIndexed, options=zone.effects) setting = cls(device, rw, validator) setting.name = cls.name + str(int(zone.location)) - setting.label = _('LEDs: ') + str(_hidpp20.LEDZoneLocations[zone.location]) + setting.label = _('LEDs') + ' ' + str(_hidpp20.LEDZoneLocations[zone.location]) + choices = [_hidpp20.LEDEffects[e.ID][0] for e in zone.effects] + ID_field = {'name': 'ID', 'kind': _KIND.choice, 'label': None, 'choices': choices} + setting.possible_fields = [ID_field] + cls.possible_fields + setting.fields_map = _hidpp20.LEDEffects settings.append(setting) return settings diff --git a/lib/solaar/ui/config_panel.py b/lib/solaar/ui/config_panel.py index daa2a1aa..e9dee6b1 100644 --- a/lib/solaar/ui/config_panel.py +++ b/lib/solaar/ui/config_panel.py @@ -24,6 +24,7 @@ from logging import getLogger from threading import Timer as _Timer from gi.repository import Gdk, GLib, Gtk +from logitech_receiver.hidpp20 import LEDEffectIndexed as _LEDEffectIndexed from logitech_receiver.settings import KIND as _SETTING_KIND from logitech_receiver.settings import SENSITIVITY_IGNORE as _SENSITIVITY_IGNORE from solaar.i18n import _, ngettext @@ -74,6 +75,21 @@ def _write_async(setting, value, sbox, sensitive=True, key=None): # +class ComboBoxText(Gtk.ComboBoxText): + + def get_value(self): + return int(self.get_active_id()) + + def set_value(self, value): + return self.set_active_id(str(int(value))) + + +class Scale(Gtk.Scale): + + def get_value(self): + return int(super().get_value()) + + class Control(): def __init__(**kwargs): @@ -93,7 +109,8 @@ class Control(): def layout(self, sbox, label, change, spinner, failed): sbox.pack_start(label, False, False, 0) sbox.pack_end(change, False, False, 0) - sbox.pack_end(self, sbox.setting.kind == _SETTING_KIND.range, sbox.setting.kind == _SETTING_KIND.range, 0) + fill = sbox.setting.kind == _SETTING_KIND.range or sbox.setting.kind == _SETTING_KIND.hetero + sbox.pack_end(self, fill, fill, 0) sbox.pack_end(spinner, False, False, 0) sbox.pack_end(failed, False, False, 0) return self @@ -510,6 +527,76 @@ class PackedRangeControl(MultipleRangeControl): self._button.set_tooltip_text(b) +# control an ID key that determines what else to show +class HeteroKeyControl(Gtk.HBox, Control): + + def __init__(self, sbox, delegate=None): + super().__init__(homogeneous=False, spacing=6) + self.init(sbox, delegate) + self._items = {} + for item in sbox.setting.possible_fields: + if item['label']: + item_lblbox = Gtk.Label(item['label']) + self.pack_start(item_lblbox, False, False, 0) + else: + item_lblbox = None + if item['kind'] == _SETTING_KIND.choice: + item_box = ComboBoxText() + for entry in item['choices']: + item_box.append(str(int(entry)), str(entry)) + item_box.set_active(0) + item_box.connect('changed', self.changed) + self.pack_start(item_box, False, False, 0) + elif item['kind'] == _SETTING_KIND.range: + item_box = Scale() + item_box.set_range(item['min'], item['max']) + item_box.set_round_digits(0) + item_box.set_digits(0) + item_box.set_increments(1, 5) + item_box.connect('value-changed', self.changed) + self.pack_start(item_box, True, True, 0) + self._items[str(item['name'])] = (item_lblbox, item_box) + + def get_value(self): + result = {} + for k, (_lblbox, box) in self._items.items(): + result[str(k)] = box.get_value() + result = _LEDEffectIndexed(**result) + return result + + def set_value(self, value): + self.set_sensitive(False) + for k, v in value.__dict__.items(): + if k in self._items: + (lblbox, box) = self._items[k] + box.set_value(v) + self.setup_visibles(value.ID) + + def setup_visibles(self, ID): + fields = self.sbox.setting.fields_map[ID][1] if ID in self.sbox.setting.fields_map else {} + for name, (lblbox, box) in self._items.items(): + visible = name in fields or name == 'ID' + if lblbox: + lblbox.set_visible(visible) + box.set_visible(visible) + + def changed(self, control): + if self.get_sensitive() and control.get_sensitive(): + if 'ID' in self._items and control == self._items['ID'][1]: + self.setup_visibles(int(self._items['ID'][1].get_value())) + if hasattr(control, '_timer'): + control._timer.cancel() + control._timer = _Timer(0.3, lambda: GLib.idle_add(self._write, control)) + control._timer.start() + + def _write(self, control): + control._timer.cancel() + delattr(control, '_timer') + new_state = self.get_value() + if self.sbox.setting._value != new_state: + _write_async(self.sbox.setting, new_state, self.sbox) + + # # # @@ -591,6 +678,8 @@ def _create_sbox(s, device): control = MultipleRangeControl(sbox, change) elif s.kind == _SETTING_KIND.packed_range: control = PackedRangeControl(sbox, change) + elif s.kind == _SETTING_KIND.hetero: + control = HeteroKeyControl(sbox, change) else: if _log.isEnabledFor(_WARNING): _log.warn('setting %s display not implemented', s.label) diff --git a/setup.py b/setup.py index d10a2cdd..939c0523 100755 --- a/setup.py +++ b/setup.py @@ -77,6 +77,7 @@ For instructions on installing Solaar see https://pwr-solaar.github.io/Solaar/in python_requires='>=3.7', install_requires=[ 'evdev (>= 1.1.2) ; platform_system=="Linux"', + 'webcolors', 'pyudev (>= 0.13)', 'PyYAML (>= 3.12)', 'python-xlib (>= 0.27)',