ui: add UI for LED Zone control

This commit is contained in:
Peter F. Patel-Schneider 2024-02-07 10:37:51 -05:00
parent 3328a6085f
commit 73d091c86f
5 changed files with 152 additions and 52 deletions

View File

@ -1152,7 +1152,17 @@ class Backlight:
self.device.feature_request(FEATURE.BACKLIGHT2, 0x10, data_bytes) 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 = { LEDParamSize = {
LEDParam.color: 3, LEDParam.color: 3,
LEDParam.speed: 1, LEDParam.speed: 1,
@ -1161,44 +1171,34 @@ LEDParamSize = {
LEDParam.ramp: 1, LEDParam.ramp: 1,
LEDParam.form: 1 LEDParam.form: 1
} }
LEDEffects = _NamedInts( LEDEffects = {
Disable=0x00, 0x0: [_NamedInt(0x0, _('Disable')), {}],
Fixed=0x01, 0x1: [_NamedInt(0x1, _('Fixed')), {
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.color: 0,
LEDParam.ramp: 3 LEDParam.ramp: 3
}, }],
LEDEffects.Pulse: { 0x2: [_NamedInt(0x2, _('Pulse')), {
LEDParam.color: 0, LEDParam.color: 0,
LEDParam.speed: 3 LEDParam.speed: 3
}, }],
LEDEffects.Cycle: { 0x3: [_NamedInt(0x3, _('Cycle')), {
LEDParam.period: 5, LEDParam.period: 5,
LEDParam.intensity: 7 LEDParam.intensity: 7
}, }],
LEDEffects.Boot: {}, 0x8: [_NamedInt(0x8, _('Boot')), {}],
LEDEffects.Demo: {}, 0x9: [_NamedInt(0x9, _('Demo')), {}],
LEDEffects.Breathe: { 0xA: [_NamedInt(0xA, _('Breathe')), {
LEDParam.color: 0, LEDParam.color: 0,
LEDParam.period: 3, LEDParam.period: 3,
LEDParam.form: 5, LEDParam.form: 5,
LEDParam.intensity: 6 LEDParam.intensity: 6
}, }],
LEDEffects.Ripple: { 0xB: [_NamedInt(0xB, _('Ripple')), {
LEDParam.color: 0, LEDParam.color: 0,
LEDParam.period: 4 LEDParam.period: 4
} }]
} }
# Wave=0x04, Stars=0x05, Press=0x06, Audio=0x07, # not implemented
class LEDEffectSetting: # an effect plus its parameters class LEDEffectSetting: # an effect plus its parameters
@ -1210,9 +1210,10 @@ class LEDEffectSetting: # an effect plus its parameters
@classmethod @classmethod
def from_bytes(cls, bytes): def from_bytes(cls, bytes):
args = {'ID': LEDEffects[bytes[0]]} effect = LEDEffects[bytes[0]] if bytes[0] in LEDEffects else None
if args['ID'] in LEDEffectsParams: args = {'ID': effect[0] if effect else None}
for p, b in LEDEffectsParams[args['ID']].items(): if effect:
for p, b in effect[1].items():
args[str(p)] = _bytes2int(bytes[1 + b:1 + b + LEDParamSize[p]]) args[str(p)] = _bytes2int(bytes[1 + b:1 + b + LEDParamSize[p]])
else: else:
args['bytes'] = bytes args['bytes'] = bytes
@ -1224,7 +1225,7 @@ class LEDEffectSetting: # an effect plus its parameters
return self.bytes if self.bytes else b'\xff' * 11 return self.bytes if self.bytes else b'\xff' * 11
else: else:
bs = [0] * 10 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]) bs[b:b + LEDParamSize[p]] = _int2bytes(getattr(self, str(p), 0), LEDParamSize[p])
return _int2bytes(ID, 1) + bytes(bs) 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) return dumper.represent_mapping('!LEDEffectSetting', data.__dict__, flow_style=True)
def __str__(self): 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) _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)} args = {'ID': next((ze.ID for ze in options if ze.index == bytes[0]), None)}
else: else:
args = {'ID': None} args = {'ID': None}
if args['ID'] in LEDEffectsParams: if args['ID'] in LEDEffects:
for p, b in LEDEffectsParams[args['ID']].items(): for p, b in LEDEffects[args['ID']][1].items():
args[str(p)] = _bytes2int(bytes[1 + b:1 + b + LEDParamSize[p]]) args[str(p)] = _bytes2int(bytes[1 + b:1 + b + LEDParamSize[p]])
else: else:
args['bytes'] = bytes args['bytes'] = bytes

View File

@ -1231,14 +1231,8 @@ class HeteroValidator(Validator):
to_write = new_value.to_bytes() to_write = new_value.to_bytes()
return to_write return to_write
def acceptable(self, args, current): # FIXME def acceptable(self, args, current): # should this actually do some checking?
if len(args) != 2: return True
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
class PackedRangeValidator(Validator): class PackedRangeValidator(Validator):

View File

