fix AVR input swap
Deploy HEOS panel / deploy (push) Successful in 25s

This commit is contained in:
2026-09-15 17:20:24 +02:00
parent dc7f09052d
commit 6a0f1fa5e8
13 changed files with 272 additions and 410 deletions
+37 -27
View File
@@ -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`) | | Living Room | 2× Denon Home 200 as an In-Room Group | a **group** (`gid`) |
| Lego Room | Denon Home 400 | a **player** (`pid`) | | 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`. 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 `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 `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. 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 `AVR_INPUT_CODES` narrows the input picker to the sources you actually use,
setup menu (it asks the AVR with `SSSOD ?`). `AVR_INPUT_CODES` narrows it by their HEOS input id (`GET /api/avr/inputs` shows the exact strings, e.g.
further to the sources you actually use, and sets their order; leave it empty `inputs/aux_in_1`), and sets their order; leave it empty to list everything
to list everything the AVR still has switched on. HEOS reports for it.
## Add it to the iOS home screen ## 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 ## Run it as a service
`deploy/heos-panel.service` runs the panel out of its own virtualenv and `deploy/heos-panel.service` runs the panel out of its own virtualenv, under
restarts it if it dies: `gunicorn` rather than `python3 app.py`'s dev server, and restarts it if it
dies:
```bash ```bash
sudo cp deploy/heos-panel.service /etc/systemd/system/ 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. 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 ## Deploying from Gitea
`.gitea/workflows/deploy.yml` checks out the push, runs the tests, rsyncs `.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 Leaving a room deliberately does *not* rewrite the AVR's group, so the other
room's music does not restart. 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) | ## One protocol, not two
| --- | --- | --- |
| Speaks | JSON, `heos://player/...` | plain text, `SIGAME`, `SSFUN ?` |
| Used for | players, groups, volume | the AVR's **renamed** input list |
HEOS only knows generic input ids like `inputs/hdmi_in_1`; the names you gave Everything goes over the HEOS CLI (port 1255) — players, groups, volume, and
your sources live in the AVR's own protocol, which is why both are here. 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 Selecting an input goes through `browse/play_input`, not a raw `SI<code>`
remote show up in the panel too. Some Denon models only accept **one** Telnet Telnet command, for the same reason joining a room re-sends it (see above):
connection at a time — if another integration (Home Assistant, say) already that is what actually tells HEOS to *stream* the input to whichever players
holds it, the AVR card will read `offline` while the HEOS half keeps working. are grouped with the AVR, not just which jack the AVR itself is listening to.
## HTTP API ## 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/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/mute` | `{"target": "home400"}` |
| `POST /api/playback` | `{"target": "home400", "state": "pause"}`, or no `state` to toggle | | `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` | `{"target": "home400", "joined": true}` |
| `POST /api/group/none` | every room back on its own | | `POST /api/group/none` | every room back on its own |
| `GET /api/avr/inputs` | your renamed sources | | `GET /api/avr/inputs` | your renamed sources, over HEOS |
| `POST /api/avr/input` | `{"code": "GAME"}` | | `POST /api/avr/input` | `{"code": "inputs/aux_in_1"}` |
`POST /volume/up` and `/volume/down` take one snapped tap by default; pass `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. `?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 The original bridge's endpoints still answer, so existing Shortcuts and
scripts keep working: `/targets`, `/volume`, `/volume/{set,up,down,mute}`, scripts keep working: `/targets`, `/volume`, `/volume/{set,up,down,mute}`,
`/playback/{play,pause,stop,next,previous}`, `/group/{create,remove}`, `/playback/{play,pause,stop,next,previous}`, `/group/{create,remove}`,
`/inputs`, `/input/{set,relay}`, `/avr/{raw,input,inputs}`, `/raw/<command>`. `/inputs`, `/input/{set,relay}`, `/avr/{input,inputs}`, `/raw/<command>`.
Two of them are worth keeping for troubleshooting: Worth keeping for troubleshooting:
``` ```
GET /raw/browse/browse?sid=1027 # any heos:// command, raw reply 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 ## 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 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) heos.py HEOS CLI client (persistent socket, reconnects itself)
avr.py Denon Telnet client + the renamed input list
config.py your devices and preferences config.py your devices and preferences
demo.py fake speakers for --demo demo.py fake speakers for --demo
templates/ static/ the interface templates/ static/ the interface
tests/ fake HEOS + AVR servers, and tests against them tests/ a fake HEOS server, and tests against it
``` ```
+37 -20
View File
@@ -2,7 +2,11 @@
"""HEOS panel: a phone-sized web remote plus the HTTP bridge it runs on. """HEOS panel: a phone-sized web remote plus the HTTP bridge it runs on.
pip3 install -r requirements.txt pip3 install -r requirements.txt
python3 app.py # http://<pi-ip>:5443/ python3 app.py # dev server, http://<pi-ip>: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 Everything the UI does goes through /api/*. The flatter, query-string
endpoints from the original heos_bridge.py (/volume/up?target=..., and 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 argparse
import os
from functools import wraps from functools import wraps
from flask import Flask, jsonify, render_template, request from flask import Flask, jsonify, render_template, request
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
import config import config
from avr import AvrError
from controller import Controller, TargetError from controller import Controller, TargetError
from heos import HeosError 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 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): def handle_errors(view):
"""One place to turn our three failure modes into sensible JSON.""" """One place to turn our three failure modes into sensible JSON."""
@@ -39,7 +53,7 @@ def handle_errors(view):
return view(*args, **kwargs) return view(*args, **kwargs)
except (TargetError, ValueError) as exc: except (TargetError, ValueError) as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
except (HeosError, AvrError) as exc: except HeosError as exc:
return jsonify({"error": str(exc)}), 502 return jsonify({"error": str(exc)}), 502
return wrapped return wrapped
@@ -149,6 +163,19 @@ def api_playback():
return jsonify({"target": key, "state": controller.toggle_play(key, state)}) 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") @app.post("/api/group")
@handle_errors @handle_errors
def api_group(): def api_group():
@@ -174,8 +201,7 @@ def api_group_none():
@app.get("/api/avr/inputs") @app.get("/api/avr/inputs")
@handle_errors @handle_errors
def api_avr_inputs(): def api_avr_inputs():
refresh = request.args.get("refresh") in ("1", "true", "yes") return jsonify(controller.avr_inputs())
return jsonify(controller.avr.inputs(refresh=refresh))
@app.post("/api/avr/input") @app.post("/api/avr/input")
@@ -183,8 +209,8 @@ def api_avr_inputs():
def api_avr_set_input(): def api_avr_set_input():
code = _payload().get("code") code = _payload().get("code")
if not code: if not code:
raise ValueError("Provide 'code', e.g. GAME -- see GET /api/avr/inputs") raise ValueError("Provide 'code', e.g. inputs/aux_in_1 -- see GET /api/avr/inputs")
return jsonify(controller.avr.select_input(code)) return jsonify(controller.avr_select_input(code))
# --- The original bridge's API, unchanged ----------------------------- # --- The original bridge's API, unchanged -----------------------------
@@ -311,30 +337,21 @@ def legacy_raw(subpath):
return jsonify(controller.heos.command(subpath, **request.args.to_dict())) 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=<raw telnet command>'")
return jsonify({"lines": controller.avr.telnet.request(cmd, timeout=3.0)})
@app.get("/avr/inputs") @app.get("/avr/inputs")
@handle_errors @handle_errors
def legacy_avr_inputs(): def legacy_avr_inputs():
return jsonify(controller.avr.inputs(refresh=True)) return jsonify(controller.avr_inputs())
@app.route("/avr/input", methods=["GET", "POST"]) @app.route("/avr/input", methods=["GET", "POST"])
@handle_errors @handle_errors
def legacy_avr_input(): def legacy_avr_input():
if request.method == "GET": 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") code = request.args.get("input")
if not code: if not code:
raise ValueError("provide '?input=<code>', e.g. GAME, TV, CD, AUX1") raise ValueError("provide '?input=<code>', e.g. inputs/aux_in_1 -- see GET /avr/inputs")
return jsonify(controller.avr.select_input(code)) return jsonify(controller.avr_select_input(code))
def main(): def main():
-255
View File
@@ -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)}
+4 -10
View File
@@ -12,12 +12,6 @@ reports for your own devices.
HEOS_HOST = "192.168.0.10" HEOS_HOST = "192.168.0.10"
HEOS_PORT = 1255 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. # Port the panel itself listens on.
WEB_PORT = 5443 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. # this rather than adding it, so at 5 a level of 23 goes to 25, not 28.
VOLUME_STEP = 5 VOLUME_STEP = 5
# Sources you deleted in the AVR's setup menu are hidden from the picker # Narrows the AVR's input picker to the sources you actually use, by their
# automatically. This narrows it further to the ones you actually use, by # HEOS input id (see GET /api/avr/inputs for the exact strings, e.g.
# their SI code (see GET /api/avr/inputs), and sets their order in the # "inputs/aux_in_1"), and sets their order in the list. Empty = every
# list. Empty = every source the AVR still has switched on. # source HEOS reports for it.
AVR_INPUT_CODES = [] AVR_INPUT_CODES = []
# Shown as the app's name on the iOS home screen. # Shown as the app's name on the iOS home screen.
+81 -8
View File
@@ -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 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 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. or the second Home 200 gets left behind.
* Unmerging must re-issue set_group with the pair's own pids to put * 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. 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 import json
@@ -17,7 +23,6 @@ import threading
import time import time
from pathlib import Path from pathlib import Path
from avr import AvrControl, AvrError
from heos import HeosClient, HeosError, parse_message from heos import HeosClient, HeosError, parse_message
MEMBERS_FILE = Path(__file__).with_name("members.json") MEMBERS_FILE = Path(__file__).with_name("members.json")
@@ -50,7 +55,6 @@ class Controller:
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)
self.avr = AvrControl(cfg.AVR_HOST, cfg.AVR_PORT, cfg.AVR_INPUT_CODES)
self._lock = threading.RLock() self._lock = threading.RLock()
self._members_file = Path(getattr(cfg, "MEMBERS_FILE", MEMBERS_FILE)) self._members_file = Path(getattr(cfg, "MEMBERS_FILE", MEMBERS_FILE))
self._learned = _load_learned(self._members_file) self._learned = _load_learned(self._members_file)
@@ -312,8 +316,26 @@ class Controller:
wanted = set(self.joined_keys()) | {key} 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.group_targets(self.cfg.HOST_KEY, [k for k in self.cfg.ROOM_KEYS if k in wanted])
self.scan() self.scan()
self._nudge_avr_input()
return self.joined_keys() 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: def leave(self, key: str) -> list:
"""Remove one room. Deliberately does not rewrite the AVR's group: """Remove one room. Deliberately does not rewrite the AVR's group:
whatever is still joined keeps playing without a hiccup.""" 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)) self.heos.command(f"player/{command}", pid=self.playback_pid(key))
def heos_inputs(self, key: str) -> list: 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: 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))
@@ -392,6 +417,54 @@ class Controller:
params["spid"] = self.playback_pid(source_key) params["spid"] = self.playback_pid(source_key)
self.heos.command("browse/play_input", **params) 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 ------------------------------------------ # -- one snapshot for the UI ------------------------------------------
def state(self) -> dict: def state(self) -> dict:
snapshot = { snapshot = {
@@ -435,11 +508,11 @@ class Controller:
try: try:
snapshot["avr"] = { snapshot["avr"] = {
"connected": self.avr.connected, "connected": self.avr_connected(),
"inputs": self.avr.inputs(), "inputs": self.avr_inputs(),
"input": self.avr.current_input(), "input": self.avr_current_input(),
} }
except (AvrError, OSError) as exc: except (HeosError, TargetError) as exc:
snapshot["errors"].append(str(exc)) snapshot["errors"].append(str(exc))
return snapshot return snapshot
+10
View File
@@ -133,3 +133,13 @@ class DemoController:
def play_heos_input(self, key, input_id, source_key=None): def play_heos_input(self, key, input_id, source_key=None):
return 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)
+8 -2
View File
@@ -22,8 +22,14 @@ Type=simple
User=franzz User=franzz
Group=www-data Group=www-data
WorkingDirectory=/var/www/html/heos WorkingDirectory=/var/www/html/heos
ExecStart=/var/www/html/heos/.venv/bin/python /var/www/html/heos/app.py ExecStart=/var/www/html/heos/.venv/bin/gunicorn --worker-class gthread --workers 1 --threads 8 --bind 0.0.0.0:5443 app:app
# Add --host 127.0.0.1 above to allow only the reverse proxy in. # 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 Restart=always
RestartSec=3 RestartSec=3
+1
View File
@@ -1 +1,2 @@
flask>=3.0 flask>=3.0
gunicorn>=21
+20
View File
@@ -61,6 +61,7 @@ els('.room').forEach((node) => {
toggle: el('[data-role="group"]', node), toggle: el('[data-role="group"]', node),
toggleLabel: el('[data-role="group-label"]', node), toggleLabel: el('[data-role="group-label"]', node),
play: el('[data-role="play"]', node), play: el('[data-role="play"]', node),
next: el('[data-role="next"]', node),
steps: els('.step', node), steps: els('.step', node),
volume: null, volume: null,
playState: null, playState: null,
@@ -80,6 +81,7 @@ els('.room').forEach((node) => {
room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped)); room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
room.play.addEventListener('click', () => togglePlay(key)); room.play.addEventListener('click', () => togglePlay(key));
room.next.addEventListener('click', () => skipTrack(key));
}); });
function paintRoom(room) { function paintRoom(room) {
@@ -93,6 +95,12 @@ function paintRoom(room) {
room.play.disabled = !room.available || room.playState === null; room.play.disabled = !room.available || room.playState === null;
room.play.setAttribute( room.play.setAttribute(
'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`); '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.disabled = !room.available;
room.toggle.classList.toggle('busy', room.busy); 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) { function applyJoined(joined) {
Object.values(rooms).forEach((room) => { Object.values(rooms).forEach((room) => {
room.grouped = joined.includes(room.key); room.grouped = joined.includes(room.key);
+3 -3
View File
@@ -124,7 +124,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
.transport { .transport {
flex: 0 0 auto; flex: 0 0 auto;
width: 66px; height: 52px; width: 66px; height: 62px;
border-radius: 16px; border-radius: 16px;
background: var(--raised); background: var(--raised);
display: grid; place-items: center; 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. */ everything else in here uses. */
.transport .icon-play { fill: currentColor; stroke: none; } .transport .icon-play { fill: currentColor; stroke: none; }
.transport .icon-pause { stroke-width: 2.4; } .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: /* 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, `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 -------------------------------------------- */ /* --- join / leave the AVR -------------------------------------------- */
.toggle { .toggle {
height: 52px; border-radius: 16px; height: 62px; border-radius: 16px;
background: var(--raised); background: var(--raised);
display: flex; align-items: center; justify-content: center; gap: 10px; display: flex; align-items: center; justify-content: center; gap: 10px;
font-size: 15px; font-weight: 550; 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); background: none; border: 1px solid var(--edge); color: var(--muted);
} }
.joined .toggle .dot { display: none; } .joined .toggle .dot { display: none; }
.joined .transport { height: 40px; width: 58px; }
.joined .toggle:active { background: var(--raised); color: var(--ink); } .joined .toggle:active { background: var(--raised); color: var(--ink); }
/* --- misc ------------------------------------------------------------- */ /* --- misc ------------------------------------------------------------- */
+4
View File
@@ -64,6 +64,10 @@
<svg class="icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5v14M15 5v14"/></svg> <svg class="icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5v14M15 5v14"/></svg>
</button> </button>
<button class="transport" data-role="next" aria-label="{{ room.label }}: next track" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
<button class="toggle" data-role="group" aria-pressed="false"> <button class="toggle" data-role="group" aria-pressed="false">
<span class="dot" aria-hidden="true"></span> <span class="dot" aria-hidden="true"></span>
<span data-role="group-label">Separate</span> <span data-role="group-label">Separate</span>
+27 -59
View File
@@ -1,4 +1,4 @@
"""Stand-in Denon hardware: just enough HEOS and Telnet to test against. """Stand-in HEOS hardware: just enough of the CLI protocol to test against.
The grouping rules are the part worth pinning down -- what a set_group The grouping rules are the part worth pinning down -- what a set_group
call does to a stereo pair is the kind of thing you do not want to find call does to a stereo pair is the kind of thing you do not want to find
@@ -15,12 +15,22 @@ class FakeHeos(threading.Thread):
NAMES = {1: "Home Cinema", 2: "Lego Room", 3: "Denon Home 200 L", 4: "Denon Home 200 R"} NAMES = {1: "Home Cinema", 2: "Lego Room", 3: "Denon Home 200 L", 4: "Denon Home 200 R"}
# What browse/browse?sid=<AVR pid> reports: HEOS's own list of the
# AVR's local inputs, already under whatever names you gave them in
# its setup menu -- HEOS carries the renamed labels itself.
AVR_INPUTS = [
{"name": "Z30 Pro", "mid": "inputs/mediaplayer"},
{"name": "Switch", "mid": "inputs/game"},
{"name": "LG G5", "mid": "inputs/tvaudio"},
]
def __init__(self): def __init__(self):
super().__init__(daemon=True) super().__init__(daemon=True)
self.groups = {3: [3, 4]} # gid -> pids, leader first self.groups = {3: [3, 4]} # gid -> pids, leader first
self.volumes = {pid: 20 for pid in self.NAMES} self.volumes = {pid: 20 for pid in self.NAMES}
self.play_states = {pid: "play" for pid in self.NAMES} self.play_states = {pid: "play" for pid in self.NAMES}
self.group_volumes = {3: 25} self.group_volumes = {3: 25}
self.now_playing_mid = {1: "inputs/mediaplayer"} # pid -> what get_now_playing_media reports
self.commands = [] # everything we were asked to do self.commands = [] # everything we were asked to do
self.server = socket.socket() self.server = socket.socket()
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -106,6 +116,22 @@ class FakeHeos(threading.Thread):
self.play_states[pid] = args["state"] self.play_states[pid] = args["state"]
return self._ok(path, message=f"pid={pid}&state={args['state']}") return self._ok(path, message=f"pid={pid}&state={args['state']}")
if path == "player/get_now_playing_media":
pid = int(args["pid"])
mid = self.now_playing_mid.get(pid, "")
name = next((s["name"] for s in self.AVR_INPUTS if s["mid"] == mid), mid)
return self._ok(path, payload={"mid": mid, "station": name} if mid else {})
if path == "browse/browse":
sid = int(args["sid"])
payload = list(self.AVR_INPUTS) if sid == 1 else []
return self._ok(path, payload=payload)
if path == "browse/play_input":
pid = int(args["pid"])
self.now_playing_mid[pid] = args["input"]
return self._ok(path)
if path.endswith("/toggle_mute") or path == "system/heart_beat": if path.endswith("/toggle_mute") or path == "system/heart_beat":
return self._ok(path) return self._ok(path)
@@ -125,61 +151,3 @@ class FakeHeos(threading.Thread):
if payload is not None: if payload is not None:
reply["payload"] = payload reply["payload"] = payload
return reply return reply
class FakeAvr(threading.Thread):
"""A Denon Telnet server that knows SI and SSFUN."""
SOURCES = [("MPLAY", "Apple TV"), ("GAME", "PlayStation"),
("SAT/CBL", "TV Box"), ("DVD", "Old DVD")]
DELETED = {"DVD"} # switched off in the AVR's setup menu
def __init__(self):
super().__init__(daemon=True)
self.input = "MPLAY"
self.server = socket.socket()
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.server.bind(("127.0.0.1", 0))
self.server.listen(4)
self.port = self.server.getsockname()[1]
self.start()
def run(self):
while True:
try:
conn, _ = self.server.accept()
except OSError:
return
threading.Thread(target=self._serve, args=(conn,), daemon=True).start()
def _serve(self, conn):
buffer = b""
with conn:
while True:
try:
chunk = conn.recv(1024)
except OSError:
return
if not chunk:
return
buffer += chunk
while b"\r" in buffer:
line, buffer = buffer.split(b"\r", 1)
for reply in self.handle(line.decode().strip()):
conn.sendall(reply.encode() + b"\r")
def handle(self, command: str) -> list:
if command == "SSFUN ?":
# The real AVR pads the names out with spaces.
return [f"SSFUN{code} {name} " for code, name in self.SOURCES] + ["SSFUN END"]
if command == "SSSOD ?":
return [f"SSSOD{code} {'DEL' if code in self.DELETED else 'USE'}"
for code, _ in self.SOURCES] + ["SSSOD END"]
if command == "SI?":
return [f"SI{self.input}"]
if command.startswith("SI"):
self.input = command[2:]
return [f"SI{self.input}"]
if command == "PW?":
return ["PWON"]
return []
+40 -26
View File
@@ -5,7 +5,6 @@
import sys import sys
import tempfile import tempfile
import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -13,7 +12,7 @@ from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from controller import Controller, stepped_level # noqa: E402 from controller import Controller, stepped_level # noqa: E402
from tests.fakes import FakeAvr, FakeHeos # noqa: E402 from tests.fakes import FakeHeos # noqa: E402
PAIR = {3, 4} # the two Home 200s PAIR = {3, 4} # the two Home 200s
AVR_PID = 1 AVR_PID = 1
@@ -21,10 +20,9 @@ HOME400_PID = 2
def build(tmpdir): def build(tmpdir):
heos, avr = FakeHeos(), FakeAvr() heos = FakeHeos()
cfg = SimpleNamespace( cfg = SimpleNamespace(
HEOS_HOST="127.0.0.1", HEOS_PORT=heos.port, HEOS_HOST="127.0.0.1", HEOS_PORT=heos.port,
AVR_HOST="127.0.0.1", AVR_PORT=avr.port,
HOST_KEY="avr", HOST_KEY="avr",
ROOM_KEYS=["home400", "living_room_group"], ROOM_KEYS=["home400", "living_room_group"],
TARGETS={ TARGETS={
@@ -36,14 +34,14 @@ def build(tmpdir):
VOLUME_STEP=5, VOLUME_STEP=5,
MEMBERS_FILE=str(Path(tmpdir) / "members.json"), MEMBERS_FILE=str(Path(tmpdir) / "members.json"),
) )
return Controller(cfg), heos, avr return Controller(cfg), heos
class PanelTest(unittest.TestCase): class PanelTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = tempfile.TemporaryDirectory() self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup) self.addCleanup(self.tmp.cleanup)
self.panel, self.heos, self.avr = build(self.tmp.name) self.panel, self.heos = build(self.tmp.name)
def group_pids(self): def group_pids(self):
return {gid: set(pids) for gid, pids in self.heos.groups.items()} return {gid: set(pids) for gid, pids in self.heos.groups.items()}
@@ -73,6 +71,18 @@ class PanelTest(unittest.TestCase):
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, HOME400_PID} | PAIR}) self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, HOME400_PID} | PAIR})
self.assertEqual(self.panel.joined_keys(), ["home400", "living_room_group"]) self.assertEqual(self.panel.joined_keys(), ["home400", "living_room_group"])
def test_joining_replays_the_avr_input_over_heos(self):
"""A room that has just joined sometimes stays silent until the
AVR's input is reselected -- through HEOS's own browse/play_input,
the way the HEOS app does it, not the AVR's Telnet port -- so
join() pokes it with whatever is already playing."""
before = len(self.heos.commands)
self.panel.join("home400")
replays = [c for c in self.heos.commands[before:] if "browse/play_input" in c]
self.assertEqual(len(replays), 1)
self.assertIn(f"pid={AVR_PID}", replays[0])
self.assertIn("input=inputs/mediaplayer", replays[0])
def test_leaving_rebuilds_the_stereo_pair(self): def test_leaving_rebuilds_the_stereo_pair(self):
self.panel.join("living_room_group") self.panel.join("living_room_group")
self.panel.leave("living_room_group") self.panel.leave("living_room_group")
@@ -164,29 +174,33 @@ class PanelTest(unittest.TestCase):
self.panel.toggle_play("living_room_group", "pause") self.panel.toggle_play("living_room_group", "pause")
self.assertEqual(self.heos.play_states[3], "pause") self.assertEqual(self.heos.play_states[3], "pause")
# -- the AVR --------------------------------------------------------- # -- the AVR, entirely over HEOS --------------------------------------
def test_renamed_inputs_and_selection(self): def test_avr_inputs_carry_your_renamed_labels(self):
deadline = time.time() + 5 """HEOS reports the AVR's own renamed sources itself (browse/browse
while not self.panel.avr.connected and time.time() < deadline: on its pid) -- there is no separate Telnet lookup needed for them."""
time.sleep(0.05) self.assertTrue(self.panel.avr_connected())
self.assertTrue(self.panel.avr.connected)
self.assertEqual( self.assertEqual(
self.panel.avr.inputs(), self.panel.avr_inputs(),
[{"code": "MPLAY", "name": "Apple TV"}, [{"code": "inputs/mediaplayer", "name": "Z30 Pro"},
{"code": "GAME", "name": "PlayStation"}, {"code": "inputs/game", "name": "Switch"},
{"code": "SAT/CBL", "name": "TV Box"}], {"code": "inputs/tvaudio", "name": "LG G5"}],
)
self.assertEqual(
self.panel.avr_current_input(), {"code": "inputs/mediaplayer", "name": "Z30 Pro"}
) )
self.assertEqual(self.panel.avr.current_input(), {"code": "MPLAY", "name": "Apple TV"})
# "Old DVD" is deleted in the AVR's setup menu, so the picker skips def test_selecting_an_avr_input_goes_through_heos(self):
# it -- but it keeps its name, in case the AVR is sitting on it. """Selection has to be browse/play_input, not the AVR's own Telnet
self.assertNotIn("DVD", [s["code"] for s in self.panel.avr.inputs()]) port -- that is what actually pushes the stream to a joined group,
self.assertIn({"code": "DVD", "name": "Old DVD"}, self.panel.avr.all_inputs()) not just what the AVR itself is listening to."""
self.assertEqual(self.panel.avr.name_for("DVD"), "Old DVD") self.assertEqual(
self.panel.avr_select_input("inputs/game"),
self.assertEqual(self.panel.avr.select_input("GAME"), {"code": "GAME", "name": "PlayStation"}) {"code": "inputs/game", "name": "Switch"},
self.assertEqual(self.avr.input, "GAME") )
self.assertEqual(
self.panel.avr_current_input(), {"code": "inputs/game", "name": "Switch"}
)
self.assertTrue(any("browse/play_input" in c for c in self.heos.commands))
# -- the whole snapshot the UI renders ------------------------------- # -- the whole snapshot the UI renders -------------------------------
def test_state_snapshot(self): def test_state_snapshot(self):