Remove group management
Deploy HEOS panel / deploy (push) Successful in 25s

This commit is contained in:
2026-09-16 13:43:31 +02:00
parent 5b82a24ac4
commit 58a2dedc03
15 changed files with 358 additions and 588 deletions
+34 -129
View File
@@ -1,15 +1,11 @@
"""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
you merge it into the AVR's group, at which point that gid stops
existing and its two players are just two members of the AVR's group.
Two consequences drive most of the code below:
* Merging must send EVERY member pid of the pair, not just its leader,
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.
The interesting part is grouping. Every room here -- including the
living room's L/R pair, which HEOS pairs at the hardware level into a
single pid -- is addressed by one player pid, merged or not. set_group
replaces a group wholesale rather than adding to it, so join() and
leave() always have to name every room that should remain in the AVR's
group, not just the one being added or removed.
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
@@ -18,14 +14,11 @@ 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
import threading
import time
from pathlib import Path
from heos import HeosClient, HeosError, parse_message
MEMBERS_FILE = Path(__file__).with_name("members.json")
from zidoo import ZidooClient
def stepped_level(current: int, steps: int, size: int) -> int:
@@ -58,9 +51,9 @@ class Controller:
def __init__(self, cfg):
self.cfg = cfg
self.heos = HeosClient(cfg.HEOS_HOST, cfg.HEOS_PORT)
zidoo_host = getattr(cfg, "ZIDOO_HOST", None)
self.zidoo = ZidooClient(zidoo_host, getattr(cfg, "ZIDOO_PORT", 9529)) if zidoo_host else None
self._lock = threading.RLock()
self._members_file = Path(getattr(cfg, "MEMBERS_FILE", MEMBERS_FILE))
self._learned = _load_learned(self._members_file)
self._players = []
self._groups = []
self._scanned_at = 0.0
@@ -78,13 +71,11 @@ class Controller:
# -- network picture -----------------------------------------------
def scan(self) -> dict:
"""Re-read every player and group, and remember what the rooms
are made of while we can see them."""
"""Re-read every player and group."""
with self._lock:
self._players = self.heos.command("player/get_players").get("payload", [])
self._groups = self.heos.command("group/get_groups").get("payload", [])
self._scanned_at = time.monotonic()
self._learn_members()
return {"players": self._players, "groups": self._groups}
def _fresh(self, max_age: float = 2.0):
@@ -97,41 +88,6 @@ class Controller:
return player.get("pid")
return None
def _group_named(self, name: str):
for group in self._groups:
if group.get("name") == name:
return group
return None
@staticmethod
def _ordered_pids(group: dict) -> list:
"""Member pids with the leader first -- HEOS makes the first pid
in a set_group call the leader, so the order is not cosmetic."""
players = group.get("players", [])
leaders = [p for p in players if p.get("role") == "leader"]
others = [p for p in players if p.get("role") != "leader"]
return [p.get("pid") for p in leaders + others]
def _learn_members(self):
"""Record what each room's group is made of whenever we catch it
standing on its own, so we can rebuild it after a merge."""
host_pid = self._host_pid()
changed = False
for key in self.cfg.ROOM_KEYS:
if "players" in self.cfg.TARGETS[key]:
continue # configured by hand, nothing to learn
group = self._group_named(self.cfg.TARGETS[key]["heos_name"])
if not group:
continue
pids = self._ordered_pids(group)
if host_pid in pids:
continue # currently merged with the AVR: not its own shape
if self._learned.get(key) != pids:
self._learned[key] = pids
changed = True
if changed:
_save_learned(self._members_file, self._learned)
# -- resolving rooms to pids ---------------------------------------
def _host_pid(self):
"""The AVR is always a plain player. Resolving it by player name
@@ -144,63 +100,25 @@ class Controller:
return pid
def member_pids(self, key: str) -> list:
"""Every player that makes up a room, leader first."""
"""The pid of the one player that is this room, as a list."""
if key not in self.cfg.TARGETS:
raise TargetError(f"Unknown room '{key}'")
if key == self.cfg.HOST_KEY:
return [self._host_pid()]
target = self.cfg.TARGETS[key]
name = target["heos_name"]
if "players" in target:
pids = []
for player_name in target["players"]:
pid = self._player_pid(player_name)
if pid is None:
raise TargetError(f"No HEOS player named '{player_name}' was found")
pids.append(pid)
return pids
# A group under this name wins over a player under the same name:
# a stereo pair is usually named after its left-hand speaker.
host_pid = self._host_pid()
group = self._group_named(name)
if group:
pids = self._ordered_pids(group)
if host_pid not in pids:
return pids
known = self._learned.get(key)
if known:
live = {p.get("pid") for p in self._players}
if all(pid in live for pid in known):
return known
name = self.cfg.TARGETS[key]["heos_name"]
pid = self._player_pid(name)
if pid is not None:
return [pid]
raise TargetError(
f"No HEOS player or group named '{name}' was found. "
f"Check GET /api/targets for the names HEOS actually reports."
)
if pid is None:
raise TargetError(
f"No HEOS player named '{name}' was found. "
f"Check GET /api/targets for the names HEOS actually reports."
)
return [pid]
# -- volume ---------------------------------------------------------
def _volume_handles(self, key: str) -> list:
"""Where volume for this room lives right now, as (scope, id).
A room that is its own HEOS group has a single group volume. Once
it is merged into the AVR's group that gid is gone, and the only
knobs left are the member players' own volumes.
"""
pids = self.member_pids(key)
if len(pids) > 1:
wanted = set(pids)
for group in self._groups:
if {p.get("pid") for p in group.get("players", [])} == wanted:
return [("group", group["gid"])]
return [("player", pid) for pid in pids]
"""Where volume for this room lives right now, as (scope, id)."""
return [("player", pid) for pid in self.member_pids(key)]
@staticmethod
def _id_param(scope: str) -> str:
@@ -302,8 +220,7 @@ class Controller:
return pids
def ungroup(self, key: str) -> list:
"""Stand a room back up on its own. For the Home 200 pair this
re-forms the pair rather than leaving two lone speakers behind."""
"""Stand a room back up on its own."""
with self._lock:
self._fresh()
pids = self.member_pids(key)
@@ -466,17 +383,8 @@ class Controller:
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")."""
expect."""
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):
@@ -501,6 +409,15 @@ class Controller:
name = next((s["name"] for s in self.avr_inputs() if s["code"] == code), code)
return {"code": code, "name": name}
def zidoo_now_playing(self, current_input):
"""Whatever a Zidoo plugged into the AVR is showing, when it is the
AVR's selected input -- None otherwise, or if no Zidoo is
configured, or the Zidoo has nothing loaded."""
zidoo_code = getattr(self.cfg, "ZIDOO_INPUT_CODE", None)
if not (self.zidoo and zidoo_code and current_input and current_input["code"] == zidoo_code):
return None
return self.zidoo.now_playing()
# -- one snapshot for the UI ------------------------------------------
def state(self) -> dict:
snapshot = {
@@ -552,26 +469,14 @@ class Controller:
]
try:
current_input = self.avr_current_input()
snapshot["avr"] = {
"connected": self.avr_connected(),
"inputs": self.avr_inputs(),
"input": self.avr_current_input(),
"input": current_input,
"now_playing": self.zidoo_now_playing(current_input),
}
except (HeosError, TargetError) as exc:
snapshot["errors"].append(str(exc))
return snapshot
def _load_learned(path: Path) -> dict:
try:
return json.loads(path.read_text())
except (OSError, ValueError):
return {}
def _save_learned(path: Path, data: dict):
try:
path.write_text(json.dumps(data, indent=2))
except OSError:
pass # a read-only checkout just means we re-learn next time