Merge 00a6a59f9e into 5e4ae7261e
This commit is contained in:
commit
6cf1c73d6d
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue