From 0cc048cdf6915dba54598565c8b4281a89965d48 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Thu, 16 Jul 2026 20:20:35 -0700 Subject: [PATCH] Add G560 audio features: bass level, working equalizer, jack detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the BassTone feature (0x8305) as a 0-100 slider — subwoofer level on the G560, hardware-verified. Fix the Equalizer setting (0x8310), which never built on devices reporting signed dB limits: getEQInfo dbMin/dbMax are signed (the G560 reports -20..+6) but were unpacked unsigned, tripping the validator's min/max assertion. The reported bounds are also exclusive — the G560 NACKs writes at exactly dbMin/dbMax — so present dbMin+1..dbMax-1. Render with the vertical-slider graphic EQ panel. Note: on the G560 the stored curve round-trips but has no audible effect on USB playback; whether it shapes the aux input path is still unknown. Rename HEADSET_OUT (0x8320) to JACK_DETECTION: the feature reports 3.5mm jack presence (getJackStatus statuses/available bitmasks), it is not an output-mode control. Fix solaar show sending little-endian feature IDs to ROOT.getFeature, which silently dropped the flags/version column for every non-palindromic feature ID and displayed unknown features byte-swapped (the G560's 0x8305 rendered as "unknown:8305 {0583}"). --- docs/devices/G560 Gaming Speaker 0A78.txt | 6 ++--- lib/logitech_receiver/hidpp20_constants.py | 5 ++-- lib/logitech_receiver/settings_templates.py | 26 +++++++++++++++---- lib/solaar/cli/show.py | 8 +++--- .../test_setting_templates.py | 14 ++++++++++ 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/docs/devices/G560 Gaming Speaker 0A78.txt b/docs/devices/G560 Gaming Speaker 0A78.txt index 51adf771..d34ddaf0 100644 --- a/docs/devices/G560 Gaming Speaker 0A78.txt +++ b/docs/devices/G560 Gaming Speaker 0A78.txt @@ -4,12 +4,12 @@ G560 Gaming Speaker Codename : G560 Gaming Speaker Kind : speaker Protocol : HID++ 4.2 - Serial number: + Serial number: Model ID: 000000000A78 Unit ID: FFFFFFFF 0: U1 22.04.B0370 Supports 10 HID++ 2.0 features: - 0: ROOT {0000} V0 + 0: ROOT {0000} V0 1: FEATURE SET {0001} 2: DEVICE FW VERSION {0003} Firmware: 0 U1 22.04.B0370 0A78 @@ -60,5 +60,5 @@ G560 Gaming Speaker 8: BRIGHTNESS CONTROL {8040} Brightness Control (saved): 100 Brightness Control : 100 - 9: unknown:8305 {0583} V0 + 9: unknown:8305 {0583} V0 Battery status unavailable. diff --git a/lib/logitech_receiver/hidpp20_constants.py b/lib/logitech_receiver/hidpp20_constants.py index ce13db72..7b43795c 100644 --- a/lib/logitech_receiver/hidpp20_constants.py +++ b/lib/logitech_receiver/hidpp20_constants.py @@ -175,10 +175,11 @@ class SupportedFeature(IntEnum): OPERATING_RANGE = 0x8138 TRUE_FORCE = 0x8139 FFB_FILTER = 0x8140 - # Headsets + # Headsets / speakers SIDETONE = 0x8300 + BASS_TONE = 0x8305 EQUALIZER = 0x8310 - HEADSET_OUT = 0x8320 + JACK_DETECTION = 0x8320 # jack presence (was HEADSET_OUT) # Centurion core CENTURION_DEVICE_INFO = 0x0100 CENTURION_DEVICE_NAME = 0x0101 diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 6f803a40..cd46a604 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -1587,6 +1587,16 @@ class Sidetone(settings.Setting): max_value = 100 +class BassTone(settings.Setting): + name = "bass_tone" + label = _("Bass Level") + description = _("Set subwoofer bass level.") + feature = _F.BASS_TONE + validator_class = settings_validator.RangeValidator + min_value = 0 + max_value = 100 + + class Equalizer(settings.RangeFieldSetting): name = "equalizer" label = _("Equalizer") @@ -1596,16 +1606,21 @@ class Equalizer(settings.RangeFieldSetting): keys_universe = [] class validator_class(settings_validator.PackedRangeValidator): + kind = settings.Kind.GRAPHIC_EQ + @classmethod def build(cls, setting_class, device): data = device.feature_request(_F.EQUALIZER, 0x00) if not data: return None - count, dbRange, _x, dbMin, dbMax = struct.unpack("!BBBBB", data[:5]) - if dbMin == 0: - dbMin = -dbRange - if dbMax == 0: - dbMax = dbRange + # dbMin/dbMax are signed; the G560 reports an asymmetric -20..+6 + count, dbRange, _x, dbMin, dbMax = struct.unpack("!BBBbb", data[:5]) + if dbMin == 0 and dbMax == 0: + dbMin, dbMax = -dbRange, dbRange + else: + # reported bounds are exclusive: the G560 NACKs writes at + # exactly dbMin/dbMax but accepts dbMin+1..dbMax-1 + dbMin, dbMax = dbMin + 1, dbMax - 1 map = common.NamedInts() for g in range((count + 6) // 7): freqs = device.feature_request(_F.EQUALIZER, 0x10, g * 7) @@ -4520,6 +4535,7 @@ SETTINGS: list[settings.Setting] = [ HapticLevel, PlayHapticWaveForm, Sidetone, + BassTone, Equalizer, ADCPower, HeadsetEcoMode, diff --git a/lib/solaar/cli/show.py b/lib/solaar/cli/show.py index 0f41b3f1..0ed3cd2c 100644 --- a/lib/solaar/cli/show.py +++ b/lib/solaar/cli/show.py @@ -239,11 +239,9 @@ def _print_device(dev, num=None): # For centurion child, skip dongle features (already shown on the receiver) if is_centurion_child and not in_sub_device: continue - if isinstance(feature, str): - feature_bytes = bytes.fromhex(feature[-4:]) - else: - feature_bytes = feature.to_bytes(2, byteorder="little") - feature_int = int.from_bytes(feature_bytes, byteorder="little") + # ROOT.getFeature takes the feature ID big-endian. + feature_int = int(feature[-4:], 16) if isinstance(feature, str) else int(feature) + feature_bytes = feature_int.to_bytes(2, byteorder="big") display_name = feature if is_centurion_child and in_sub_device: # Use cached version — skip slow bridge ROOT queries diff --git a/tests/logitech_receiver/test_setting_templates.py b/tests/logitech_receiver/test_setting_templates.py index aa4f4c48..28fcd826 100644 --- a/tests/logitech_receiver/test_setting_templates.py +++ b/tests/logitech_receiver/test_setting_templates.py @@ -271,6 +271,11 @@ simple_tests = [ fake_hidpp.Response("05", 0x0400), fake_hidpp.Response("0A", 0x0410, "0A"), ), + Setup( + FeatureTest(settings_templates.BassTone, 0x46, 0x50), + fake_hidpp.Response("46", 0x0400), + fake_hidpp.Response("50", 0x0410, "50"), + ), Setup( FeatureTest(settings_templates.ADCPower, 5, 0xA, version=0x03), fake_hidpp.Response("05", 0x0410), @@ -662,6 +667,15 @@ key_tests = [ fake_hidpp.Response("E010", 0x0430, "02E010"), fake_hidpp.Response("E018", 0x0430, "02E018"), ), + Setup( # signed asymmetric dB range as reported by the G560 (-20..+6, bounds exclusive) + FeatureTest(settings_templates.Equalizer, {0: -4, 1: 2}, {1: 5}, 2), + [-19, 5], + fake_hidpp.Response("021A00EC06", 0x0400), + fake_hidpp.Response("0000200040", 0x0410, "00"), + fake_hidpp.Response("FC02", 0x0420, "00"), + fake_hidpp.Response("FC02", 0x0430, "02FC02"), + fake_hidpp.Response("FC05", 0x0430, "02FC05"), + ), Setup( # HeadsetOnboardEQ: 2 bands, 128Hz/-2dB/Q10 and 256Hz/+3dB/Q10 FeatureTest(settings_templates.HeadsetOnboardEQ, {0: -2, 1: 3}, {1: 5}, 2), [-12, 12],