From 38b5665e51eb46a245229e22eff28cd72bf2dcb2 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Thu, 16 Jul 2026 19:46:36 -0700 Subject: [PATCH 1/5] 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( From 0cc048cdf6915dba54598565c8b4281a89965d48 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Thu, 16 Jul 2026 20:20:35 -0700 Subject: [PATCH 2/5] Add G560 audio features: bass level, working equalizer, jack detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the BassTone feature (0x8305) as a 0-100 slider — subwoofer level on the G560, hardware-verified. Fix the Equalizer setting (0x8310), which never built on devices reporting signed dB limits: getEQInfo dbMin/dbMax are signed (the G560 reports -20..+6) but were unpacked unsigned, tripping the validator's min/max assertion. The reported bounds are also exclusive — the G560 NACKs writes at exactly dbMin/dbMax — so present dbMin+1..dbMax-1. Render with the vertical-slider graphic EQ panel. Note: on the G560 the stored curve round-trips but has no audible effect on USB playback; whether it shapes the aux input path is still unknown. Rename HEADSET_OUT (0x8320) to JACK_DETECTION: the feature reports 3.5mm jack presence (getJackStatus statuses/available bitmasks), it is not an output-mode control. Fix solaar show sending little-endian feature IDs to ROOT.getFeature, which silently dropped the flags/version column for every non-palindromic feature ID and displayed unknown features byte-swapped (the G560's 0x8305 rendered as "unknown:8305 {0583}"). --- docs/devices/G560 Gaming Speaker 0A78.txt | 6 ++--- lib/logitech_receiver/hidpp20_constants.py | 5 ++-- lib/logitech_receiver/settings_templates.py | 26 +++++++++++++++---- lib/solaar/cli/show.py | 8 +++--- .../test_setting_templates.py | 14 ++++++++++ 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/docs/devices/G560 Gaming Speaker 0A78.txt b/docs/devices/G560 Gaming Speaker 0A78.txt index 51adf771..d34ddaf0 100644 --- a/docs/devices/G560 Gaming Speaker 0A78.txt +++ b/docs/devices/G560 Gaming Speaker 0A78.txt @@ -4,12 +4,12 @@ G560 Gaming Speaker Codename : G560 Gaming Speaker Kind : speaker Protocol : HID++ 4.2 - Serial number: + Serial number: Model ID: 000000000A78 Unit ID: FFFFFFFF 0: U1 22.04.B0370 Supports 10 HID++ 2.0 features: - 0: ROOT {0000} V0 + 0: ROOT {0000} V0 1: FEATURE SET {0001} 2: DEVICE FW VERSION {0003} Firmware: 0 U1 22.04.B0370 0A78 @@ -60,5 +60,5 @@ G560 Gaming Speaker 8: BRIGHTNESS CONTROL {8040} Brightness Control (saved): 100 Brightness Control : 100 - 9: unknown:8305 {0583} V0 + 9: unknown:8305 {0583} V0 Battery status unavailable. diff --git a/lib/logitech_receiver/hidpp20_constants.py b/lib/logitech_receiver/hidpp20_constants.py index ce13db72..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 diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 6f803a40..cd46a604 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) @@ -4520,6 +4535,7 @@ SETTINGS: list[settings.Setting] = [ HapticLevel, PlayHapticWaveForm, Sidetone, + BassTone, Equalizer, ADCPower, HeadsetEcoMode, 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/tests/logitech_receiver/test_setting_templates.py b/tests/logitech_receiver/test_setting_templates.py index aa4f4c48..28fcd826 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), @@ -662,6 +667,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], From bdc17e9fb5253c66abb7a30a1b4ebf478e73f0d3 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Thu, 16 Jul 2026 20:52:13 -0700 Subject: [PATCH 3/5] Add startup animation toggle for COLOR_LED_EFFECTS devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0x8070 V1+ NvConfig (GetNvConfig/SetNvConfig, funcs 4/5) carries a startup-effect toggle under capability 0x0001 — a pure on/off switch (0x01/0x02) with no color payload, unlike the 0x8071 NvConfig boot effects; the boot color comes from the NV-persisted zone effect. GetNvConfig echoes the capability ID ahead of the value. Wire format captured from G HUB driving a G560 (usbmon) and the round-trip hardware-verified. Build probes the capability and suppresses the setting on devices that NACK it. The G560's only other NvConfig capability is 0x0002, which on 0x8071 devices causes a firmware animation-loop lockup — deliberately not exposed. --- lib/logitech_receiver/settings_templates.py | 29 +++++++++++++++++++ .../test_setting_templates.py | 5 ++++ 2 files changed, 34 insertions(+) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index cd46a604..0139001c 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -3330,6 +3330,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") @@ -4503,6 +4531,7 @@ SETTINGS: list[settings.Setting] = [ Backlight3, LEDControl, LEDZoneSetting, + LEDStartupAnimation, RGBControl, RGBEffectSetting, PerKeyLighting, diff --git a/tests/logitech_receiver/test_setting_templates.py b/tests/logitech_receiver/test_setting_templates.py index 28fcd826..02a855fe 100644 --- a/tests/logitech_receiver/test_setting_templates.py +++ b/tests/logitech_receiver/test_setting_templates.py @@ -286,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, From 3b76be9abb7238566d376f60235ff54d8a26464c Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Thu, 16 Jul 2026 21:08:21 -0700 Subject: [PATCH 4/5] Leave lighting alone on connect when LED control is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect-time apply pushed persisted zone effects and the LED-control release write even when the user had LED control switched off — with the G560 (0x8070) accepting zone writes without a SW-control claim, Solaar repainted lighting it didn't own on every startup, stomping the device's onboard effect or any other controller (e.g. OpenRGB). Off now doubles as "leave the lighting alone": LEDControl.apply skips the wire entirely unless the persisted value is an explicit claim, and zone-effect applies follow their control gate (led_control for 0x8070, rgb_control for 0x8071 — the same gate PerKeyLighting already enforces). When the gate is on, the claim itself repaints the saved zone effects, matching the RGBControl claim flow. --- lib/logitech_receiver/settings_templates.py | 27 +++++++++++ .../test_setting_templates.py | 46 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 0139001c..6efa002c 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -3246,6 +3246,18 @@ class LEDControl(settings.Setting): 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 @@ -3257,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 = { @@ -3809,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, diff --git a/tests/logitech_receiver/test_setting_templates.py b/tests/logitech_receiver/test_setting_templates.py index 02a855fe..32305306 100644 --- a/tests/logitech_receiver/test_setting_templates.py +++ b/tests/logitech_receiver/test_setting_templates.py @@ -1072,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 From 00a6a59f9ebc457fd70aea7f61eda935a23cc199 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Thu, 16 Jul 2026 22:27:17 -0700 Subject: [PATCH 5/5] Grey out LED zone rows when LED Control is off (0x8070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0x8071 path already greys rgb_zone_* rows (and per-key) when rgb_control is off, so the user sees that those settings have no effect in Device mode. The 0x8070 zone settings had no such gate: led_zone_* rows stayed active even with led_control off, implying writes that go nowhere. Add the 0x8070 analog — led_zone_* rows gate on led_control, mirroring rgb_zone_* / rgb_control — wired into the same three points: initial _apply_rgb_gates, the toggle-time re-gate, and the async-read guard in _update_setting_item so a late read can't un-grey a blocked row. --- lib/solaar/ui/config_panel.py | 44 +++++++++++++++++++--- tests/solaar/ui/test_config_panel_gates.py | 43 +++++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 tests/solaar/ui/test_config_panel_gates.py 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/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