diff --git a/docs/devices/G560 Gaming Speaker 0A78.txt b/docs/devices/G560 Gaming Speaker 0A78.txt new file mode 100644 index 00000000..d34ddaf0 --- /dev/null +++ b/docs/devices/G560 Gaming Speaker 0A78.txt @@ -0,0 +1,64 @@ +G560 Gaming Speaker + Device path : /dev/hidraw11 + USB id : 046d:0A78 + Codename : G560 Gaming Speaker + Kind : speaker + Protocol : HID++ 4.2 + Serial number: + Model ID: 000000000A78 + Unit ID: FFFFFFFF + 0: U1 22.04.B0370 + Supports 10 HID++ 2.0 features: + 0: ROOT {0000} V0 + 1: FEATURE SET {0001} + 2: DEVICE FW VERSION {0003} + Firmware: 0 U1 22.04.B0370 0A78 + Unit ID: FFFFFFFF Model ID: 000000000A78 Transport IDs: {'btid': '0000', 'btleid': '0000'} + 3: DEVICE NAME {0005} + Name: G560 Gaming Speaker + Kind: speaker + 4: COLOR LED EFFECTS {8070} + Zone 0 (Left Front): 5 effect(s) + [0] 0x00 Disabled caps 0x0000=none default 0ms params: — + [1] 0x01 Static caps 0x0000=none default 0ms params: color, ramp + [2] 0x03 Cycle caps 0xc005=color+period+fw default 1000ms params: period, intensity + [3] 0x07 Audio Visualizer caps 0x0067=color+fade+period++0x0060 default 1000ms params: cycle, color, period + [4] 0x0a Breathe caps 0xc105=color+period+fw++0x0100 default 60ms params: color, period, form, intensity + Zone 1 (Right Front): 5 effect(s) + [0] 0x00 Disabled caps 0x0000=none default 0ms params: — + [1] 0x01 Static caps 0x0000=none default 0ms params: color, ramp + [2] 0x03 Cycle caps 0xc005=color+period+fw default 1000ms params: period, intensity + [3] 0x07 Audio Visualizer caps 0x0067=color+fade+period++0x0060 default 1000ms params: cycle, color, period + [4] 0x0a Breathe caps 0xc105=color+period+fw++0x0100 default 60ms params: color, period, form, intensity + Zone 2 (Left Rear): 5 effect(s) + [0] 0x00 Disabled caps 0x0000=none default 0ms params: — + [1] 0x01 Static caps 0x0000=none default 0ms params: color, ramp + [2] 0x03 Cycle caps 0xc005=color+period+fw default 1000ms params: period, intensity + [3] 0x07 Audio Visualizer caps 0x0067=color+fade+period++0x0060 default 1000ms params: cycle, color, period + [4] 0x0a Breathe caps 0xc105=color+period+fw++0x0100 default 60ms params: color, period, form, intensity + Zone 3 (Right Rear): 5 effect(s) + [0] 0x00 Disabled caps 0x0000=none default 0ms params: — + [1] 0x01 Static caps 0x0000=none default 0ms params: color, ramp + [2] 0x03 Cycle caps 0xc005=color+period+fw default 1000ms params: period, intensity + [3] 0x07 Audio Visualizer caps 0x0067=color+fade+period++0x0060 default 1000ms params: cycle, color, period + [4] 0x0a Breathe caps 0xc105=color+period+fw++0x0100 default 60ms params: color, period, form, intensity + LED Control (saved): False + LED Control : False + LEDs Left Front (saved): !LEDEffectSetting {ID: 7, color: 0xff7800, cycle: 0, direction: 0, form: 0, intensity: 0, period: 5000, ramp: 0, saturation: 0, speed: 0} + LEDs Left Front : !LEDEffectSetting {ID: 7, color: 0xff6000, cycle: 0, period: 4839} + LEDs Right Front (saved): !LEDEffectSetting {ID: 7, color: 0xff7800, cycle: 0, direction: 0, form: 0, intensity: 0, period: 5000, ramp: 0, saturation: 0, speed: 0} + LEDs Right Front : !LEDEffectSetting {ID: 7, color: 0xff6000, cycle: 0, period: 4839} + LEDs Left Rear (saved): !LEDEffectSetting {ID: 7, color: 0xf6f5f4, cycle: 0, direction: 0, form: 0, intensity: 100, period: 5000, ramp: 0, saturation: 0, speed: 0} + LEDs Left Rear : !LEDEffectSetting {ID: 7, color: 0xffffff, cycle: 0, period: 4839} + LEDs Right Rear (saved): !LEDEffectSetting {ID: 7, color: 0xf6f5f4, cycle: 0, direction: 0, form: 0, intensity: 0, period: 5000, ramp: 0, saturation: 0, speed: 0} + LEDs Right Rear : !LEDEffectSetting {ID: 7, color: 0xffffff, cycle: 0, period: 4839} + 5: GKEY {8010} + Divert G and M Keys (saved): False + Divert G and M Keys : False + 6: EQUALIZER {8310} + 7: HEADSET OUT {8320} + 8: BRIGHTNESS CONTROL {8040} + Brightness Control (saved): 100 + Brightness Control : 100 + 9: unknown:8305 {0583} V0 + Battery status unavailable. diff --git a/lib/logitech_receiver/hidpp10_constants.py b/lib/logitech_receiver/hidpp10_constants.py index 3861bcd4..201a395b 100644 --- a/lib/logitech_receiver/hidpp10_constants.py +++ b/lib/logitech_receiver/hidpp10_constants.py @@ -43,6 +43,16 @@ DEVICE_KIND = NamedInts( headset=0x0D, # not from Logitech documentation remote_control=0x0E, # for compatibility with HID++ 2.0 receiver=0x0F, # for compatibility with HID++ 2.0 + # 0x10 and up: not HID++ 1.0 protocol values, only for compatibility with HID++ 2.0 + webcam=0x10, + steering_wheel=0x11, + dock=0x12, + speaker=0x13, + microphone=0x14, + illumination_light=0x15, + programmable_controller=0x16, + car_sim_pedals=0x17, + adapter=0x18, ) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index def630a2..768b7013 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -1170,11 +1170,16 @@ class LEDParam: form = "form" saturation = "saturation" direction = "direction" + cycle = "cycle" # NamedInts (not IntEnum) so the GTK ComboBoxText shows readable labels. LedRampChoice = common.NamedInts(Default=0, Yes=1, No=2) +# Audio Visualizer color mode: values >= 2 select unmapped non-pulsing +# sub-modes, so only the two known ones are exposed. +LedCycleChoices = common.NamedInts(Off=0, On=1) + LedFormChoices = common.NamedInts( Default=0, Sine=1, @@ -1211,6 +1216,7 @@ LEDParamSize = { LEDParam.form: 1, LEDParam.saturation: 1, LEDParam.direction: 1, + LEDParam.cycle: 1, } # Entry: [NamedInt, params, defaults, ranges] — trailing dicts optional. # ranges overrides a field's global min/max, e.g. period: (2, 200). @@ -1230,6 +1236,13 @@ LEDEffects = { {LEDParam.period: 6, LEDParam.direction: 9}, {LEDParam.period: 5000}, ], + # G560 factory default: pulses brightness with audio; cycle On + # self-cycles hue (color ignored), Off pulses the fixed color. + 0x07: [ + NamedInt(0x07, _("Audio Visualizer")), + {LEDParam.cycle: 0, LEDParam.color: 1, LEDParam.period: 5}, + {LEDParam.period: 5000}, + ], 0x08: [NamedInt(0x08, _("Boot")), {}], 0x09: [NamedInt(0x09, _("Demo")), {}], 0x0A: [ @@ -1350,13 +1363,23 @@ LEDZoneLocations[0x09] = _("Primary 4") LEDZoneLocations[0x0A] = _("Primary 5") LEDZoneLocations[0x0B] = _("Primary 6") +# Location codes mean different things per device kind; these override the +# mouse/keyboard table above, falling back to it for codes they don't name. +LEDZoneLocationsByKind = {"speaker": common.NamedInts()} +LEDZoneLocationsByKind["speaker"][0x01] = _("Left Front") +LEDZoneLocationsByKind["speaker"][0x02] = _("Right Front") +LEDZoneLocationsByKind["speaker"][0x03] = _("Left Rear") +LEDZoneLocationsByKind["speaker"][0x04] = _("Right Rear") + class LEDZoneInfo: # effects that a zone can do def __init__(self, feature, function, offset, effect_function, device, index): info = device.feature_request(feature, function, index, 0xFF, 0x00) self.location, self.count = struct.unpack("!HB", info[1 + offset : 4 + offset]) self.index = index - self.location = LEDZoneLocations[self.location] if LEDZoneLocations[self.location] else self.location + kind_locations = LEDZoneLocationsByKind.get(str(getattr(device, "kind", None))) + location = kind_locations[self.location] if kind_locations else None + self.location = location or LEDZoneLocations[self.location] or self.location self.effects = [] for i in range(0, self.count): self.effects.append(LEDEffectInfo(feature, effect_function, device, index, i)) diff --git a/lib/logitech_receiver/hidpp20_constants.py b/lib/logitech_receiver/hidpp20_constants.py index be0082c1..7b43795c 100644 --- a/lib/logitech_receiver/hidpp20_constants.py +++ b/lib/logitech_receiver/hidpp20_constants.py @@ -175,10 +175,11 @@ class SupportedFeature(IntEnum): OPERATING_RANGE = 0x8138 TRUE_FORCE = 0x8139 FFB_FILTER = 0x8140 - # Headsets + # Headsets / speakers SIDETONE = 0x8300 + BASS_TONE = 0x8305 EQUALIZER = 0x8310 - HEADSET_OUT = 0x8320 + JACK_DETECTION = 0x8320 # jack presence (was HEADSET_OUT) # Centurion core CENTURION_DEVICE_INFO = 0x0100 CENTURION_DEVICE_NAME = 0x0101 @@ -257,6 +258,18 @@ DEVICE_KIND = NamedInts( trackball=0x05, presenter=0x06, receiver=0x07, + headset=0x08, + webcam=0x09, + steering_wheel=0x0A, + joystick=0x0B, + gamepad=0x0C, + dock=0x0D, + speaker=0x0E, + microphone=0x0F, + illumination_light=0x10, + programmable_controller=0x11, + car_sim_pedals=0x12, + adapter=0x13, ) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index a3891b35..6efa002c 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -1587,6 +1587,16 @@ class Sidetone(settings.Setting): max_value = 100 +class BassTone(settings.Setting): + name = "bass_tone" + label = _("Bass Level") + description = _("Set subwoofer bass level.") + feature = _F.BASS_TONE + validator_class = settings_validator.RangeValidator + min_value = 0 + max_value = 100 + + class Equalizer(settings.RangeFieldSetting): name = "equalizer" label = _("Equalizer") @@ -1596,16 +1606,21 @@ class Equalizer(settings.RangeFieldSetting): keys_universe = [] class validator_class(settings_validator.PackedRangeValidator): + kind = settings.Kind.GRAPHIC_EQ + @classmethod def build(cls, setting_class, device): data = device.feature_request(_F.EQUALIZER, 0x00) if not data: return None - count, dbRange, _x, dbMin, dbMax = struct.unpack("!BBBBB", data[:5]) - if dbMin == 0: - dbMin = -dbRange - if dbMax == 0: - dbMax = dbRange + # dbMin/dbMax are signed; the G560 reports an asymmetric -20..+6 + count, dbRange, _x, dbMin, dbMax = struct.unpack("!BBBbb", data[:5]) + if dbMin == 0 and dbMax == 0: + dbMin, dbMax = -dbRange, dbRange + else: + # reported bounds are exclusive: the G560 NACKs writes at + # exactly dbMin/dbMax but accepts dbMin+1..dbMax-1 + dbMin, dbMax = dbMin + 1, dbMax - 1 map = common.NamedInts() for g in range((count + 6) // 7): freqs = device.feature_request(_F.EQUALIZER, 0x10, g * 7) @@ -3219,6 +3234,30 @@ class LEDControl(settings.Setting): if isinstance(self._value, int) and not isinstance(self._value, bool): self._value = self._value != 0 + def write(self, value, save=True): + result = super().write(value, save) + # Repaint saved zone effects after a fresh claim — firmware starts blank. + if value and result is not None: + for s in self._device.settings: + if s.name.startswith(LEDZoneSetting.name) and s._value is not None: + try: + s.write(s._value, save=False) + except Exception as e: + logger.warning("%s: post-claim repaint of %s failed: %s", self._device, s.name, e) + return result + + def apply(self): + # Off = leave the lighting alone on connect (don't send the release); + # another app may own it. Only an explicit On claim is applied. + try: + value = self.read(self.persist) + except Exception: + value = None + if value: + super().apply() + elif logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: LED control off — leaving lighting untouched on %s", self.name, self._device) + colors = special_keys.COLORS _LEDP = hidpp20.LEDParam @@ -3230,6 +3269,20 @@ class LEDZoneSetting(settings.Setting): label = _("LED Zone Effects") description = _("Set effect for LED Zone") + "\n" + _("LED Control needs to be enabled.") feature = _F.COLOR_LED_EFFECTS + gate_setting_name = LEDControl.name + + def apply(self): + # Skip when the control gate is off (SETTINGS order runs it first, so + # its _value is set) — leave the lighting to whatever owns it. + for s in self._device.settings: + if s.name == self.gate_setting_name: + if not s._value: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("%s: %s off — not applying on %s", self.name, s.name, self._device) + return + break + super().apply() + color_field = {"name": _LEDP.color, "kind": settings.Kind.COLOR, "label": _("Color")} speed_field = {"name": _LEDP.speed, "kind": settings.Kind.RANGE, "label": _("Speed"), "min": 0, "max": 255} period_field = { @@ -3250,6 +3303,12 @@ class LEDZoneSetting(settings.Setting): "label": _("Direction"), "choices": hidpp20.LedDirectionChoices, } + cycle_field = { + "name": _LEDP.cycle, + "kind": settings.Kind.CHOICE, + "label": _("Color Cycling"), + "choices": hidpp20.LedCycleChoices, + } # Per-widget visibility driven by LEDEffects[ID][1]; RGBEffectSetting # overrides this list to drop ramp/form on 0x8071. possible_fields = [ @@ -3261,6 +3320,7 @@ class LEDZoneSetting(settings.Setting): saturation_field, form_field, direction_field, + cycle_field, ] @classmethod @@ -3272,11 +3332,14 @@ class LEDZoneSetting(settings.Setting): prefix = common.int2bytes(zone.index, 1) rw = settings.FeatureRW(cls.feature, read_fnid, write_fnid, prefix=prefix, suffix=suffix) validator = settings_validator.HeteroValidator( - data_class=hidpp20.LEDEffectSetting, options=zone.effects, readable=infos.readable and read_fnid is not None + data_class=hidpp20.LEDEffectSetting, + options=zone.effects, + readable=infos.readable and read_fnid is not None, + read_skip_byte_count=len(prefix), # GetEffect echoes the zone index ) 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(zone.location) choices = [hidpp20.LEDEffects[e.ID][0] for e in zone.effects if e.ID in hidpp20.LEDEffects] ID_field = {"name": "ID", "kind": settings.Kind.CHOICE, "label": None, "choices": choices} setting.possible_fields = [ID_field] + possible_fields @@ -3293,6 +3356,34 @@ class LEDZoneSetting(settings.Setting): return cls.setup(device, 0xE0, 0x30, b"") +class LEDStartupAnimation(settings.Setting): + """Startup effect on/off via 0x8070 NvConfig (funcs 4/5, cap 0x0001, V1+). + + A pure toggle with no color payload; GetNvConfig echoes the capability ID + ahead of the value. + """ + + name = "led_startup_animation" + label = _("Startup Animation") + description = ( + _("Firmware-played animation when the device powers on.") + "\n" + _("Setting persists on the device (non-volatile).") + ) + feature = _F.COLOR_LED_EFFECTS + min_version = 1 + rw_options = {"read_fnid": 0x40, "write_fnid": 0x50, "prefix": b"\x00\x01"} # NvConfig capability 0x0001 + validator_class = settings_validator.BooleanValidator + validator_options = {"true_value": 0x01, "false_value": 0x02, "read_skip_byte_count": 2} + + @classmethod + def build(cls, device): + try: # gate on the device actually supporting the capability + if not device.feature_request(cls.feature, 0x40, b"\x00\x01"): + return None + except exceptions.FeatureCallError: + return None + return super().build(device) + + class RGBControl(settings.Setting): name = "rgb_control" label = _("LED Control") @@ -3744,6 +3835,7 @@ class RGBEffectSetting(LEDZoneSetting): label = _("LED Zone Effects") description = _("Set effect for LED Zone") + "\n" + _("LED Control needs to be enabled.") feature = _F.RGB_EFFECTS + gate_setting_name = RGBControl.name # 0x8071 firmware-fixes ramp/form bytes; drop those widgets here. possible_fields = [ LEDZoneSetting.color_field, @@ -4466,6 +4558,7 @@ SETTINGS: list[settings.Setting] = [ Backlight3, LEDControl, LEDZoneSetting, + LEDStartupAnimation, RGBControl, RGBEffectSetting, PerKeyLighting, @@ -4498,6 +4591,7 @@ SETTINGS: list[settings.Setting] = [ HapticLevel, PlayHapticWaveForm, Sidetone, + BassTone, Equalizer, ADCPower, HeadsetEcoMode, diff --git a/lib/logitech_receiver/settings_validator.py b/lib/logitech_receiver/settings_validator.py index 5e6d2eff..d19dd43a 100644 --- a/lib/logitech_receiver/settings_validator.py +++ b/lib/logitech_receiver/settings_validator.py @@ -583,17 +583,18 @@ class HeteroValidator(Validator): def build(cls, setting_class, device, **kwargs): return cls(**kwargs) - def __init__(self, data_class=None, options=None, readable=True): + def __init__(self, data_class=None, options=None, readable=True, read_skip_byte_count=0): # options=None for purely host-side settings — data_class handles bytes[0] as the ID. assert data_class is not None self.data_class = data_class self.options = options self.readable = readable + self.read_skip_byte_count = read_skip_byte_count # e.g. request-echo bytes before the payload 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) + reply_value = self.data_class.from_bytes(reply_bytes[self.read_skip_byte_count :], options=self.options) return reply_value def prepare_write(self, new_value, current_value=None): diff --git a/lib/solaar/cli/show.py b/lib/solaar/cli/show.py index 0f41b3f1..0ed3cd2c 100644 --- a/lib/solaar/cli/show.py +++ b/lib/solaar/cli/show.py @@ -239,11 +239,9 @@ def _print_device(dev, num=None): # For centurion child, skip dongle features (already shown on the receiver) if is_centurion_child and not in_sub_device: continue - if isinstance(feature, str): - feature_bytes = bytes.fromhex(feature[-4:]) - else: - feature_bytes = feature.to_bytes(2, byteorder="little") - feature_int = int.from_bytes(feature_bytes, byteorder="little") + # ROOT.getFeature takes the feature ID big-endian. + feature_int = int(feature[-4:], 16) if isinstance(feature, str) else int(feature) + feature_bytes = feature_int.to_bytes(2, byteorder="big") display_name = feature if is_centurion_child and in_sub_device: # Use cached version — skip slow bridge ROOT queries diff --git a/lib/solaar/ui/config_panel.py b/lib/solaar/ui/config_panel.py index ebc3209b..8526a731 100644 --- a/lib/solaar/ui/config_panel.py +++ b/lib/solaar/ui/config_panel.py @@ -816,6 +816,8 @@ _icons_allowables = {v: k for k, v in _allowables_icons.items()} # the zone index (rgb_zone_1, rgb_zone_2, ...). _SW_CONTROL_DEPENDENT_NAMES = ("rgb_idle_timeout", "rgb_idle_effect", "rgb_sleep_timeout") _SW_CONTROL_DEPENDENT_PREFIXES = ("rgb_zone_",) +# 0x8070 analog of rgb_control/rgb_zone_: led_zone_* rows need led_control on. +_LED_CONTROL_DEPENDENT_PREFIXES = ("led_zone_",) # headset_led_control = whether Solaar holds the live-coloring claim (off lets # another app drive the LEDs). The 0x0620 per-zone painting and the 0x0621 # onboard effect are both live LED control, so both need the claim; per-zone @@ -846,6 +848,25 @@ def _sw_control_blocked(device): return value not in (True, 3) +def _led_control_blocked(device): + """True when the 0x8070 LED Control is off (Device/firmware mode) — its + led_zone_* rows have no effect. Reads setting._value first, then the + persister; accepts the current bool or a legacy int 0/1.""" + persister = getattr(device, "persister", None) + if persister is None: + return False + value = None + for s in getattr(device, "settings", []) or []: + if s.name == "led_control": + value = s._value + break + if value is None: + value = persister.get("led_control") + if value is None: + return False + return value not in (True, 1) + + def _headset_led_blocked(device): """True when the headset's LED Control is off (Device/firmware mode). Reads setting._value first, then the persister; accepts the current bool @@ -919,6 +940,8 @@ def _gate_blocks(device, name): completions can't undo the grey-out when their callbacks land later.""" if name in _SW_CONTROL_DEPENDENT_NAMES or any(name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES): return _sw_control_blocked(device) + if any(name.startswith(p) for p in _LED_CONTROL_DEPENDENT_PREFIXES): + return _led_control_blocked(device) if name == "per-key-lighting": return _sw_control_blocked(device) or _zone_effect_blocks_perkey(device) if name in _HEADSET_LED_DEPENDENT_NAMES: @@ -943,6 +966,7 @@ def _apply_rgb_gates(device): if ( name in _SW_CONTROL_DEPENDENT_NAMES or any(name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES) + or any(name.startswith(p) for p in _LED_CONTROL_DEPENDENT_PREFIXES) or name == "per-key-lighting" or name in _HEADSET_LED_DEPENDENT_NAMES ): @@ -993,9 +1017,13 @@ def _change_click(button, sbox): # The lock icon on rgb_control, any zone, per-key, or headset_led_control # can change whether a dependent row is functional — re-evaluate the gate. name = sbox.setting.name - if name in ("rgb_control", "per-key-lighting", "headset_led_control", "headset-onboard-effect") or name.startswith( - "rgb_zone_" - ): + if name in ( + "rgb_control", + "led_control", + "per-key-lighting", + "headset_led_control", + "headset-onboard-effect", + ) or name.startswith(("rgb_zone_", "led_zone_")): _apply_rgb_gates(sbox.setting._device) return True @@ -1099,9 +1127,13 @@ def _update_setting_item(sbox, value, is_online=True, sensitive=True, null_okay= logger.warning("%s: error setting control value (%s): %s", sbox.setting.name, sbox.setting._device, repr(e)) sbox._control.set_sensitive(sensitive is True and can_function) _change_icon(sensitive, sbox._change_icon) - # rgb_control / rgb_zone_* gate per-key; headset_led_control and the - # headset-onboard-effect gate the per-zone row — re-evaluate on a change. - if name in ("rgb_control", "headset_led_control", "headset-onboard-effect") or name.startswith("rgb_zone_"): + # A control/zone/onboard-effect change can flip a dependent row's gate. + if name in ( + "rgb_control", + "led_control", + "headset_led_control", + "headset-onboard-effect", + ) or name.startswith(("rgb_zone_", "led_zone_")): _apply_rgb_gates(sbox.setting._device) diff --git a/lib/solaar/ui/icons.py b/lib/solaar/ui/icons.py index e5799b1b..7d5b4d22 100644 --- a/lib/solaar/ui/icons.py +++ b/lib/solaar/ui/icons.py @@ -136,6 +136,12 @@ def device_icon_set(name="_", kind=None): icon_set += ("input-mouse",) elif str(kind) == "headset": icon_set += ("audio-headphones", "audio-headset") + elif str(kind) == "speaker": + icon_set += ("audio-speakers",) + elif str(kind) == "microphone": + icon_set += ("audio-input-microphone",) + elif str(kind) == "webcam": + icon_set += ("camera-web",) icon_set += (f"input-{str(kind)}",) # icon_set += (name.replace(' ', '-'),) _ICON_SETS[name] = icon_set diff --git a/tests/logitech_receiver/fake_hidpp.py b/tests/logitech_receiver/fake_hidpp.py index 3e4dc59b..910fd2fe 100644 --- a/tests/logitech_receiver/fake_hidpp.py +++ b/tests/logitech_receiver/fake_hidpp.py @@ -264,6 +264,11 @@ zone_responses_2 = [ # responses for RGB EFFECTS Response("0000000300040005", 0x0700, "000000"), Response("0001000200080009", 0x0700, "000100"), ] +zone_responses_speaker = [ # COLOR LED EFFECTS zone with Audio Visualizer (G560) + Response("00000102", 0x0710, "00FF00"), + Response("0000000300040005", 0x0720, "000000"), + Response("0001000700040005", 0x0720, "000100"), +] effects_responses_1 = [Response("0100000001", 0x0700)] + zone_responses_1 effects_responses_2 = [Response("FFFF0100000001", 0x0700, "FFFF00")] + zone_responses_2 diff --git a/tests/logitech_receiver/test_hidpp20_complex.py b/tests/logitech_receiver/test_hidpp20_complex.py index 03ce788d..116db277 100644 --- a/tests/logitech_receiver/test_hidpp20_complex.py +++ b/tests/logitech_receiver/test_hidpp20_complex.py @@ -692,6 +692,19 @@ def test_LEDEffectSetting(hex, ID, color, speed, period, intensity, ramp, form): assert yaml.safe_load(str(setting)) == setting +def test_LEDEffectSetting_audio_visualizer(): + # G560 factory readback: cycle on, BE16 period 0x1388 (5000 ms) + byt = bytes.fromhex("0701000000001388000000") + setting = hidpp20.LEDEffectSetting.from_bytes(byt) + + assert setting.ID == common.NamedInt(0x7, "Audio Visualizer") + assert setting.cycle == 1 + assert setting.color == 0 + assert setting.period == 0x1388 # regression: mis-split as intensity/period pre-0x07 + assert setting.to_bytes() == byt + assert yaml.safe_load(str(setting)) == setting + + @pytest.mark.parametrize( "feature, function, response, ID, capabilities, period", [ @@ -744,6 +757,17 @@ def test_LEDZoneInfo(feature, function, offset, effect_function, responses, inde assert zone.effects[1].ID == id_1 +def test_LEDZoneInfo_speaker_locations(): + feature = hidpp20_constants.SupportedFeature.COLOR_LED_EFFECTS + device = fake_hidpp.Device(feature=feature, responses=fake_hidpp.zone_responses_1, offset=0x07) + device.kind = "speaker" + + zone = hidpp20.LEDZoneInfo(feature, 0x10, 0, 0x20, device, 0) + + assert zone.location == 1 + assert str(zone.location) == "Left Front" + + @pytest.mark.parametrize( "responses, setting, expected_command", [ @@ -754,6 +778,11 @@ def test_LEDZoneInfo(feature, function, offset, effect_function, responses, inde hidpp20.LEDEffectSetting(ID=0xB, color=0x808080, period=0x20), "000180808000002000000000", ], + [ + fake_hidpp.zone_responses_speaker, + hidpp20.LEDEffectSetting(ID=0x7, cycle=1, color=0, period=0x1388), + "000101000000001388000000", + ], ], ) def test_LEDZoneInfo_to_command(responses, setting, expected_command): diff --git a/tests/logitech_receiver/test_hidpp20_simple.py b/tests/logitech_receiver/test_hidpp20_simple.py index b8f88896..82b536f2 100644 --- a/tests/logitech_receiver/test_hidpp20_simple.py +++ b/tests/logitech_receiver/test_hidpp20_simple.py @@ -52,14 +52,23 @@ def test_get_ids(): assert tid_map == {"btid": "1234", "wpid": "5678", "usbid": "9ABC"} -def test_get_kind(): - responses = [fake_hidpp.Response("00", 0x0420)] +@pytest.mark.parametrize( + "response, name, value", + [ + ("00", "keyboard", 0x01), + ("08", "headset", 0x0D), + ("0E", "speaker", 0x13), + ("13", "adapter", 0x18), + ], +) +def test_get_kind(response, name, value): + responses = [fake_hidpp.Response(response, 0x0420)] device = fake_hidpp.Device(responses=responses, feature=SupportedFeature.DEVICE_NAME) result = _hidpp20.get_kind(device) - assert result == "keyboard" - assert result == 1 + assert result == name + assert result == value def test_get_name(): diff --git a/tests/logitech_receiver/test_setting_templates.py b/tests/logitech_receiver/test_setting_templates.py index 01bb52f4..32305306 100644 --- a/tests/logitech_receiver/test_setting_templates.py +++ b/tests/logitech_receiver/test_setting_templates.py @@ -271,6 +271,11 @@ simple_tests = [ fake_hidpp.Response("05", 0x0400), fake_hidpp.Response("0A", 0x0410, "0A"), ), + Setup( + FeatureTest(settings_templates.BassTone, 0x46, 0x50), + fake_hidpp.Response("46", 0x0400), + fake_hidpp.Response("50", 0x0410, "50"), + ), Setup( FeatureTest(settings_templates.ADCPower, 5, 0xA, version=0x03), fake_hidpp.Response("05", 0x0410), @@ -281,6 +286,11 @@ simple_tests = [ fake_hidpp.Response("00", 0x0470), fake_hidpp.Response("01", 0x0480, "01"), ), + Setup( # NvConfig startup toggle: GetNvConfig echoes the cap ID; 0x01 on / 0x02 off + FeatureTest(settings_templates.LEDStartupAnimation, False, True, version=5), + fake_hidpp.Response("00010200000000000000", 0x0440, "0001"), + fake_hidpp.Response("000101", 0x0450, "000101"), + ), Setup( FeatureTest( settings_templates.LEDZoneSetting, @@ -291,7 +301,7 @@ simple_tests = [ fake_hidpp.Response("00000102", 0x0410, "00FF00"), fake_hidpp.Response("0000000300040005", 0x0420, "000000"), fake_hidpp.Response("0001000B00080009", 0x0420, "000100"), - fake_hidpp.Response("000000000000010050", 0x04E0, "00"), + fake_hidpp.Response("00000000000000010050", 0x04E0, "00"), # GetEffect echoes the zone index fake_hidpp.Response("000000000000000101500000", 0x0430, "000000000000000101500000"), ), Setup( @@ -662,6 +672,15 @@ key_tests = [ fake_hidpp.Response("E010", 0x0430, "02E010"), fake_hidpp.Response("E018", 0x0430, "02E018"), ), + Setup( # signed asymmetric dB range as reported by the G560 (-20..+6, bounds exclusive) + FeatureTest(settings_templates.Equalizer, {0: -4, 1: 2}, {1: 5}, 2), + [-19, 5], + fake_hidpp.Response("021A00EC06", 0x0400), + fake_hidpp.Response("0000200040", 0x0410, "00"), + fake_hidpp.Response("FC02", 0x0420, "00"), + fake_hidpp.Response("FC02", 0x0430, "02FC02"), + fake_hidpp.Response("FC05", 0x0430, "02FC05"), + ), Setup( # HeadsetOnboardEQ: 2 bands, 128Hz/-2dB/Q10 and 256Hz/+3dB/Q10 FeatureTest(settings_templates.HeadsetOnboardEQ, {0: -2, 1: 3}, {1: 5}, 2), [-12, 12], @@ -1053,3 +1072,49 @@ def test_HeadsetOnboardEffect_absent_animated_fields_seed_defaults(): assert effect.intensity == 100 assert effect.period == 5000 + + +def _led_lighting_device(): + responses = [ + fake_hidpp.Response("0100000001", 0x0400), + fake_hidpp.Response("00000102", 0x0410, "00FF00"), + fake_hidpp.Response("0000000300040005", 0x0420, "000000"), + fake_hidpp.Response("0001000B00080009", 0x0420, "000100"), + fake_hidpp.Response("01", 0x0480, "01"), + fake_hidpp.Response("000000000000000020500000", 0x0430, "000000000000000020500000"), + ] + device = fake_hidpp.Device(responses=responses, feature=hidpp20_constants.SupportedFeature.COLOR_LED_EFFECTS, offset=4) + control = settings_templates.check_feature(device, settings_templates.LEDControl) + zones = settings_templates.check_feature(device, settings_templates.LEDZoneSetting) + device.settings = [control] + zones + return device + + +def test_lighting_apply_led_control_off_leaves_lighting_alone(mocker): + """With led_control persisted off, connect-time apply must not send any + lighting traffic — the device or another app (e.g. OpenRGB) owns it.""" + device = _led_lighting_device() + device.persister["led_control"] = False + device.persister[device.settings[1].name] = hidpp20.LEDEffectSetting(ID=3, period=0x20, intensity=0x50) + spy = mocker.spy(device, "request") + + for s in device.settings: + s.apply() + + assert spy.call_count == 0 + + +def test_lighting_apply_led_control_on_claims_and_repaints(mocker): + """With led_control persisted on, apply claims SW control and pushes the + saved zone effect.""" + device = _led_lighting_device() + device.persister["led_control"] = True + device.persister[device.settings[1].name] = hidpp20.LEDEffectSetting(ID=3, period=0x20, intensity=0x50) + spy = mocker.spy(device, "request") + + for s in device.settings: + s.apply() + + request_ids = [c.args[0] for c in spy.call_args_list] + assert 0x0480 in request_ids # SetSWControl claim + assert 0x0430 in request_ids # zone effect write diff --git a/tests/solaar/ui/test_config_panel_gates.py b/tests/solaar/ui/test_config_panel_gates.py new file mode 100644 index 00000000..2efba909 --- /dev/null +++ b/tests/solaar/ui/test_config_panel_gates.py @@ -0,0 +1,43 @@ +from types import SimpleNamespace + +from solaar.ui import config_panel + + +def _device(settings, persister): + return SimpleNamespace(settings=settings, persister=persister) + + +def _setting(name, value): + return SimpleNamespace(name=name, _value=value) + + +def test_led_control_blocked_reads_setting_value_first(): + device = _device([_setting("led_control", False)], {"led_control": True}) + assert config_panel._led_control_blocked(device) is True + + device = _device([_setting("led_control", True)], {"led_control": False}) + assert config_panel._led_control_blocked(device) is False + + +def test_led_control_blocked_falls_back_to_persister(): + device = _device([_setting("led_control", None)], {"led_control": False}) + assert config_panel._led_control_blocked(device) is True + + +def test_led_control_blocked_unknown_does_not_block(): + device = _device([], {}) + assert config_panel._led_control_blocked(device) is False + + +def test_gate_blocks_led_zone_follows_led_control(): + off = _device([_setting("led_control", False), _setting("led_zone_1", None)], {}) + assert config_panel._gate_blocks(off, "led_zone_1") is True + + on = _device([_setting("led_control", True), _setting("led_zone_1", None)], {}) + assert config_panel._gate_blocks(on, "led_zone_1") is False + + +def test_gate_blocks_rgb_zone_unaffected_by_led_control(): + # rgb_zone_ still keys off rgb_control, not led_control + device = _device([_setting("led_control", False), _setting("rgb_control", True), _setting("rgb_zone_1", None)], {}) + assert config_panel._gate_blocks(device, "rgb_zone_1") is False