@ -24,6 +24,8 @@ from struct import pack as _pack
from struct import unpack as _unpack from struct import unpack as _unpack
from time import time as _time from time import time as _time
import webcolors as _webcolors
from . import hidpp10 as _hidpp10 from . import hidpp10 as _hidpp10
from . import hidpp20 as _hidpp20 from . import hidpp20 as _hidpp20
from . import special_keys as _special_keys 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 bytes2int as _bytes2int
from .common import int2bytes as _int2bytes from .common import int2bytes as _int2bytes
from .i18n import _ from .i18n import _
from .settings import KIND as _KIND
from .settings import ActionSettingRW as _ActionSettingRW from .settings import ActionSettingRW as _ActionSettingRW
from .settings import BitFieldSetting as _BitFieldSetting from .settings import BitFieldSetting as _BitFieldSetting
from .settings import BitFieldValidator as _BitFieldV from .settings import BitFieldValidator as _BitFieldV
@ -1430,20 +1433,28 @@ class LEDControl(_Setting):
validator_options = {'choices': choices_universe} validator_options = {'choices': choices_universe}
# an LED Zone has an index, a set of possible LED effects, and an LED effect setting with parameters colors = _NamedInts()
# the parameters are different for each effect 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 # reading the current setting for a zone returns zeros on some devices
class LEDZoneSetting(_Setting): class LEDZoneSetting(_Setting):
name = 'led_zone_' name = 'led_zone_'
label = _('LED Zone Effects') label = _('LED Zone Effects')
description = _('Set effect for LED Zone') description = _('Set effect for LED Zone')
feature = _F.COLOR_LED_EFFECTS feature = _F.COLOR_LED_EFFECTS
color_field = {'name': _LEDP.color, 'kind': _KIND.choice, 'label': None, 'choices': colors}
class validator_class(_HeteroV): 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}
@classmethod intensity_field = {'name': _LEDP.intensity, 'kind': _KIND.range, 'label': _('Intensity'), 'min': 0, 'max': 100}
def build(cls, setting_class, device, effects): ramp_field = {'name': _LEDP.ramp, 'kind': _KIND.choice, 'label': _('Ramp'), 'choices': _hidpp20.LEDRampChoices}
return cls(data_class=_hidpp20.LEDEffectIndexed, options=effects) # 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 @classmethod
def build(cls, device): def build(cls, device):
@ -1452,10 +1463,14 @@ class LEDZoneSetting(_Setting):
for zone in zone_infos: for zone in zone_infos:
prefix = zone.index.to_bytes(1) prefix = zone.index.to_bytes(1)
rw = _FeatureRW(_F.COLOR_LED_EFFECTS, read_fnid=0xE0, write_fnid=0x30, prefix=prefix) 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 = cls(device, rw, validator)
setting.name = cls.name + str(int(zone.location)) 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) settings.append(setting)
return settings return settings

View File

@ -24,6 +24,7 @@ from logging import getLogger
from threading import Timer as _Timer from threading import Timer as _Timer
from gi.repository import Gdk, GLib, Gtk 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 KIND as _SETTING_KIND
from logitech_receiver.settings import SENSITIVITY_IGNORE as _SENSITIVITY_IGNORE from logitech_receiver.settings import SENSITIVITY_IGNORE as _SENSITIVITY_IGNORE
from solaar.i18n import _, ngettext 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(): class Control():
def __init__(**kwargs): def __init__(**kwargs):
@ -93,7 +109,8 @@ class Control():
def layout(self, sbox, label, change, spinner, failed): def layout(self, sbox, label, change, spinner, failed):
sbox.pack_start(label, False, False, 0) sbox.pack_start(label, False, False, 0)
sbox.pack_end(change, 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(spinner, False, False, 0)
sbox.pack_end(failed, False, False, 0) sbox.pack_end(failed, False, False, 0)
return self return self
@ -510,6 +527,76 @@ class PackedRangeControl(MultipleRangeControl):
self._button.set_tooltip_text(b) 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) control = MultipleRangeControl(sbox, change)
elif s.kind == _SETTING_KIND.packed_range: elif s.kind == _SETTING_KIND.packed_range:
control = PackedRangeControl(sbox, change) control = PackedRangeControl(sbox, change)
elif s.kind == _SETTING_KIND.hetero:
control = HeteroKeyControl(sbox, change)
else: else:
if _log.isEnabledFor(_WARNING): if _log.isEnabledFor(_WARNING):
_log.warn('setting %s display not implemented', s.label) _log.warn('setting %s display not implemented', s.label)

View File

@ -77,6 +77,7 @@ For instructions on installing Solaar see https://pwr-solaar.github.io/Solaar/in
python_requires='>=3.7', python_requires='>=3.7',
install_requires=[ install_requires=[
'evdev (>= 1.1.2) ; platform_system=="Linux"', 'evdev (>= 1.1.2) ; platform_system=="Linux"',
'webcolors',
'pyudev (>= 0.13)', 'pyudev (>= 0.13)',
'PyYAML (>= 3.12)', 'PyYAML (>= 3.12)',
'python-xlib (>= 0.27)', 'python-xlib (>= 0.27)',