Files
heos/controller.py
T
franzz ec684b7803
Deploy HEOS panel / deploy (push) Successful in 26s
Make progress bar cursor draggable
2026-09-16 23:19:08 +02:00

496 lines
20 KiB
Python

"""The actual behaviour of the panel, on top of the HEOS CLI client.
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
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 threading
import time
from heos import HeosClient, HeosError, parse_message
from zidoo import ZidooClient
def stepped_level(current: int, steps: int, size: int) -> int:
"""Where the volume lands after `steps` taps of +/-.
A tap moves to the next multiple of `size` rather than adding `size`,
so a level of 23 with a step of 5 goes 23 -> 25 -> 30 on the way up
and 23 -> 20 -> 15 on the way down. Levels that are already on a
multiple simply move a whole step.
"""
if steps > 0:
landing = (current // size + steps) * size
elif steps < 0:
aligned = current // size * size
first = current - size if aligned == current else aligned
landing = first - (-steps - 1) * size
else:
return current
return max(0, min(100, landing))
class TargetError(ValueError):
"""We cannot find that room on the network right now."""
class Controller:
# HEOS's source id for Spotify, in get_now_playing_media's payload.
SPOTIFY_SID = "4"
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._players = []
self._groups = []
self._scanned_at = 0.0
threading.Thread(target=self._keep_warm, daemon=True).start()
def _keep_warm(self):
"""HEOS hangs up on idle connections; a heartbeat keeps the first
button press of the evening as quick as the second."""
while True:
time.sleep(240)
try:
self.heos.heart_beat()
except HeosError:
pass
# -- network picture -----------------------------------------------
def scan(self) -> dict:
"""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()
return {"players": self._players, "groups": self._groups}
def _fresh(self, max_age: float = 2.0):
if time.monotonic() - self._scanned_at > max_age:
self.scan()
def _player_pid(self, name: str):
for player in self._players:
if player.get("name") == name:
return player.get("pid")
return None
# -- resolving rooms to pids ---------------------------------------
def _host_pid(self):
"""The AVR is always a plain player. Resolving it by player name
matters: once it leads a group, HEOS also reports a *group* under
the very same name."""
target = self.cfg.TARGETS[self.cfg.HOST_KEY]
pid = self._player_pid(target["heos_name"])
if pid is None:
raise TargetError(f"No HEOS player named '{target['heos_name']}' (the AVR) was found")
return pid
def member_pids(self, key: str) -> list:
"""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()]
name = self.cfg.TARGETS[key]["heos_name"]
pid = self._player_pid(name)
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)."""
return [("player", pid) for pid in self.member_pids(key)]
@staticmethod
def _id_param(scope: str) -> str:
return "pid" if scope == "player" else "gid"
def _read_volume(self, scope, obj_id) -> int:
reply = self.heos.command(f"{scope}/get_volume", **{self._id_param(scope): obj_id})
return int(parse_message(reply["heos"]["message"]).get("level", -1))
def volume(self, key: str) -> int:
with self._lock:
self._fresh()
scope, obj_id = self._volume_handles(key)[0]
return self._read_volume(scope, obj_id)
def set_volume(self, key: str, level: int) -> int:
level = max(0, min(100, int(level)))
with self._lock:
self._fresh()
for scope, obj_id in self._volume_handles(key):
self.heos.command(
f"{scope}/set_volume", **{self._id_param(scope): obj_id}, level=level
)
return level
def nudge_volume(self, key: str, delta: int) -> int:
"""Move volume by delta and report where it landed.
Absolute rather than HEOS's own volume_up/down, because the UI
coalesces a fast burst of taps into one call and volume_up caps
its step at 10.
"""
with self._lock:
self._fresh()
handles = self._volume_handles(key)
current = self._read_volume(*handles[0])
level = max(0, min(100, current + int(delta)))
for scope, obj_id in handles:
self.heos.command(
f"{scope}/set_volume", **{self._id_param(scope): obj_id}, level=level
)
return level
def step_volume(self, key: str, steps: int) -> int:
"""Move by whole taps, landing on multiples of VOLUME_STEP.
Counted in taps rather than points so the speakers' own level is
what gets rounded, even when the phone's copy of it is a few
seconds stale.
"""
with self._lock:
self._fresh()
handles = self._volume_handles(key)
level = stepped_level(
self._read_volume(*handles[0]), int(steps), self.cfg.VOLUME_STEP
)
for scope, obj_id in handles:
self.heos.command(
f"{scope}/set_volume", **{self._id_param(scope): obj_id}, level=level
)
return level
def toggle_mute(self, key: str):
with self._lock:
self._fresh()
for scope, obj_id in self._volume_handles(key):
self.heos.command(f"{scope}/toggle_mute", **{self._id_param(scope): obj_id})
# -- grouping --------------------------------------------------------
def _host_group_pids(self) -> set:
"""Every pid currently in the AVR's group (empty if it is alone)."""
host_pid = self._host_pid()
for group in self._groups:
pids = {p.get("pid") for p in group.get("players", [])}
if host_pid in pids and len(pids) > 1:
return pids
return set()
def joined_keys(self) -> list:
joined, host_pids = [], self._host_group_pids()
for key in self.cfg.ROOM_KEYS:
try:
if host_pids & set(self.member_pids(key)):
joined.append(key)
except TargetError:
continue
return joined
def group_targets(self, host_key: str, member_keys: list) -> list:
"""One set_group call: the host's pid first -- that is what makes it
the leader whose content everyone else plays -- then every member
pid of every room listed."""
with self._lock:
self._fresh()
pids = list(self.member_pids(host_key))
for key in member_keys:
pids.extend(self.member_pids(key))
self.heos.command("group/set_group", pid=",".join(str(p) for p in pids))
return pids
def ungroup(self, key: str) -> list:
"""Stand a room back up on its own."""
with self._lock:
self._fresh()
pids = self.member_pids(key)
self.heos.command("group/set_group", pid=",".join(str(p) for p in pids))
return pids
def join(self, key: str) -> list:
"""Add a room to the AVR's group. set_group replaces a group
wholesale, so the call has to name everyone who is already in it
as well as the newcomer."""
with self._lock:
self.scan()
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."""
with self._lock:
self.scan()
if key in self.joined_keys():
self.ungroup(key)
self.scan()
return self.joined_keys()
def set_membership(self, joined: list) -> list:
"""Make the AVR's group contain exactly these rooms and no others."""
with self._lock:
self.scan()
wanted = [k for k in self.cfg.ROOM_KEYS if k in joined]
for key in list(self.joined_keys()):
if key not in wanted:
self.ungroup(key)
if wanted:
self.group_targets(self.cfg.HOST_KEY, wanted)
self.scan()
return self.joined_keys()
# -- playback (kept for the original bridge's API) --------------------
def playback_pid(self, key: str):
return self.member_pids(key)[0]
def play_state(self, key: str, state: str):
with self._lock:
self._fresh()
self.heos.command("player/set_play_state", pid=self.playback_pid(key), state=state)
def get_play_state(self, key: str) -> str:
""""play", "pause" or "stop" for whatever this room is doing."""
with self._lock:
self._fresh()
reply = self.heos.command("player/get_play_state", pid=self.playback_pid(key))
return parse_message(reply["heos"]["message"]).get("state", "stop")
def now_playing_media(self, key: str) -> dict:
"""HEOS's get_now_playing_media payload for a room. An idle player
can refuse the query outright, which is the same as nothing loaded."""
with self._lock:
self._fresh()
try:
reply = self.heos.command("player/get_now_playing_media", pid=self.playback_pid(key))
except HeosError:
return {}
return reply.get("payload") or {}
@classmethod
def _is_spotify(cls, media: dict) -> bool:
return (str(media.get("sid")) == cls.SPOTIFY_SID
or str(media.get("mid", "")).startswith("spotify:"))
@staticmethod
def _track(media: dict):
"""{"song", "artist", "image"} for a room's card, or None when there
is no song to show. An AVR input is not one: HEOS fills in its
"song" with the input's own name, which the input picker already
shows. Artist and cover are None when HEOS has nothing for them."""
song = media.get("song")
if not song or str(media.get("mid", "")).startswith("inputs/"):
return None
return {"song": song, "artist": media.get("artist") or None, "image": media.get("image_url") or None}
def on_spotify(self, key: str) -> bool:
"""Whether a room's now-playing is a Spotify stream, playing or
paused -- the only thing its play/pause and next buttons make sense
for."""
return self._is_spotify(self.now_playing_media(key))
def toggle_play(self, key: str, state: str = None) -> str:
"""Start or stop a room. With no state, flips whatever it is doing
now -- read from the speakers rather than trusted from the phone,
whose copy can be a few seconds old.
A room that is grouped shares the AVR's transport, so this stops the
whole group. That is HEOS's doing, not ours: a group has one thing
playing, by definition.
"""
with self._lock:
self._fresh()
if state is None:
state = "pause" if self.get_play_state(key) == "play" else "play"
self.heos.command(
"player/set_play_state", pid=self.playback_pid(key), state=state
)
return state
def skip(self, key: str, direction: str):
with self._lock:
self._fresh()
command = "play_next" if direction == "next" else "play_previous"
self.heos.command(f"player/{command}", pid=self.playback_pid(key))
def heos_inputs(self, key: str) -> list:
"""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))
return [{"name": i.get("name"), "input_id": i.get("mid")} for i in reply.get("payload", [])]
def play_heos_input(self, key: str, input_id: str, source_key: str = None):
with self._lock:
self._fresh()
params = {"pid": self.playback_pid(key), "input": input_id}
if source_key:
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."""
sources = self.heos_inputs(self.cfg.HOST_KEY)
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}
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()
def zidoo_seek(self, position_ms: int):
"""Jump the Zidoo's film -- the AVR card's now-playing -- to
position_ms."""
if not self.zidoo:
raise TargetError("No Zidoo is configured to seek in")
self.zidoo.seek(position_ms)
# -- one snapshot for the UI ------------------------------------------
def state(self) -> dict:
snapshot = {
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]},
"rooms": [],
"avr": {"connected": False, "input": None, "inputs": []},
"heos_ok": True,
"errors": [],
}
with self._lock:
try:
self.scan()
joined = set(self.joined_keys())
for key in self.cfg.ROOM_KEYS:
room = {
"key": key,
"label": self.cfg.TARGETS[key]["label"],
"available": True,
"grouped": key in joined,
"volume": None,
"play_state": None,
"spotify": False,
"now_playing": None,
"error": None,
}
try:
scope, obj_id = self._volume_handles(key)[0]
room["volume"] = self._read_volume(scope, obj_id)
room["play_state"] = self.get_play_state(key)
media = self.now_playing_media(key)
room["spotify"] = self._is_spotify(media)
# Kept while paused, so the card does not jump about
# under the thumb that just pressed pause.
if room["play_state"] != "stop":
track = self._track(media)
if track is not None:
progress = self.heos.progress_for(self.playback_pid(key))
if progress and progress["duration"]:
track["position_ms"] = progress["cur_pos"]
track["duration_ms"] = progress["duration"]
room["now_playing"] = track
except (TargetError, HeosError, KeyError) as exc:
room["available"] = False
room["error"] = str(exc)
snapshot["rooms"].append(room)
except (HeosError, TargetError) as exc:
snapshot["heos_ok"] = False
snapshot["errors"].append(str(exc))
snapshot["rooms"] = [
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": False,
"grouped": False, "volume": None, "play_state": None, "spotify": False,
"now_playing": None, "error": str(exc)}
for key in self.cfg.ROOM_KEYS
]
try:
current_input = self.avr_current_input()
snapshot["avr"] = {
"connected": self.avr_connected(),
"inputs": self.avr_inputs(),
"input": current_input,
"now_playing": self.zidoo_now_playing(current_input),
}
except (HeosError, TargetError) as exc:
snapshot["errors"].append(str(exc))
return snapshot