From ec05c112f08d88968f5e6ec97d0bad19f3c51714 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Thu, 7 May 2026 11:24:43 -0700 Subject: [PATCH] settings_templates: fix 0x1B0C wire encoding for Pro X 2 Superstrike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AnalogButtons feature packs each tunable in bits 7..2 of its byte (wire = logical << 2); byte 2 bit 0 is a firmware-managed sensitivity flag, the rest of the low bits are reserved-zero. Solaar 1.1.19 sent the slider value verbatim, so any logical 1/2/3 produced wire bytes 0x01/0x02/0x03 — non-zero reserved bits and below the logical minimum, hence INVALID_ARGUMENT (issue #3202). Only multiples of 4 happened to land on a valid wire byte. Decode bytes 1/2/3 of getConfig and caps[2..4] of getCapabilities by right-shifting 2; left-shift the user value by 2 on setConfig and OR back the prior sensitivity bit on rapid-trigger writes. Defaults fallbacks updated to logical mid-points and the validator maxima now reflect the real ranges (actuation 1..10, rapid trigger 1..5, haptics 0..5). Persisted values from 1.1.19 were raw wire bytes (e.g. 40), which now exceed the new max and would fail apply()'s prepare_write. A new _AnalogButtonSetting subclass migrates such values in _pre_read by dividing by 4 when the result lands inside the new valid range, and rewrites the persister so the migration is one-shot. --- lib/logitech_receiver/settings_templates.py | 81 ++++++++++++++++----- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index ddab8b0a..e29b25fc 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -1999,6 +1999,12 @@ class ForceSensing(settings_new.Settings): # Analog button tuning settings (actuation point, rapid trigger, haptics) +# +# Bytes 1 (actuation), 2 (rapid trigger), 3 (haptics) of the 0x1B0C config struct +# pack a logical value in bits 7..2 (i.e. wire = logical << 2). Byte 2 bit 0 is a +# firmware-managed sensitivityFlag that must be preserved across writes; all other +# low-order bits are reserved and must be zero. Sending a wire byte that doesn't +# match this layout produces INVALID_ARGUMENT — see issue #3202. class _AnalogButtonActuationRW(settings.FeatureRW): @@ -2011,14 +2017,15 @@ class _AnalogButtonActuationRW(settings.FeatureRW): def read(self, device, data_bytes=b""): res = device.feature_request(self.feature, 0x20, self.button_index) if not res: - return b"\x14" # default mid-point - return bytes([res[1]]) + return b"\x05" # default mid-point (logical) + return bytes([res[1] >> 2]) def write(self, device, data_bytes): current = device.feature_request(self.feature, 0x20, self.button_index) if not current: return None - return device.feature_request(self.feature, 0x10, self.button_index, data_bytes[0], current[2], current[3]) + wire_act = (data_bytes[0] & 0x3F) << 2 + return device.feature_request(self.feature, 0x10, self.button_index, wire_act, current[2], current[3]) class _AnalogButtonRapidTriggerRW(settings.FeatureRW): @@ -2031,14 +2038,16 @@ class _AnalogButtonRapidTriggerRW(settings.FeatureRW): def read(self, device, data_bytes=b""): res = device.feature_request(self.feature, 0x20, self.button_index) if not res: - return b"\x0a" # default mid-point - return bytes([res[2]]) + return b"\x03" # default mid-point (logical) + return bytes([res[2] >> 2]) def write(self, device, data_bytes): current = device.feature_request(self.feature, 0x20, self.button_index) if not current: return None - return device.feature_request(self.feature, 0x10, self.button_index, current[1], data_bytes[0], current[3]) + # Preserve the firmware-managed sensitivityFlag (byte 2 bit 0). + wire_rt = ((data_bytes[0] & 0x3F) << 2) | (current[2] & 0x01) + return device.feature_request(self.feature, 0x10, self.button_index, current[1], wire_rt, current[3]) class _AnalogButtonHapticsRW(settings.FeatureRW): @@ -2051,14 +2060,46 @@ class _AnalogButtonHapticsRW(settings.FeatureRW): def read(self, device, data_bytes=b""): res = device.feature_request(self.feature, 0x20, self.button_index) if not res: - return b"\x0a" # default mid-point - return bytes([res[3]]) + return b"\x03" # default mid-point (logical) + return bytes([res[3] >> 2]) def write(self, device, data_bytes): current = device.feature_request(self.feature, 0x20, self.button_index) if not current: return None - return device.feature_request(self.feature, 0x10, self.button_index, current[1], current[2], data_bytes[0]) + wire_haptics = (data_bytes[0] & 0x3F) << 2 + return device.feature_request(self.feature, 0x10, self.button_index, current[1], current[2], wire_haptics) + + +class _AnalogButtonSetting(settings.Setting): + """Setting subclass that migrates legacy raw-byte persisted values. + + Solaar 1.1.19 stored the wire byte (logical value × 4) under these names. After + the encoding fix, the same key holds the logical value. If a stored value is + above the new max but a divide-by-4 lands inside the valid range, treat it as + legacy raw and migrate in place — this prevents apply() from raising + INVALID_ARGUMENT/ValueError on first run after upgrade. + """ + + def _pre_read(self, cached, key=None): + super()._pre_read(cached, key) + if self._value is None or not isinstance(self._value, int): + return + validator = self._validator + if self._value > validator.max_value and (self._value & 0x03) == 0: + migrated = self._value >> 2 + if validator.min_value <= migrated <= validator.max_value: + if logger.isEnabledFor(logging.INFO): + logger.info( + "%s: migrating legacy raw value %d to logical %d on %s", + self.name, + self._value, + migrated, + self._device, + ) + self._value = migrated + if getattr(self._device, "persister", None) is not None: + self._device.persister[self.name] = self._value class AnalogButtonTuning(settings.Setting): @@ -2073,14 +2114,14 @@ class AnalogButtonTuning(settings.Setting): def build(cls, device): if cls.feature not in device.features: return None - # Get capabilities: [flags, button_count, max_actuation, max_rt, max_haptics, ...] + # Capabilities: [flags, button_count, max_act<<2, max_rt<<2, max_haptics<<2, ...] caps = device.feature_request(cls.feature, 0x00) if not caps or len(caps) < 5: return None - button_count = min(caps[1], 2) # Byte 1 is button count, limit to 2 (left/right) - max_actuation = caps[2] if caps[2] > 0 else 40 # Byte 2 is max actuation - max_rt_level = caps[3] if caps[3] > 0 else 20 # Byte 3 is max RT level - max_haptics = caps[4] if caps[4] > 0 else 20 # Byte 4 is max haptics + button_count = min(caps[1], 2) # firmware reports 3; only L/R are user-accessible + max_actuation = (caps[2] >> 2) if caps[2] > 0 else 10 + max_rt_level = (caps[3] >> 2) if caps[3] > 0 else 5 + max_haptics = (caps[4] >> 2) if caps[4] > 0 else 5 if button_count == 0: return None @@ -2093,26 +2134,26 @@ class AnalogButtonTuning(settings.Setting): rw_act = _AnalogButtonActuationRW(cls.feature, i) val_act = settings_validator.RangeValidator(min_value=1, max_value=max_actuation) - s_act = settings.Setting(device, rw_act, val_act) + s_act = _AnalogButtonSetting(device, rw_act, val_act) s_act.name = f"analog-button-tuning_actuation-{i}" s_act.label = f"{btn_name} Actuation Point" - s_act.description = _("Actuation point depth (raw device value).") + s_act.description = _("Actuation point depth (1=shallow, %d=deep).") % max_actuation all_settings.append(s_act) rw_rt = _AnalogButtonRapidTriggerRW(cls.feature, i) val_rt = settings_validator.RangeValidator(min_value=1, max_value=max_rt_level) - s_rt = settings.Setting(device, rw_rt, val_rt) + s_rt = _AnalogButtonSetting(device, rw_rt, val_rt) s_rt.name = f"analog-button-tuning_rapid-trigger-{i}" s_rt.label = f"{btn_name} Rapid Trigger" - s_rt.description = _("Rapid trigger sensitivity (raw device value).") + s_rt.description = _("Rapid trigger sensitivity (1..%d).") % max_rt_level all_settings.append(s_rt) rw_haptics = _AnalogButtonHapticsRW(cls.feature, i) val_haptics = settings_validator.RangeValidator(min_value=0, max_value=max_haptics) - s_haptics = settings.Setting(device, rw_haptics, val_haptics) + s_haptics = _AnalogButtonSetting(device, rw_haptics, val_haptics) s_haptics.name = f"analog-button-tuning_haptics-{i}" s_haptics.label = f"{btn_name} Click Haptics" - s_haptics.description = _("Click haptic feedback intensity (raw device value, 0=off).") + s_haptics.description = _("Click haptic feedback intensity (0=off, %d=max).") % max_haptics all_settings.append(s_haptics) return all_settings if all_settings else None