+91
-12
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user