Snap volume to whole steps, and hide deleted AVR sources

A tap now moves to the next multiple of VOLUME_STEP rather than adding
it, so 23 goes to 25 and 25 goes to 30 and the levels stay round. The
panel counts taps and lets the speakers do the rounding from whatever
level they are actually at, since the phone's copy can be seconds old;
the same rule is mirrored in JS so the optimistic number never has to
correct itself when the reply lands.

The input picker also asks the AVR which sources are still switched on
(SSSOD ?) and leaves out the ones deleted in its setup menu. Sources it
does not mention are kept, so a model that ignores the command shows its
whole list rather than nothing; deleted sources also keep their names, in
case the AVR is sitting on one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 21:39:31 +02:00
co-authored by Claude Opus 5
parent 4affd18b3b
commit 1a1fcc84d6
9 changed files with 198 additions and 33 deletions
+41 -5
View File
@@ -163,6 +163,24 @@ def parse_ssfun(lines: list) -> list:
return sources
def parse_sssod(lines: list) -> dict:
"""Parse `SSSOD ?` output -- 'SSSODTUNER DEL' and friends -- into
{"TUNER": False, "CD": True, ...}, i.e. which sources you have left
switched on in the AVR's own setup menu."""
usage = {}
for line in lines:
if not line.startswith("SSSOD"):
continue
rest = line[len("SSSOD"):].strip()
if rest in ("END", ""):
continue
code, _, value = rest.rpartition(" ")
code = code.strip()
if code:
usage[code] = value.strip().upper() != "DEL"
return usage
class AvrControl:
"""The input list and the current input, in the names you chose."""
@@ -170,14 +188,15 @@ class AvrControl:
self.telnet = DenonTelnet(host, port)
self.allowed_codes = list(allowed_codes or [])
self._inputs = None
self._usage = None
@property
def connected(self) -> bool:
return self.telnet.connected
def inputs(self, refresh: bool = False) -> list:
"""Your renamed source list. Cached: it only changes when you
rename something in the AVR's own setup menu."""
def all_inputs(self, refresh: bool = False) -> list:
"""Every source the AVR knows, under your names, deleted ones
included. Cached: it only changes when you edit the setup menu."""
if self._inputs is None or refresh:
lines = self.telnet.request(
"SSFUN ?", prefix="SSFUN",
@@ -187,7 +206,24 @@ class AvrControl:
sources = parse_ssfun(lines)
if sources:
self._inputs = sources
sources = self._inputs or []
if self._usage is None or refresh:
lines = self.telnet.request(
"SSSOD ?", prefix="SSSOD",
until=lambda line: line.strip() == "SSSOD END",
timeout=3.0,
)
self._usage = parse_sssod(lines)
return self._inputs or []
def inputs(self, refresh: bool = False) -> list:
"""What the picker offers: the sources you can actually select.
Sources you deleted in the AVR's setup menu are left out -- they
are exactly the ones you never want to land on. Anything SSSOD
does not mention is kept, so a model that does not answer that
command shows its whole list rather than nothing at all.
"""
sources = [s for s in self.all_inputs(refresh) if self._usage.get(s["code"], True)]
if self.allowed_codes:
order = {code: i for i, code in enumerate(self.allowed_codes)}
sources = sorted(
@@ -208,7 +244,7 @@ class AvrControl:
return {"code": code, "name": self.name_for(code)}
def name_for(self, code: str) -> str:
for source in self.inputs():
for source in self.all_inputs():
if source["code"] == code:
return source["name"]
return code