This commit is contained in:
+34
-129
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user