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.
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),