Add support for the G560 Gaming Speaker and speaker devices generally

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.
This commit is contained in:
Ken Sanislo 2026-07-16 19:46:36 -07:00
parent 5e4ae7261e
commit 38b5665e51
11 changed files with 191 additions and 10 deletions

View File

@ -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.

View File

@ -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,
)

View File

@ -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))

View File

@ -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,
)

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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():

View File

@ -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(