Files
heos/heos.py
T
franzz 053be20b9c
Deploy HEOS panel / deploy (push) Successful in 26s
Optimize api/state
2026-09-17 16:15:59 +02:00

231 lines
9.3 KiB
Python

"""HEOS CLI client (TCP port 1255).
HEOS is not JSON-RPC or anything else standard: you send one
`heos://group/command?a=1&b=2` line and read back one JSON line. This
keeps the socket open between commands -- the panel fires a command on
every button press, and a fresh TCP handshake per press is what made
the original bridge feel sluggish.
"""
import json
import socket
import threading
import time
from urllib.parse import unquote_plus
class HeosError(RuntimeError):
"""The device answered, but said no (or never answered at all)."""
def parse_message(message: str) -> dict:
"""Turn a HEOS `heos.message` string into a dict.
e.g. "pid=12345&level=23" -> {"pid": "12345", "level": "23"}
"""
result = {}
for part in message.split("&"):
if "=" in part:
key, value = part.split("=", 1)
result[key] = unquote_plus(value)
elif part:
result[part] = ""
return result
class HeosClient:
"""One persistent, lock-guarded, self-healing HEOS connection."""
def __init__(self, host: str, port: int = 1255, timeout: float = 5.0):
self.host = host
self.port = port
self.timeout = timeout
self._sock = None
self._buffer = b""
self._lock = threading.Lock()
# pid -> {"cur_pos", "duration"}, filled in from whatever
# unsolicited progress events turn up while we are reading the
# reply to some other command. Dropped on reconnect, since a gap
# in the connection is a gap in what we know.
self._progress = {}
# -- connection management -----------------------------------------
def _connect(self):
self._close()
sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
sock.settimeout(self.timeout)
self._sock = sock
self._buffer = b""
self._progress = {}
# Without this, HEOS never pushes the now-playing-progress events
# that _exchange() below watches for. Best-effort: a player that
# refuses it still works, it just never shows song progress.
try:
self._exchange("system/register_for_change_events", {"enable": "on"})
except (OSError, HeosError):
pass
def _close(self):
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
self._buffer = b""
def _read_line(self, deadline: float) -> bytes:
while b"\r\n" not in self._buffer:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("no reply from HEOS in time")
self._sock.settimeout(remaining)
chunk = self._sock.recv(4096)
if not chunk:
raise ConnectionError("HEOS closed the connection")
self._buffer += chunk
line, self._buffer = self._buffer.split(b"\r\n", 1)
return line
# -- the one method everything else goes through -------------------
def command(self, path: str, **params) -> dict:
"""Send `heos://<path>?<params>` and return the parsed reply.
Retries once on a socket-level problem, because HEOS quietly
drops connections that have been idle for a few minutes.
"""
with self._lock:
for attempt in (1, 2):
try:
if self._sock is None:
self._connect()
return self._exchange(path, params)
except (OSError, ValueError) as exc:
# ValueError covers a desynced stream (bad JSON): in
# both cases the fix is the same, start a fresh socket.
self._close()
if attempt == 2:
raise HeosError(f"cannot reach HEOS at {self.host}: {exc}") from exc
def _exchange(self, path: str, params: dict) -> dict:
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")
# Browse calls can take a while, hence the generous overall budget.
deadline = time.monotonic() + self.timeout * 3
while True:
reply = json.loads(self._read_line(deadline).decode("utf-8"))
heos = reply.get("heos", {})
if heos.get("command") != path:
self._watch_progress(heos)
continue # an event or a late reply to something else
if "under process" in heos.get("message", ""):
continue # placeholder ack; the real payload follows
if heos.get("result") == "fail":
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
client has -- there is no dedicated listener, so this only catches
what happens to arrive while some other command is being read."""
if heos.get("command") != "event/player_now_playing_progress":
return
message = parse_message(heos.get("message", ""))
pid = message.get("pid")
if not pid:
return
try:
self._progress[pid] = {
"cur_pos": int(message.get("cur_pos", 0)),
"duration": int(message.get("duration", 0)),
}
except ValueError:
pass
def progress_for(self, pid) -> dict:
"""{"cur_pos", "duration"} in ms for a player, or None if no
progress event for it has come through yet this connection."""
return self._progress.get(str(pid))
def heart_beat(self):
"""Keep the socket warm so the first press after an idle spell is
as fast as the rest."""
self.command("system/heart_beat")
def _failure_text(heos: dict) -> str:
message = parse_message(heos.get("message", ""))
text = message.get("text") or "unknown error"
eid = message.get("eid")
return f"HEOS refused '{heos.get('command')}': {text}" + (f" (eid {eid})" if eid else "")