Optimize api/state
Deploy HEOS panel / deploy (push) Successful in 26s

This commit is contained in:
2026-09-17 16:15:59 +02:00
parent 74c232e678
commit 053be20b9c
2 changed files with 157 additions and 12 deletions
+91 -12
View File
@@ -48,6 +48,11 @@ class Controller:
# HEOS's source id for Spotify, in get_now_playing_media's payload. # HEOS's source id for Spotify, in get_now_playing_media's payload.
SPOTIFY_SID = "4" 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): def __init__(self, cfg):
self.cfg = cfg self.cfg = cfg
self.heos = HeosClient(cfg.HEOS_HOST, cfg.HEOS_PORT) self.heos = HeosClient(cfg.HEOS_HOST, cfg.HEOS_PORT)
@@ -57,6 +62,7 @@ class Controller:
self._players = [] self._players = []
self._groups = [] self._groups = []
self._scanned_at = 0.0 self._scanned_at = 0.0
self._inputs_cache = {} # key -> (inputs, cached_at)
threading.Thread(target=self._keep_warm, daemon=True).start() threading.Thread(target=self._keep_warm, daemon=True).start()
def _keep_warm(self): def _keep_warm(self):
@@ -71,10 +77,18 @@ class Controller:
# -- network picture ----------------------------------------------- # -- network picture -----------------------------------------------
def scan(self) -> dict: def scan(self) -> dict:
"""Re-read every player and group.""" """Re-read every player and group, in one round trip."""
with self._lock: with self._lock:
self._players = self.heos.command("player/get_players").get("payload", []) players_reply, groups_reply = self.heos.command_batch([
self._groups = self.heos.command("group/get_groups").get("payload", []) ("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() self._scanned_at = time.monotonic()
return {"players": self._players, "groups": self._groups} return {"players": self._players, "groups": self._groups}
@@ -356,11 +370,19 @@ class Controller:
"""A player's physical inputs, as HEOS itself lists them -- under """A player's physical inputs, as HEOS itself lists them -- under
whatever names you gave them in the AVR's own setup menu. HEOS 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 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: with self._lock:
self._fresh() self._fresh()
reply = self.heos.command("browse/browse", sid=self.playback_pid(key)) 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): def play_heos_input(self, key: str, input_id: str, source_key: str = None):
with self._lock: with self._lock:
@@ -427,6 +449,12 @@ class Controller:
# -- one snapshot for the UI ------------------------------------------ # -- one snapshot for the UI ------------------------------------------
def state(self) -> dict: 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 = { snapshot = {
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]}, "host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]},
"rooms": [], "rooms": [],
@@ -435,10 +463,44 @@ class Controller:
"errors": [], "errors": [],
} }
host_pid = None
avr_now_playing = None
with self._lock: with self._lock:
try: try:
self.scan() self.scan()
joined = set(self.joined_keys()) 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: for key in self.cfg.ROOM_KEYS:
room = { room = {
"key": key, "key": key,
@@ -452,17 +514,29 @@ class Controller:
"error": None, "error": None,
} }
try: try:
scope, obj_id = self._volume_handles(key)[0] if key in room_errors:
room["volume"] = self._read_volume(scope, obj_id) raise room_errors[key]
room["play_state"] = self.get_play_state(key) data = by_room.get(key, {})
media = self.now_playing_media(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) room["spotify"] = self._is_spotify(media)
# Kept while paused, so the card does not jump about # Kept while paused, so the card does not jump about
# under the thumb that just pressed pause. # under the thumb that just pressed pause.
if room["play_state"] != "stop": if room["play_state"] != "stop":
track = self._track(media) track = self._track(media)
if track is not None: 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"]: if progress and progress["duration"]:
track["position_ms"] = progress["cur_pos"] track["position_ms"] = progress["cur_pos"]
track["duration_ms"] = progress["duration"] track["duration_ms"] = progress["duration"]
@@ -482,9 +556,14 @@ class Controller:
] ]
try: 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"] = { snapshot["avr"] = {
"connected": self.avr_connected(), "connected": host_pid is not None,
"inputs": self.avr_inputs(), "inputs": self.avr_inputs(),
"input": current_input, "input": current_input,
"now_playing": self.zidoo_now_playing(current_input), "now_playing": self.zidoo_now_playing(current_input),
+66
View File
@@ -127,6 +127,72 @@ class HeosClient:
raise HeosError(_failure_text(heos)) raise HeosError(_failure_text(heos))
return reply 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): def _watch_progress(self, heos: dict):
"""Skims a passing player_now_playing_progress event for its """Skims a passing player_now_playing_progress event for its
pid/cur_pos/duration, the only source of song-position data this pid/cur_pos/duration, the only source of song-position data this