From 5b704c5bd761154a124cf36c713dd4ea6247b6ed Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Fri, 15 May 2026 15:53:19 -0700 Subject: [PATCH] HeadsetSidetone: scale UI percent to the device's gain-step grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0x0604 wire "level" is a gain-step index 0..N-1, not a 0-100 percent. G HUB reads the step count N from getSidetoneLevelSettings (function 2) and scales: level = (N-1)*pct/100. Solaar wrote the raw percent, which only matches when N == 101 — so V2 headsets reporting a smaller N (G522: N=10) got out-of-range step writes the firmware silently ACK'd without applying. Read func 2 at build time, take reply byte 2 as N (default 101 when the call is absent — V1 — or the reply is unusable). The validator now maps the 0-100 UI percent to/from the wire step index and clamps writes to N-1. V1 devices (e.g. PRO X 2) keep N=101, which makes the scaling an exact identity — their wire traffic is unchanged. The raw func-2 reply is still logged at debug level for a future G HUB setpoint correlation. --- lib/logitech_receiver/settings_templates.py | 42 ++++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index c40bf9cd..6d7a9400 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -1708,10 +1708,35 @@ class HeadsetSidetone(settings.Setting): description = _("Sidetone level (0 = off, 100 = max).") feature = _F.HEADSET_AUDIO_SIDETONE rw_options = {"read_fnid": 0x00, "write_fnid": 0x10} - validator_class = settings_validator.RangeValidator min_value = 0 max_value = 100 + class validator_class(settings_validator.RangeValidator): + """UI value is 0-100 percent; the wire level is a gain-step index + 0..N-1. N (gain_steps) comes from getSidetoneLevelSettings on V2 + devices, or defaults to 101 on V1 — which makes step == percent, so + V1 round-trips unchanged. The firmware silently ignores out-of-range + step writes, so writes are clamped to N-1.""" + + gain_steps = 101 + + def _level_bytes(self, raw): + return common.bytes2int(raw[self.read_skip_byte_count : self.read_skip_byte_count + self._byte_count]) + + def validate_read(self, reply_bytes): + level = self._level_bytes(reply_bytes) + steps = self.gain_steps + return int(round(level * 100 / (steps - 1))) if steps > 1 else level + + def prepare_write(self, new_value, current_value=None): + steps = self.gain_steps + level = int(round((steps - 1) * new_value / 100)) if steps > 1 else new_value + level = max(0, min((steps - 1) if steps > 1 else self.max_value, level)) + to_write = self.write_prefix_bytes + common.int2bytes(level, self._byte_count) + if current_value is not None and self._level_bytes(current_value) == level: + return None + return to_write + @classmethod def build(cls, device): # Version <= 1: GetSidetone returns [mic_count, mic_id, level]; SetSidetone takes [mic_id, level] @@ -1721,18 +1746,23 @@ class HeadsetSidetone(settings.Setting): skip, prefix = 3, b"\x01\xff" else: skip, prefix = 2, b"\x01" - # getSidetoneLevelSettings (fn 2) carries the gain-step count that scales - # wire level <-> UI percent; its byte layout is unmapped. Log the raw - # reply under -dd so a user log pins it down. - if logger.isEnabledFor(logging.DEBUG): + # V2 getSidetoneLevelSettings (fn 2) reply byte 2 is the gain-step count N. + # V1 has no such call — N stays 101 (step == percent). Raw reply still + # logged at debug so a G HUB setpoint correlation can refine the layout. + gain_steps = 101 + if version > 1: try: reply = device.feature_request(cls.feature, 0x20) - logger.debug("%s: getSidetoneLevelSettings raw reply: %s", cls.name, reply.hex() if reply else reply) except Exception as e: + reply = None logger.debug("%s: getSidetoneLevelSettings probe raised %s", cls.name, e) + logger.debug("%s: getSidetoneLevelSettings raw reply: %s", cls.name, reply.hex() if reply else reply) + if reply is not None and len(reply) >= 3 and reply[2] > 1: + gain_steps = reply[2] rw = settings.FeatureRW(cls.feature, **cls.rw_options) validator = cls.validator_class.build(cls, device, read_skip_byte_count=skip, write_prefix_bytes=prefix) if validator: + validator.gain_steps = gain_steps return cls(device, rw, validator)