diff --git a/README.md b/README.md index 01da64f..be7ed30 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Everything it does fits on one screen: | --- | --- | --- | | Living Room | 2× Denon Home 200 as an In-Room Group | a **group** (`gid`) | | Lego Room | Denon Home 400 | a **player** (`pid`) | -| Home Cinema | Denon AVR-X3800H | a **player**, plus Telnet on port 23 | +| Home Cinema | Denon AVR-X3800H | a **player** | Any other mix works — it is all in `config.py`. @@ -67,15 +67,16 @@ ROOM_KEYS = ["home400", "living_room_group"] # the cards, in order ``` `HEOS_HOST` only needs to point at **one** device: HEOS is distributed, so any -unit can see and control the whole network. `AVR_HOST` must be the AVR itself. +unit can see and control the whole network — including the AVR's own inputs, +so there is nothing AVR-specific to configure beyond its entry in `TARGETS`. `VOLUME_STEP` is the grid the volume buttons snap to, not simply how much they add: at 5, a tap moves 23 to 25 and 25 to 30. -The input picker already leaves out sources switched off in the AVR's own -setup menu (it asks the AVR with `SSSOD ?`). `AVR_INPUT_CODES` narrows it -further to the sources you actually use, and sets their order; leave it empty -to list everything the AVR still has switched on. +`AVR_INPUT_CODES` narrows the input picker to the sources you actually use, +by their HEOS input id (`GET /api/avr/inputs` shows the exact strings, e.g. +`inputs/aux_in_1`), and sets their order; leave it empty to list everything +HEOS reports for it. ## Add it to the iOS home screen @@ -87,8 +88,9 @@ if you ever put it behind a domain name, give it HTTPS. ## Run it as a service -`deploy/heos-panel.service` runs the panel out of its own virtualenv and -restarts it if it dies: +`deploy/heos-panel.service` runs the panel out of its own virtualenv, under +`gunicorn` rather than `python3 app.py`'s dev server, and restarts it if it +dies: ```bash sudo cp deploy/heos-panel.service /etc/systemd/system/ @@ -98,6 +100,10 @@ sudo systemctl enable --now heos-panel Edit `User=` and the paths in it if you keep the panel somewhere else. +`python3 app.py` (no gunicorn) is still the right way to run it by hand +while working on it — see `--demo` below — the dev-server warning it prints +is expected there and only matters for the service above. + ## Deploying from Gitea `.gitea/workflows/deploy.yml` checks out the push, runs the tests, rsyncs @@ -235,20 +241,25 @@ a restart. If you would rather pin them down, list them in `config.py`: Leaving a room deliberately does *not* rewrite the AVR's group, so the other room's music does not restart. -## Two protocols, not one +**A newly joined room can stay silent on the AVR's input.** Joining alone +does not push audio to it — HEOS needs telling *again* which input is +playing before it streams that input to the new member, the same reselect +you'd otherwise do by hand in the HEOS app (Home → Sources → AV). `join()` +does this for you: it reads the AVR's current input back and replays it +through `browse/play_input` right after the group merge. -| | HEOS CLI (port 1255) | Denon Telnet (port 23) | -| --- | --- | --- | -| Speaks | JSON, `heos://player/...` | plain text, `SIGAME`, `SSFUN ?` | -| Used for | players, groups, volume | the AVR's **renamed** input list | +## One protocol, not two -HEOS only knows generic input ids like `inputs/hdmi_in_1`; the names you gave -your sources live in the AVR's own protocol, which is why both are here. +Everything goes over the HEOS CLI (port 1255) — players, groups, volume, and +the AVR's own inputs. `browse/browse` on the AVR's pid lists its inputs under +whatever names you gave them in its setup menu; HEOS reports those renamed +labels itself, so there used to be a second client here for the AVR's Denon +Telnet port just to fetch them, and it is not needed any more. -The Telnet connection is held open, so input changes made with the physical -remote show up in the panel too. Some Denon models only accept **one** Telnet -connection at a time — if another integration (Home Assistant, say) already -holds it, the AVR card will read `offline` while the HEOS half keeps working. +Selecting an input goes through `browse/play_input`, not a raw `SI` +Telnet command, for the same reason joining a room re-sends it (see above): +that is what actually tells HEOS to *stream* the input to whichever players +are grouped with the AVR, not just which jack the AVR itself is listening to. ## HTTP API @@ -261,10 +272,11 @@ Used by the interface: | `POST /api/volume` | `{"target": "home400", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute) | | `POST /api/mute` | `{"target": "home400"}` | | `POST /api/playback` | `{"target": "home400", "state": "pause"}`, or no `state` to toggle | +| `POST /api/skip` | `{"target": "home400", "direction": "next"}` — `previous` too | | `POST /api/group` | `{"target": "home400", "joined": true}` | | `POST /api/group/none` | every room back on its own | -| `GET /api/avr/inputs` | your renamed sources | -| `POST /api/avr/input` | `{"code": "GAME"}` | +| `GET /api/avr/inputs` | your renamed sources, over HEOS | +| `POST /api/avr/input` | `{"code": "inputs/aux_in_1"}` | `POST /volume/up` and `/volume/down` take one snapped tap by default; pass `?step=3` and they move that many raw points instead, as they always did. @@ -272,13 +284,12 @@ Used by the interface: The original bridge's endpoints still answer, so existing Shortcuts and scripts keep working: `/targets`, `/volume`, `/volume/{set,up,down,mute}`, `/playback/{play,pause,stop,next,previous}`, `/group/{create,remove}`, -`/inputs`, `/input/{set,relay}`, `/avr/{raw,input,inputs}`, `/raw/`. +`/inputs`, `/input/{set,relay}`, `/avr/{input,inputs}`, `/raw/`. -Two of them are worth keeping for troubleshooting: +Worth keeping for troubleshooting: ``` GET /raw/browse/browse?sid=1027 # any heos:// command, raw reply -GET /avr/raw?cmd=SSFUN ? # any Telnet command, every line back ``` ## Working on it @@ -293,11 +304,10 @@ python3 tools/make_icons.py # re-render the icons from static/lo ``` app.py Flask: the UI, the API, and the old bridge's routes -controller.py what a room is, what grouping means, volume +controller.py what a room is, what grouping means, volume, the AVR's inputs heos.py HEOS CLI client (persistent socket, reconnects itself) -avr.py Denon Telnet client + the renamed input list config.py your devices and preferences demo.py fake speakers for --demo templates/ static/ the interface -tests/ fake HEOS + AVR servers, and tests against them +tests/ a fake HEOS server, and tests against it ``` diff --git a/app.py b/app.py index 03a674d..2db8fbc 100644 --- a/app.py +++ b/app.py @@ -2,7 +2,11 @@ """HEOS panel: a phone-sized web remote plus the HTTP bridge it runs on. pip3 install -r requirements.txt - python3 app.py # http://:5443/ + python3 app.py # dev server, http://:5443/ + +The service instead runs this under gunicorn -- see deploy/heos-panel.service +-- which imports `app` without ever calling main(), so the controller below +is built at import time rather than from main()'s argparse. Everything the UI does goes through /api/*. The flatter, query-string endpoints from the original heos_bridge.py (/volume/up?target=..., and @@ -10,13 +14,13 @@ friends) are still here so existing Shortcuts and scripts keep working. """ import argparse +import os from functools import wraps from flask import Flask, jsonify, render_template, request from werkzeug.middleware.proxy_fix import ProxyFix import config -from avr import AvrError from controller import Controller, TargetError from heos import HeosError @@ -30,6 +34,16 @@ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) controller: Controller = None +if __name__ != "__main__": + # Imported by a WSGI server rather than run as a script, so main()'s + # argparse never executes -- build the one controller instance here + # instead. HEOS_DEMO lets the fake-speakers mode work this way too. + if os.environ.get("HEOS_DEMO", "").lower() in ("1", "true", "yes"): + from demo import DemoController + controller = DemoController(config) + else: + controller = Controller(config) + def handle_errors(view): """One place to turn our three failure modes into sensible JSON.""" @@ -39,7 +53,7 @@ def handle_errors(view): return view(*args, **kwargs) except (TargetError, ValueError) as exc: return jsonify({"error": str(exc)}), 400 - except (HeosError, AvrError) as exc: + except HeosError as exc: return jsonify({"error": str(exc)}), 502 return wrapped @@ -149,6 +163,19 @@ def api_playback(): return jsonify({"target": key, "state": controller.toggle_play(key, state)}) +@app.post("/api/skip") +@handle_errors +def api_skip(): + """Jump to the next (or previous) track in a room's queue.""" + data = _payload() + key = _target_from(data) + direction = data.get("direction", "next") + if direction not in ("next", "previous"): + raise ValueError("'direction' must be next or previous") + controller.skip(key, direction) + return jsonify({"target": key, "direction": direction}) + + @app.post("/api/group") @handle_errors def api_group(): @@ -174,8 +201,7 @@ def api_group_none(): @app.get("/api/avr/inputs") @handle_errors def api_avr_inputs(): - refresh = request.args.get("refresh") in ("1", "true", "yes") - return jsonify(controller.avr.inputs(refresh=refresh)) + return jsonify(controller.avr_inputs()) @app.post("/api/avr/input") @@ -183,8 +209,8 @@ def api_avr_inputs(): def api_avr_set_input(): code = _payload().get("code") if not code: - raise ValueError("Provide 'code', e.g. GAME -- see GET /api/avr/inputs") - return jsonify(controller.avr.select_input(code)) + raise ValueError("Provide 'code', e.g. inputs/aux_in_1 -- see GET /api/avr/inputs") + return jsonify(controller.avr_select_input(code)) # --- The original bridge's API, unchanged ----------------------------- @@ -311,30 +337,21 @@ def legacy_raw(subpath): return jsonify(controller.heos.command(subpath, **request.args.to_dict())) -@app.get("/avr/raw") -@handle_errors -def legacy_avr_raw(): - cmd = request.args.get("cmd") - if not cmd: - raise ValueError("provide '?cmd='") - return jsonify({"lines": controller.avr.telnet.request(cmd, timeout=3.0)}) - - @app.get("/avr/inputs") @handle_errors def legacy_avr_inputs(): - return jsonify(controller.avr.inputs(refresh=True)) + return jsonify(controller.avr_inputs()) @app.route("/avr/input", methods=["GET", "POST"]) @handle_errors def legacy_avr_input(): if request.method == "GET": - return jsonify(controller.avr.current_input() or {"error": "AVR not reachable"}) + return jsonify(controller.avr_current_input() or {"error": "AVR not reachable"}) code = request.args.get("input") if not code: - raise ValueError("provide '?input=', e.g. GAME, TV, CD, AUX1") - return jsonify(controller.avr.select_input(code)) + raise ValueError("provide '?input=', e.g. inputs/aux_in_1 -- see GET /avr/inputs") + return jsonify(controller.avr_select_input(code)) def main(): diff --git a/avr.py b/avr.py deleted file mode 100644 index 6a68b72..0000000 --- a/avr.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Denon AVR control over the classic Telnet protocol (TCP port 23). - -Nothing to do with HEOS. Commands are short plain-text strings ending in -a bare \\r: "SI?" asks which input is selected, "SIGAME" selects GAME, -"SSFUN ?" lists the sources *under the names you gave them* -- which is -the only reason we bother with this protocol at all, since HEOS only -ever reports generic identifiers like "inputs/hdmi_in_1". - -The AVR also pushes a line at us whenever anything changes, including -changes made from the physical remote. So instead of polling, we hold -the connection open, read continuously, and keep the last value of each -status prefix. Asking for the current input is then free. -""" - -import socket -import threading -import time - -# Status prefixes worth remembering from the AVR's chatter. -_TRACKED = ("SI", "PW", "MV", "MU") - -_LOG_LIMIT = 200 - - -class AvrError(RuntimeError): - pass - - -class DenonTelnet: - """Persistent listener + request/response on one Telnet connection.""" - - def __init__(self, host: str, port: int = 23, connect_timeout: float = 3.0): - self.host = host - self.port = port - self.connect_timeout = connect_timeout - self.status = {} # "SI" -> "MPLAY" - self.connected = False - self.last_error = None - self._sock = None - self._send_lock = threading.Lock() - self._cv = threading.Condition() - self._log = [] # [(seq, line)], newest last - self._seq = 0 - threading.Thread(target=self._listen_forever, daemon=True).start() - - # -- background reader --------------------------------------------- - def _listen_forever(self): - backoff = 1.0 - while True: - try: - self._open() - backoff = 1.0 - self._read_forever() - except OSError as exc: - self._drop(exc) - time.sleep(backoff) - backoff = min(30.0, backoff * 2) - - def _open(self): - sock = socket.create_connection((self.host, self.port), timeout=self.connect_timeout) - sock.settimeout(60.0) - self._sock = sock - self.connected = True - self.last_error = None - # Prime the status cache so the first page load knows the input. - for probe in ("PW?", "SI?"): - self.send(probe) - - def _read_forever(self): - buffer = b"" - while True: - try: - chunk = self._sock.recv(1024) - except socket.timeout: - continue # the AVR is simply quiet; nothing has changed - if not chunk: - raise ConnectionError("AVR closed the connection") - buffer += chunk - while b"\r" in buffer: - raw, buffer = buffer.split(b"\r", 1) - self._ingest(raw.decode("utf-8", "replace").strip()) - - def _drop(self, exc): - self.connected = False - self.last_error = str(exc) - if self._sock is not None: - try: - self._sock.close() - except OSError: - pass - self._sock = None - - def _ingest(self, line: str): - if not line: - return - with self._cv: - self._seq += 1 - self._log.append((self._seq, line)) - del self._log[:-_LOG_LIMIT] - for prefix in _TRACKED: - # SSFUN* also starts with 'SS', never with a tracked prefix, - # so a plain startswith is safe here. - if line.startswith(prefix) and len(line) > len(prefix): - self.status[prefix] = line[len(prefix):] - break - self._cv.notify_all() - - # -- sending ------------------------------------------------------- - def send(self, command: str): - sock = self._sock - if sock is None: - raise AvrError(f"AVR at {self.host} is not connected ({self.last_error or 'no connection'})") - with self._send_lock: - sock.sendall(command.encode("utf-8") + b"\r") - time.sleep(0.05) # the AVR wants a beat between commands - - def request(self, command: str, prefix: str = None, until=None, timeout: float = 2.5) -> list: - """Send a command and collect the reply lines it triggers. - - Returns as soon as a matching line arrives (or, with `until`, as - soon as that terminator line does), so a query costs milliseconds - rather than a fixed timeout. - """ - with self._cv: - cursor = self._seq - self.send(command) - - deadline = time.monotonic() + timeout - with self._cv: - while True: - lines = [ - line for seq, line in self._log - if seq > cursor and (prefix is None or line.startswith(prefix)) - ] - if lines and (until is None or any(until(line) for line in lines)): - return lines - remaining = deadline - time.monotonic() - if remaining <= 0: - return lines - self._cv.wait(remaining) - - def recent_lines(self) -> list: - with self._cv: - return [line for _, line in self._log] - - -def parse_ssfun(lines: list) -> list: - """Parse `SSFUN ?` output -- 'SSFUNBD Blu-ray ' and friends -- - into [{"code": "BD", "name": "Blu-ray"}, ...].""" - sources = [] - for line in lines: - if not line.startswith("SSFUN"): - continue - rest = line[len("SSFUN"):] - if rest.strip() in ("END", ""): - continue - parts = rest.split(" ", 1) - if len(parts) != 2: - continue - code, name = parts[0].strip(), parts[1].strip() - if code and name: - sources.append({"code": code, "name": name}) - return sources - - -def parse_sssod(lines: list) -> dict: - """Parse `SSSOD ?` output -- 'SSSODTUNER DEL' and friends -- into - {"TUNER": False, "CD": True, ...}, i.e. which sources you have left - switched on in the AVR's own setup menu.""" - usage = {} - for line in lines: - if not line.startswith("SSSOD"): - continue - rest = line[len("SSSOD"):].strip() - if rest in ("END", ""): - continue - code, _, value = rest.rpartition(" ") - code = code.strip() - if code: - usage[code] = value.strip().upper() != "DEL" - return usage - - -class AvrControl: - """The input list and the current input, in the names you chose.""" - - def __init__(self, host: str, port: int = 23, allowed_codes=()): - self.telnet = DenonTelnet(host, port) - self.allowed_codes = list(allowed_codes or []) - self._inputs = None - self._usage = None - - @property - def connected(self) -> bool: - return self.telnet.connected - - def all_inputs(self, refresh: bool = False) -> list: - """Every source the AVR knows, under your names, deleted ones - included. Cached: it only changes when you edit the setup menu.""" - if self._inputs is None or refresh: - lines = self.telnet.request( - "SSFUN ?", prefix="SSFUN", - until=lambda line: line.strip() == "SSFUN END", - timeout=3.0, - ) - sources = parse_ssfun(lines) - if sources: - self._inputs = sources - if self._usage is None or refresh: - lines = self.telnet.request( - "SSSOD ?", prefix="SSSOD", - until=lambda line: line.strip() == "SSSOD END", - timeout=3.0, - ) - self._usage = parse_sssod(lines) - return self._inputs or [] - - def inputs(self, refresh: bool = False) -> list: - """What the picker offers: the sources you can actually select. - - Sources you deleted in the AVR's setup menu are left out -- they - are exactly the ones you never want to land on. Anything SSSOD - does not mention is kept, so a model that does not answer that - command shows its whole list rather than nothing at all. - """ - sources = [s for s in self.all_inputs(refresh) if self._usage.get(s["code"], True)] - if self.allowed_codes: - order = {code: i for i, code in enumerate(self.allowed_codes)} - sources = sorted( - (s for s in sources if s["code"] in order), - key=lambda s: order[s["code"]], - ) - return sources - - def current_input(self) -> dict: - """{"code": "MPLAY", "name": "Apple TV"} -- the name comes from the - cached source list, the code from the AVR's own push messages.""" - code = self.telnet.status.get("SI") - if code is None: - lines = self.telnet.request("SI?", prefix="SI") - code = lines[0][2:] if lines else None - if code is None: - return None - return {"code": code, "name": self.name_for(code)} - - def name_for(self, code: str) -> str: - for source in self.all_inputs(): - if source["code"] == code: - return source["name"] - return code - - def select_input(self, code: str) -> dict: - self.telnet.request(f"SI{code}", prefix="SI", timeout=1.5) - self.telnet.status["SI"] = code # trust our own command immediately - return {"code": code, "name": self.name_for(code)} diff --git a/config.py b/config.py index 42694f6..0c71043 100644 --- a/config.py +++ b/config.py @@ -12,12 +12,6 @@ reports for your own devices. HEOS_HOST = "192.168.0.10" HEOS_PORT = 1255 -# The AVR's classic Denon Telnet control port. Completely separate from -# HEOS, and the only place your *renamed* input list actually lives -- -# HEOS itself only knows a fixed set of generic input identifiers. -AVR_HOST = "192.168.0.10" -AVR_PORT = 23 - # Port the panel itself listens on. WEB_PORT = 5443 @@ -59,10 +53,10 @@ ROOM_KEYS = ["home400", "living_room_group"] # this rather than adding it, so at 5 a level of 23 goes to 25, not 28. VOLUME_STEP = 5 -# Sources you deleted in the AVR's setup menu are hidden from the picker -# automatically. This narrows it further to the ones you actually use, by -# their SI code (see GET /api/avr/inputs), and sets their order in the -# list. Empty = every source the AVR still has switched on. +# Narrows the AVR's input picker to the sources you actually use, by their +# HEOS input id (see GET /api/avr/inputs for the exact strings, e.g. +# "inputs/aux_in_1"), and sets their order in the list. Empty = every +# source HEOS reports for it. AVR_INPUT_CODES = [] # Shown as the app's name on the iOS home screen. diff --git a/controller.py b/controller.py index 0147995..8e0a786 100644 --- a/controller.py +++ b/controller.py @@ -1,4 +1,4 @@ -"""The actual behaviour of the panel, on top of the two protocol clients. +"""The actual behaviour of the panel, on top of the HEOS CLI client. The interesting part is grouping. A HEOS "In-Room Group" (your pair of Home 200s) is addressed by a gid and behaves like one speaker -- until @@ -10,6 +10,12 @@ Two consequences drive most of the code below: or the second Home 200 gets left behind. * Unmerging must re-issue set_group with the pair's own pids to put the pair back together, so we have to remember what they were. + +Everything, including the AVR's own inputs, goes over the one HEOS +connection now -- there used to be a second client here for the AVR's +Denon Telnet port, only for its renamed input list, but HEOS reports +those same renamed names itself (browse/browse on the AVR's own pid), +so the Telnet side added a protocol for no remaining benefit. """ import json @@ -17,7 +23,6 @@ import threading import time from pathlib import Path -from avr import AvrControl, AvrError from heos import HeosClient, HeosError, parse_message MEMBERS_FILE = Path(__file__).with_name("members.json") @@ -50,7 +55,6 @@ class Controller: def __init__(self, cfg): self.cfg = cfg self.heos = HeosClient(cfg.HEOS_HOST, cfg.HEOS_PORT) - self.avr = AvrControl(cfg.AVR_HOST, cfg.AVR_PORT, cfg.AVR_INPUT_CODES) self._lock = threading.RLock() self._members_file = Path(getattr(cfg, "MEMBERS_FILE", MEMBERS_FILE)) self._learned = _load_learned(self._members_file) @@ -312,8 +316,26 @@ class Controller: wanted = set(self.joined_keys()) | {key} self.group_targets(self.cfg.HOST_KEY, [k for k in self.cfg.ROOM_KEYS if k in wanted]) self.scan() + self._nudge_avr_input() return self.joined_keys() + def _nudge_avr_input(self): + """A room that has just joined the AVR's group sometimes stays + silent on it until the AVR's input is reselected -- but that has to + happen the way the HEOS app does it (browse/play_input, over HEOS + itself) to actually push audio to the new member. Re-issuing the + input over the AVR's own Telnet port does not: that just tells the + AVR which of its jacks to listen to, it says nothing to HEOS about + who should be streaming it. Best-effort: a join has already + succeeded by the time this runs, so a HEOS hiccup here should not + turn it into a failure.""" + try: + current = self.avr_current_input() + if current: + self.play_heos_input(self.cfg.HOST_KEY, current["code"]) + except HeosError: + pass + def leave(self, key: str) -> list: """Remove one room. Deliberately does not rewrite the AVR's group: whatever is still joined keeps playing without a hiccup.""" @@ -378,7 +400,10 @@ class Controller: self.heos.command(f"player/{command}", pid=self.playback_pid(key)) def heos_inputs(self, key: str) -> list: - """Physical inputs as HEOS sees them (generic ids, not your names).""" + """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.""" with self._lock: self._fresh() reply = self.heos.command("browse/browse", sid=self.playback_pid(key)) @@ -392,6 +417,54 @@ class Controller: params["spid"] = self.playback_pid(source_key) self.heos.command("browse/play_input", **params) + # -- the AVR's inputs, all of it over HEOS ------------------------------ + def avr_connected(self) -> bool: + with self._lock: + self._fresh() + try: + self._host_pid() + return True + except TargetError: + return False + + def avr_inputs(self) -> list: + """{"code", "name"} pairs for the picker -- heos_inputs()'s shape, + renamed to match what the UI and /api/avr/* already send and + expect. Narrowed and ordered by AVR_INPUT_CODES, same as before, + except the codes it matches are now HEOS's own ("inputs/aux_in_1"), + not the AVR's Telnet ones ("AUX1").""" + sources = self.heos_inputs(self.cfg.HOST_KEY) + codes = getattr(self.cfg, "AVR_INPUT_CODES", None) + if codes: + order = {code: i for i, code in enumerate(codes)} + sources = sorted( + (s for s in sources if s["input_id"] in order), + key=lambda s: order[s["input_id"]], + ) + return [{"code": s["input_id"], "name": s["name"]} for s in sources] + + def avr_current_input(self): + """{"code", "name"} for whatever the AVR is playing right now, or + None if that is not a local input (or the AVR is unreachable).""" + with self._lock: + self._fresh() + try: + reply = self.heos.command( + "player/get_now_playing_media", pid=self._host_pid() + ) + except (TargetError, HeosError): + return None + payload = reply.get("payload") or {} + mid = payload.get("mid", "") + if not mid.startswith("inputs/"): + return None + return {"code": mid, "name": payload.get("station") or payload.get("song") or mid} + + def avr_select_input(self, code: str) -> dict: + self.play_heos_input(self.cfg.HOST_KEY, code) + name = next((s["name"] for s in self.avr_inputs() if s["code"] == code), code) + return {"code": code, "name": name} + # -- one snapshot for the UI ------------------------------------------ def state(self) -> dict: snapshot = { @@ -435,11 +508,11 @@ class Controller: try: snapshot["avr"] = { - "connected": self.avr.connected, - "inputs": self.avr.inputs(), - "input": self.avr.current_input(), + "connected": self.avr_connected(), + "inputs": self.avr_inputs(), + "input": self.avr_current_input(), } - except (AvrError, OSError) as exc: + except (HeosError, TargetError) as exc: snapshot["errors"].append(str(exc)) return snapshot diff --git a/demo.py b/demo.py index 6865a8c..37b0e93 100644 --- a/demo.py +++ b/demo.py @@ -133,3 +133,13 @@ class DemoController: def play_heos_input(self, key, input_id, source_key=None): return None + + # -- the AVR: app.py calls these directly, same as the real Controller + def avr_inputs(self): + return self.avr.inputs() + + def avr_current_input(self): + return self.avr.current_input() + + def avr_select_input(self, code): + return self.avr.select_input(code) diff --git a/deploy/heos-panel.service b/deploy/heos-panel.service index d555422..ceb433b 100644 --- a/deploy/heos-panel.service +++ b/deploy/heos-panel.service @@ -22,8 +22,14 @@ Type=simple User=franzz Group=www-data WorkingDirectory=/var/www/html/heos -ExecStart=/var/www/html/heos/.venv/bin/python /var/www/html/heos/app.py -# Add --host 127.0.0.1 above to allow only the reverse proxy in. +ExecStart=/var/www/html/heos/.venv/bin/gunicorn --worker-class gthread --workers 1 --threads 8 --bind 0.0.0.0:5443 app:app +# Bind 127.0.0.1:5443 above to allow only the reverse proxy in. +# +# --workers stays at 1 on purpose: the Controller holds the one persistent +# AVR Telnet connection and HEOS heartbeat thread, and gunicorn's workers +# are separate processes -- more than one would open a second Telnet +# connection, which some Denon models refuse. --threads is what gives it +# concurrency instead, same as app.py's own threaded=True dev server. Restart=always RestartSec=3 diff --git a/requirements.txt b/requirements.txt index 001e7c4..a7a45bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ flask>=3.0 +gunicorn>=21 diff --git a/static/app.js b/static/app.js index 167e7fd..419eead 100644 --- a/static/app.js +++ b/static/app.js @@ -61,6 +61,7 @@ els('.room').forEach((node) => { toggle: el('[data-role="group"]', node), toggleLabel: el('[data-role="group-label"]', node), play: el('[data-role="play"]', node), + next: el('[data-role="next"]', node), steps: els('.step', node), volume: null, playState: null, @@ -80,6 +81,7 @@ els('.room').forEach((node) => { room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped)); room.play.addEventListener('click', () => togglePlay(key)); + room.next.addEventListener('click', () => skipTrack(key)); }); function paintRoom(room) { @@ -93,6 +95,12 @@ function paintRoom(room) { room.play.disabled = !room.available || room.playState === null; room.play.setAttribute( 'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`); + // Grouped, transport belongs to the AVR's card, not this one -- pressing + // either here would still work (it shares the group's transport) but + // only invites confusion about which card is actually in charge of it. + room.play.hidden = room.grouped; + room.next.hidden = !playing || room.grouped; + room.next.disabled = !room.available; room.toggle.disabled = !room.available; room.toggle.classList.toggle('busy', room.busy); @@ -225,6 +233,18 @@ async function togglePlay(key) { } } +/* Fire-and-forget: the queue's next track has no local state to reconcile, + so there is nothing to optimistically flip the way play/pause does. */ +async function skipTrack(key) { + const room = rooms[key]; + if (!room.available) return; + try { + await api('/api/skip', { target: key, direction: 'next' }); + } catch (error) { + toast(error.message); + } +} + function applyJoined(joined) { Object.values(rooms).forEach((room) => { room.grouped = joined.includes(room.key); diff --git a/static/panel.css b/static/panel.css index 1256cc7..1cf7fa7 100644 --- a/static/panel.css +++ b/static/panel.css @@ -124,7 +124,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: .transport { flex: 0 0 auto; - width: 66px; height: 52px; + width: 66px; height: 62px; border-radius: 16px; background: var(--raised); display: grid; place-items: center; @@ -136,6 +136,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: everything else in here uses. */ .transport .icon-play { fill: currentColor; stroke: none; } .transport .icon-pause { stroke-width: 2.4; } +.transport[data-role="next"] svg { fill: currentColor; stroke: none; } /* Which icon shows is a class on the button, not `hidden` on the svg: `hidden` is an HTMLElement property and SVGElement does not inherit it, @@ -146,7 +147,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: /* --- join / leave the AVR -------------------------------------------- */ .toggle { - height: 52px; border-radius: 16px; + height: 62px; border-radius: 16px; background: var(--raised); display: flex; align-items: center; justify-content: center; gap: 10px; font-size: 15px; font-weight: 550; @@ -191,7 +192,6 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: background: none; border: 1px solid var(--edge); color: var(--muted); } .joined .toggle .dot { display: none; } -.joined .transport { height: 40px; width: 58px; } .joined .toggle:active { background: var(--raised); color: var(--ink); } /* --- misc ------------------------------------------------------------- */ diff --git a/templates/index.html b/templates/index.html index a6e75a7..e3a604f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -64,6 +64,10 @@ + +