diff --git a/docs/devices/G560 Gaming Speaker 0A78.txt b/docs/devices/G560 Gaming Speaker 0A78.txt new file mode 100644 index 00000000..51adf771 --- /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..ce13db72 100644 --- a/lib/logitech_receiver/hidpp20_constants.py +++ b/lib/logitech_receiver/hidpp20_constants.py @@ -257,6 +257,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..6f803a40 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -3219,6 +3219,18 @@ 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 + colors = special_keys.COLORS _LEDP = hidpp20.LEDParam @@ -3250,6 +3262,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 +3279,7 @@ class LEDZoneSetting(settings.Setting): saturation_field, form_field, direction_field, + cycle_field, ] @classmethod @@ -3272,11 +3291,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 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/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..aa4f4c48 100644 --- a/tests/logitech_receiver/test_setting_templates.py +++ b/tests/logitech_receiver/test_setting_templates.py @@ -291,7 +291,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(