diff --git a/controller.py b/controller.py index 15d0185..d82e0d5 100644 --- a/controller.py +++ b/controller.py @@ -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), diff --git a/heos.py b/heos.py index 8361aa8..ac4101f 100644 --- a/heos.py +++ b/heos.py @@ -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