HeadsetAutoSleep: read-modify-write a single uint8 timer slot

HID++ 0x0108 is not a single integer: V3 carries two uint8 timer slots,
V4+ carries three. The old code decoded the response as one big-endian
integer and wrote the user's value into byte[0] with the other slots
zeroed — which the firmware rejects, so writes silently never round-tripped.

_AutoSleepRangeValidator now decodes only the user-facing slot (byte[0]
on V<3 and V4+, byte[1] on V3 per LGHUB) and builds the wire payload by
reading the current bytes and mutating only that slot. Range becomes the
real uint8 0–255 (not the arbitrary 240), and the description drops the
"minutes" claim since per-slot units are device-specific.

See ~/ghub/solaar_0x0108_autosleep_impl_guide.md for the RE notes.
This commit is contained in:
Ken Sanislo 2026-05-10 16:48:28 -07:00
parent 5357ef955d
commit e06c4ce93a
1 changed files with 45 additions and 7 deletions

View File

@ -1749,18 +1749,56 @@ class HeadsetMixBalance(settings.Setting):
validator_options = {"byte_count": 1}
class _AutoSleepRangeValidator(settings_validator.RangeValidator):
"""Single-slot read-modify-write validator for HID++ 0x0108 AutoSleep.
0x0108 is not a single timer: V3 has two uint8 bytes, V4+ has three. Each
byte is an independent timer slot. Solaar exposes only the user-facing slot
today and preserves the others via RMW; writing zero into the other slots
causes the firmware to reject the request.
Wire byte layout per feature version:
V<3: [timer]
V3: [reserved, timer] preserve byte[0]
V4+: [timer_a, timer_b, timer_c] preserve byte[1], byte[2]
"""
def __init__(self, byte_count, **kwargs):
super().__init__(byte_count=byte_count, **kwargs)
# V3 sources the user-controllable timer from byte[1] per LGHUB.
self._slot = 1 if byte_count == 2 else 0
def validate_read(self, reply_bytes):
if len(reply_bytes) <= self._slot:
raise AssertionError(
f"{self.__class__.__name__}: read returned {len(reply_bytes)} bytes, expected ≥ {self._slot + 1}"
)
return reply_bytes[self._slot]
def prepare_write(self, new_value, current_value=None):
if new_value < self.min_value or new_value > self.max_value:
raise ValueError(f"invalid choice {new_value!r}")
if current_value is None:
payload = bytearray(self._byte_count)
else:
payload = bytearray(current_value[: self._byte_count])
if len(payload) < self._byte_count:
payload.extend(b"\x00" * (self._byte_count - len(payload)))
if payload[self._slot] == new_value:
return None
payload[self._slot] = new_value
return bytes(payload)
class HeadsetAutoSleep(settings.Setting):
name = "headset-auto-sleep"
label = _("Auto Sleep Timeout")
description = _("Idle time in minutes before the headset enters sleep mode (0 = disabled).")
description = _("Headset idle-timer slot (0 = disabled). Units are device-specific.")
feature = _F.CENTURION_AUTO_SLEEP
rw_options = {"read_fnid": 0x00, "write_fnid": 0x10}
validator_class = settings_validator.RangeValidator
validator_class = _AutoSleepRangeValidator
min_value = 0
# Wire format byte count depends on feature version (V<3: 1B, V=3: 2B, V>=4: 3B).
# UI slider is capped at 240 min regardless — firmware accepts larger values, but
# a 31-year slider (24-bit max) is not useful.
max_value = 240
max_value = 255 # uint8 slot
validator_options = {"byte_count": 1}
@classmethod
@ -1773,7 +1811,7 @@ class HeadsetAutoSleep(settings.Setting):
else:
byte_count = 1
rw = settings.FeatureRW(cls.feature, **cls.rw_options)
validator = settings_validator.RangeValidator(min_value=0, max_value=cls.max_value, byte_count=byte_count)
validator = _AutoSleepRangeValidator(min_value=0, max_value=cls.max_value, byte_count=byte_count)
return cls(device, rw, validator)