575 lines
24 KiB
Python
575 lines
24 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"
|
|
|
|
# A player's input list only changes when someone renames a jack in
|
|
# the AVR's own setup menu, so browse/browse -- one of the slower HEOS
|
|
# calls -- does not need asking again on every poll.
|
|
INPUTS_TTL = 2 * 60 * 60
|
|
|
|
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
|
|
self._inputs_cache = {} # key -> (inputs, cached_at)
|
|
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, in one round trip."""
|
|
with self._lock:
|
|
players_reply, groups_reply = self.heos.command_batch([
|
|
("player/get_players", {}),
|
|
("group/get_groups", {}),
|
|
])
|
|
if isinstance(players_reply, HeosError):
|
|
raise players_reply
|
|
if isinstance(groups_reply, HeosError):
|
|
raise groups_reply
|
|
self._players = players_reply.get("payload", [])
|
|
self._groups = groups_reply.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.
|
|
|
|
Cached for INPUTS_TTL: this list rarely changes and browse/browse
|
|
is one of the slower HEOS calls."""
|
|
cached = self._inputs_cache.get(key)
|
|
if cached and time.monotonic() - cached[1] < self.INPUTS_TTL:
|
|
return cached[0]
|
|
with self._lock:
|
|
self._fresh()
|
|
reply = self.heos.command("browse/browse", sid=self.playback_pid(key))
|
|
inputs = [{"name": i.get("name"), "input_id": i.get("mid")} for i in reply.get("payload", [])]
|
|
self._inputs_cache[key] = (inputs, time.monotonic())
|
|
return inputs
|
|
|
|
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:
|
|
"""Everything the UI needs, in as few HEOS round trips as the
|
|
protocol allows: one to scan players/groups (their pids drive
|
|
everything after), then one batch carrying every room's volume,
|
|
play state and now-playing plus the AVR's, all sent together and
|
|
matched up as replies come back rather than asked for one at a
|
|
time."""
|
|
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": [],
|
|
}
|
|
|
|
host_pid = None
|
|
avr_now_playing = None
|
|
with self._lock:
|
|
try:
|
|
self.scan()
|
|
joined = set(self.joined_keys())
|
|
|
|
room_pids, room_errors = {}, {}
|
|
for key in self.cfg.ROOM_KEYS:
|
|
try:
|
|
room_pids[key] = self.member_pids(key)[0]
|
|
except TargetError as exc:
|
|
room_errors[key] = exc
|
|
try:
|
|
host_pid = self._host_pid()
|
|
except TargetError:
|
|
host_pid = None
|
|
|
|
requests, request_keys = [], []
|
|
for key, pid in room_pids.items():
|
|
requests += [
|
|
("player/get_volume", {"pid": pid}),
|
|
("player/get_play_state", {"pid": pid}),
|
|
("player/get_now_playing_media", {"pid": pid}),
|
|
]
|
|
request_keys += [(key, "volume"), (key, "play_state"), (key, "now_playing")]
|
|
if host_pid is not None:
|
|
requests.append(("player/get_now_playing_media", {"pid": host_pid}))
|
|
request_keys.append((None, "avr_now_playing"))
|
|
|
|
results = self.heos.command_batch(requests) if requests else []
|
|
by_room = {}
|
|
for (key, field), result in zip(request_keys, results):
|
|
if key is None:
|
|
avr_now_playing = result
|
|
else:
|
|
by_room.setdefault(key, {})[field] = result
|
|
|
|
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:
|
|
if key in room_errors:
|
|
raise room_errors[key]
|
|
data = by_room.get(key, {})
|
|
|
|
volume_reply = data.get("volume")
|
|
if isinstance(volume_reply, HeosError):
|
|
raise volume_reply
|
|
room["volume"] = int(parse_message(volume_reply["heos"]["message"]).get("level", -1))
|
|
|
|
play_reply = data.get("play_state")
|
|
if isinstance(play_reply, HeosError):
|
|
raise play_reply
|
|
room["play_state"] = parse_message(play_reply["heos"]["message"]).get("state", "stop")
|
|
|
|
np_reply = data.get("now_playing")
|
|
media = {} if isinstance(np_reply, HeosError) else (np_reply.get("payload") or {})
|
|
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(room_pids[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 = None
|
|
if host_pid is not None and avr_now_playing is not None and not isinstance(avr_now_playing, HeosError):
|
|
payload = avr_now_playing.get("payload") or {}
|
|
mid = payload.get("mid", "")
|
|
if mid.startswith("inputs/"):
|
|
current_input = {"code": mid, "name": payload.get("station") or payload.get("song") or mid}
|
|
snapshot["avr"] = {
|
|
"connected": host_pid is not None,
|
|
"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
|