fix AVR input swap
Deploy HEOS panel / deploy (push) Successful in 25s

This commit is contained in:
2026-09-15 17:20:24 +02:00
parent dc7f09052d
commit 6a0f1fa5e8
13 changed files with 272 additions and 410 deletions
+81 -8
View File
@@ -1,4 +1,4 @@
"""The actual behaviour of the panel, on top of the two protocol clients.
"""The actual behaviour of the panel, on top of the HEOS CLI client.
The interesting part is grouping. A HEOS "In-Room Group" (your pair of
Home 200s) is addressed by a gid and behaves like one speaker -- until
@@ -10,6 +10,12 @@ Two consequences drive most of the code below:
or the second Home 200 gets left behind.
* Unmerging must re-issue set_group with the pair's own pids to put
the pair back together, so we have to remember what they were.
Everything, including the AVR's own inputs, goes over the one HEOS
connection now -- there used to be a second client here for the AVR's
Denon Telnet port, only for its renamed input list, but HEOS reports
those same renamed names itself (browse/browse on the AVR's own pid),
so the Telnet side added a protocol for no remaining benefit.
"""
import json
@@ -17,7 +23,6 @@ import threading
import time
from pathlib import Path
from avr import AvrControl, AvrError
from heos import HeosClient, HeosError, parse_message
MEMBERS_FILE = Path(__file__).with_name("members.json")
@@ -50,7 +55,6 @@ class Controller:
def __init__(self, cfg):
self.cfg = cfg
self.heos = HeosClient(cfg.HEOS_HOST, cfg.HEOS_PORT)
self.avr = AvrControl(cfg.AVR_HOST, cfg.AVR_PORT, cfg.AVR_INPUT_CODES)
self._lock = threading.RLock()
self._members_file = Path(getattr(cfg, "MEMBERS_FILE", MEMBERS_FILE))
self._learned = _load_learned(self._members_file)
@@ -312,8 +316,26 @@ class Controller:
wanted = set(self.joined_keys()) | {key}
self.group_targets(self.cfg.HOST_KEY, [k for k in self.cfg.ROOM_KEYS if k in wanted])
self.scan()
self._nudge_avr_input()
return self.joined_keys()
def _nudge_avr_input(self):
"""A room that has just joined the AVR's group sometimes stays
silent on it until the AVR's input is reselected -- but that has to
happen the way the HEOS app does it (browse/play_input, over HEOS
itself) to actually push audio to the new member. Re-issuing the
input over the AVR's own Telnet port does not: that just tells the
AVR which of its jacks to listen to, it says nothing to HEOS about
who should be streaming it. Best-effort: a join has already
succeeded by the time this runs, so a HEOS hiccup here should not
turn it into a failure."""
try:
current = self.avr_current_input()
if current:
self.play_heos_input(self.cfg.HOST_KEY, current["code"])
except HeosError:
pass
def leave(self, key: str) -> list:
"""Remove one room. Deliberately does not rewrite the AVR's group:
whatever is still joined keeps playing without a hiccup."""
@@ -378,7 +400,10 @@ class Controller:
self.heos.command(f"player/{command}", pid=self.playback_pid(key))
def heos_inputs(self, key: str) -> list:
"""Physical inputs as HEOS sees them (generic ids, not your names)."""
"""A player's physical inputs, as HEOS itself lists them -- under
whatever names you gave them in the AVR's own setup menu. HEOS
carries those renamed labels, not just its generic ids, so this is
the same list the HEOS app itself shows under Sources."""
with self._lock:
self._fresh()
reply = self.heos.command("browse/browse", sid=self.playback_pid(key))
@@ -392,6 +417,54 @@ class Controller:
params["spid"] = self.playback_pid(source_key)
self.heos.command("browse/play_input", **params)
# -- the AVR's inputs, all of it over HEOS ------------------------------
def avr_connected(self) -> bool:
with self._lock:
self._fresh()
try:
self._host_pid()
return True
except TargetError:
return False
def avr_inputs(self) -> list:
"""{"code", "name"} pairs for the picker -- heos_inputs()'s shape,
renamed to match what the UI and /api/avr/* already send and
expect. Narrowed and ordered by AVR_INPUT_CODES, same as before,
except the codes it matches are now HEOS's own ("inputs/aux_in_1"),
not the AVR's Telnet ones ("AUX1")."""
sources = self.heos_inputs(self.cfg.HOST_KEY)
codes = getattr(self.cfg, "AVR_INPUT_CODES", None)
if codes:
order = {code: i for i, code in enumerate(codes)}
sources = sorted(
(s for s in sources if s["input_id"] in order),
key=lambda s: order[s["input_id"]],
)
return [{"code": s["input_id"], "name": s["name"]} for s in sources]
def avr_current_input(self):
"""{"code", "name"} for whatever the AVR is playing right now, or
None if that is not a local input (or the AVR is unreachable)."""
with self._lock:
self._fresh()
try:
reply = self.heos.command(
"player/get_now_playing_media", pid=self._host_pid()
)
except (TargetError, HeosError):
return None
payload = reply.get("payload") or {}
mid = payload.get("mid", "")
if not mid.startswith("inputs/"):
return None
return {"code": mid, "name": payload.get("station") or payload.get("song") or mid}
def avr_select_input(self, code: str) -> dict:
self.play_heos_input(self.cfg.HOST_KEY, code)
name = next((s["name"] for s in self.avr_inputs() if s["code"] == code), code)
return {"code": code, "name": name}
# -- one snapshot for the UI ------------------------------------------
def state(self) -> dict:
snapshot = {
@@ -435,11 +508,11 @@ class Controller:
try:
snapshot["avr"] = {
"connected": self.avr.connected,
"inputs": self.avr.inputs(),
"input": self.avr.current_input(),
"connected": self.avr_connected(),
"inputs": self.avr_inputs(),
"input": self.avr_current_input(),
}
except (AvrError, OSError) as exc:
except (HeosError, TargetError) as exc:
snapshot["errors"].append(str(exc))
return snapshot