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:
parent
5e4ae7261e
commit
38b5665e51
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue