From 38b5665e51eb46a245229e22eff28cd72bf2dcb2 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Thu, 16 Jul 2026 19:46:36 -0700 Subject: [PATCH] Add support for the G560 Gaming Speaker and speaker devices generally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recognize all HID++ 2.0 device types: the x0005 DeviceName type table stopped at receiver (7), so newer types resolved to no kind at all. Add the remaining official types (the G560 reports 14 = speaker) with matching HID++ 1.0 compatibility values, and themed icons for speaker/microphone/webcam. Name LED zone locations by device kind: the wire location codes are physical-zone handles whose meaning depends on the device kind — the existing table (Primary/Logo/Left Side/Right Side) matches mice and keyboards. On speakers, codes 1-4 denote the left front, right front, left rear and right rear lamps. Setting names derive from the numeric code and are unchanged, so existing configurations keep working. Add the Audio Visualizer LED effect (0x07), the G560's factory default: pulses lamp brightness with the audio signal. Wire layout (hardware-derived): control byte at offset 0 (0 = pulse a fixed color, 1 = self-cycle hue), RGB at 1-3, BE16 period in ms at 5-6. Control values >= 2 select unmapped non-pulsing sub-modes and are not exposed. The effect has no working intensity parameter; overall brightness is governed by the separate BRIGHTNESS CONTROL (0x8040) feature. Without a param table the effect displayed as Unknown(0x07) and saved settings mis-split the period across the Cycle layout. Strip the request echo from LED zone effect reads: GetEffect (0x8070 fn 0xE0) replies with the zone index echoed ahead of the effect slot and parameters, but the reply was parsed as if it began with the slot, shifting every readback one byte (only accidentally right when the zone index happened to match the slot). HeteroValidator gains a read_skip_byte_count option. Repaint saved zone effects when LED Control is re-enabled: the firmware starts a SW-control session blank, so without this the claim looks like it did nothing (mirrors RGBControl on 0x8071). All changes verified against a live G560 (046D:0A78); device scan included. --- docs/devices/G560 Gaming Speaker 0A78.txt | 64 +++++++++++++++++++ lib/logitech_receiver/hidpp10_constants.py | 10 +++ lib/logitech_receiver/hidpp20.py | 25 +++++++- lib/logitech_receiver/hidpp20_constants.py | 12 ++++ lib/logitech_receiver/settings_templates.py | 26 +++++++- lib/logitech_receiver/settings_validator.py | 5 +- lib/solaar/ui/icons.py | 6 ++ tests/logitech_receiver/fake_hidpp.py | 5 ++ .../logitech_receiver/test_hidpp20_complex.py | 29 +++++++++ .../logitech_receiver/test_hidpp20_simple.py | 17 +++-- .../test_setting_templates.py | 2 +- 11 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 docs/devices/G560 Gaming Speaker 0A78.txt 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(