This commit is contained in:
Ken Sanislo 2026-07-16 23:20:41 -07:00 committed by GitHub
commit 6cf1c73d6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 420 additions and 28 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

@ -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
@ -257,6 +258,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

@ -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)
@ -3219,6 +3234,30 @@ 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
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
@ -3230,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 = {
@ -3250,6 +3303,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 +3320,7 @@ class LEDZoneSetting(settings.Setting):
saturation_field,
form_field,
direction_field,
cycle_field,
]
@classmethod
@ -3272,11 +3332,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
@ -3293,6 +3356,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")
@ -3744,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,
@ -4466,6 +4558,7 @@ SETTINGS: list[settings.Setting] = [
Backlight3,
LEDControl,
LEDZoneSetting,
LEDStartupAnimation,
RGBControl,
RGBEffectSetting,
PerKeyLighting,
@ -4498,6 +4591,7 @@ SETTINGS: list[settings.Setting] = [
HapticLevel,
PlayHapticWaveForm,
Sidetone,
BassTone,
Equalizer,
ADCPower,
HeadsetEcoMode,

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

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

View File

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

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

@ -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),
@ -281,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,
@ -291,7 +301,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(
@ -662,6 +672,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],
@ -1053,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

View File

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