@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)}
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 +1,2 @@
|
|||||||
flask>=3.0
|
flask>=3.0
|
||||||
|
gunicorn>=21
|
||||||
|
|||||||
@@ -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
@@ -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 ------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user