HeadsetSidetone: scale UI percent to the device's gain-step grid

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.
This commit is contained in:
Ken Sanislo 2026-05-15 15:53:19 -07:00 committed by Peter F. Patel-Schneider
parent c9db25a302
commit 5b704c5bd7
1 changed files with 36 additions and 6 deletions

View File

@ -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)