+91
-12
@@ -48,6 +48,11 @@ 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)
|
||||
@@ -57,6 +62,7 @@ class Controller:
|
||||
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):
|
||||
@@ -71,10 +77,18 @@ class Controller:
|
||||
|
||||
# -- network picture -----------------------------------------------
|
||||
def scan(self) -> dict:
|
||||
"""Re-read every player and group."""
|
||||
"""Re-read every player and group, in one round trip."""
|
||||
with self._lock:
|
||||
self._players = self.heos.command("player/get_players").get("payload", [])
|
||||
self._groups = self.heos.command("group/get_groups").get("payload", [])
|
||||
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}
|
||||
|
||||
@@ -356,11 +370,19 @@ class Controller:
|
||||
"""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."""
|
||||
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))
|
||||
return [{"name": i.get("name"), "input_id": i.get("mid")} for i in reply.get("payload", [])]
|
||||
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:
|
||||
@@ -427,6 +449,12 @@ class Controller:
|
||||
|
||||
# -- 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": [],
|
||||
@@ -435,10 +463,44 @@ class Controller:
|
||||
"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,
|
||||
@@ -452,17 +514,29 @@ class Controller:
|
||||
"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)
|
||||
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(self.playback_pid(key))
|
||||
progress = self.heos.progress_for(room_pids[key])
|
||||
if progress and progress["duration"]:
|
||||
track["position_ms"] = progress["cur_pos"]
|
||||
track["duration_ms"] = progress["duration"]
|
||||
@@ -482,9 +556,14 @@ class Controller:
|
||||
]
|
||||
|
||||
try:
|
||||
current_input = self.avr_current_input()
|
||||
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": self.avr_connected(),
|
||||
"connected": host_pid is not None,
|
||||
"inputs": self.avr_inputs(),
|
||||
"input": current_input,
|
||||
"now_playing": self.zidoo_now_playing(current_input),
|
||||
|
||||
@@ -127,6 +127,72 @@ class HeosClient:
|
||||
raise HeosError(_failure_text(heos))
|
||||
return reply
|
||||
|
||||
def command_batch(self, requests: list) -> list:
|
||||
"""Send several commands back-to-back on the one socket, then
|
||||
collect their replies as they come in -- so N independent reads
|
||||
(a room's volume, play state and now-playing, say) cost one round
|
||||
trip's worth of latency instead of N.
|
||||
|
||||
`requests` is a list of (path, params) pairs. Returns one entry
|
||||
per request, in the same order: the parsed reply dict, or the
|
||||
HeosError it failed with -- a single command failing does not
|
||||
sink the rest of the batch. Only a connection-level problem
|
||||
raises, same as command().
|
||||
"""
|
||||
with self._lock:
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
if self._sock is None:
|
||||
self._connect()
|
||||
return self._exchange_batch(requests)
|
||||
except (OSError, ValueError) as exc:
|
||||
self._close()
|
||||
if attempt == 2:
|
||||
raise HeosError(f"cannot reach HEOS at {self.host}: {exc}") from exc
|
||||
|
||||
def _exchange_batch(self, requests: list) -> list:
|
||||
for path, params in requests:
|
||||
query = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
||||
command = f"heos://{path}" + (f"?{query}" if query else "")
|
||||
self._sock.sendall(command.encode("utf-8") + b"\r\n")
|
||||
|
||||
results = [None] * len(requests)
|
||||
pending = list(range(len(requests)))
|
||||
deadline = time.monotonic() + self.timeout * 3
|
||||
while pending:
|
||||
reply = json.loads(self._read_line(deadline).decode("utf-8"))
|
||||
heos = reply.get("heos", {})
|
||||
if "under process" in heos.get("message", ""):
|
||||
continue # placeholder ack; the real payload follows
|
||||
|
||||
index = self._claim(pending, requests, heos)
|
||||
if index is None:
|
||||
self._watch_progress(heos)
|
||||
continue # an event, or a reply to a command outside this batch
|
||||
pending.remove(index)
|
||||
results[index] = HeosError(_failure_text(heos)) if heos.get("result") == "fail" else reply
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _claim(pending, requests, heos):
|
||||
"""Which pending request this reply answers. Matches on path
|
||||
first; when more than one pending request shares a path (e.g.
|
||||
get_now_playing_media for two different pids), whichever id
|
||||
parameter -- pid/gid/sid -- the request carried ties it to the
|
||||
right reply, since HEOS echoes it back in the message."""
|
||||
candidates = [i for i in pending if requests[i][0] == heos.get("command")]
|
||||
if not candidates:
|
||||
return None
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
message = parse_message(heos.get("message", ""))
|
||||
for i in candidates:
|
||||
params = requests[i][1]
|
||||
for id_key in ("pid", "gid", "sid"):
|
||||
if id_key in params and message.get(id_key) == str(params[id_key]):
|
||||
return i
|
||||
return candidates[0] # can't tell them apart; oldest first
|
||||
|
||||
def _watch_progress(self, heos: dict):
|
||||
"""Skims a passing player_now_playing_progress event for its
|
||||
pid/cur_pos/duration, the only source of song-position data this
|
||||
|
||||
Reference in New Issue
Block a user