Skip to content

Commit

Permalink
ui: add UI for LED Zone control
Browse files Browse the repository at this point in the history
  • Loading branch information
pfps committed Feb 8, 2024
1 parent ffae363 commit fdc4068
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 52 deletions.
67 changes: 34 additions & 33 deletions lib/logitech_receiver/hidpp20.py
Original file line number Diff line number Diff line change
Expand Up @@ -1152,7 +1152,17 @@ 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)
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,
Expand All @@ -1161,44 +1171,34 @@ def write(self):
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
Expand All @@ -1210,9 +1210,10 @@ def __init__(self, **kwargs):

@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
Expand All @@ -1224,7 +1225,7 @@ def to_bytes(self, ID=None):
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)

Expand All @@ -1238,7 +1239,7 @@ def to_yaml(cls, dumper, data):
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)
Expand All @@ -1253,8 +1254,8 @@ def from_bytes(cls, bytes, options=None):
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
Expand Down
10 changes: 2 additions & 8 deletions lib/logitech_receiver/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1231,14 +1231,8 @@ def prepare_write(self, new_value, current_value=None):
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):
Expand Down
35 changes: 25 additions & 10 deletions lib/logitech_receiver/settings_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
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
Expand All @@ -32,6 +34,7 @@
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
Expand Down Expand Up @@ -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):
Expand All @@ -1452,10 +1463,14 @@ def build(cls, device):
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

Expand Down
91 changes: 90 additions & 1 deletion lib/solaar/ui/config_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
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
Expand Down Expand Up @@ -74,6 +75,21 @@ def _do_write(s, v, sb, key):
#


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):
Expand All @@ -93,7 +109,8 @@ def update(self):
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
Expand Down Expand Up @@ -510,6 +527,76 @@ def set_value(self, value):
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)


#
#
#
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def _data_files():
python_requires='>=3.7',
install_requires=[
'evdev (>= 1.1.2) ; platform_system=="Linux"',
'webcolors',
'pyudev (>= 0.13)',
'PyYAML (>= 3.12)',
'python-xlib (>= 0.27)',
Expand Down

0 comments on commit fdc4068

Please sign in to comment.