Merge the bridge and a home-screen interface into one app

The HEOS app is unpleasant to use for the four things that actually get
done in this house, so this is those four things on one screen: volume
for the Home 400 and the Living Room pair, joining either to the AVR,
splitting them off again, and picking the AVR's input.

The bridge's logic moves in mostly intact, split into a HEOS client, an
AVR client and a controller, with two grouping bugs fixed on the way:

  * Merging a room into the AVR sent only the pair's leader pid, which
    left the second Home 200 behind. Group targets now expand to every
    member pid, and unmerging re-forms the pair rather than leaving two
    lone speakers. The members are learned while the pair is visible and
    remembered in members.json so a restart can still rebuild it.

  * Volume for the pair used its gid, which stops existing the moment it
    joins the AVR's group. It now falls back to the member players.

Both sockets are kept open rather than reconnecting per command, which
is what made every button press feel slow; the AVR connection doubles as
a listener, so input changes made with the physical remote show up too.

The original query-string endpoints still answer, so anything already
pointed at the bridge keeps working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 21:15:43 +02:00
co-authored by Claude Opus 5
parent 86fdf9a4d3
commit 375bcfdd76
20 changed files with 2428 additions and 490 deletions
+9
View File
@@ -136,3 +136,12 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
# ---> Python / this project
__pycache__/
*.py[cod]
.venv/
venv/
# Learned HEOS group membership, written at runtime
members.json
+179 -2
View File
@@ -1,3 +1,180 @@
# heos # HEOS panel
Fix Denon Heos interface A phone-sized web remote for a Denon HEOS system, meant to be added to the
iOS home screen and used instead of the HEOS app. One Flask process serves
both the interface and the HTTP bridge behind it.
Everything it does fits on one screen:
- **Volume** up/down for the Home 400 and the Living Room pair (hold to keep moving)
- **Group** either room with the AVR — the AVR is always the host, so its
sound takes over whatever joins it
- **Ungroup** either room again, or all of them at once
- **Change the AVR's input**, listed under the names you gave them
## The kit it assumes
| Room | Device | How HEOS addresses it |
| --- | --- | --- |
| Living Room | 2× Denon Home 200 as an In-Room Group | a **group** (`gid`) |
| Lego Room | Denon Home 400 | a **player** (`pid`) |
| Home Cinema | Denon AVR-X3800H | a **player**, plus Telnet on port 23 |
Any other mix works — it is all in `config.py`.
## Install
```bash
pip3 install -r requirements.txt
python3 app.py
```
Then open `http://<pi-ip>:5005/`.
## Configure
Open `http://<pi-ip>:5005/api/targets` and copy the exact `name` HEOS reports
for each device into `TARGETS` in `config.py`. The names come from whatever
you typed in the HEOS app, so they rarely match the model names.
```python
TARGETS = {
"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"},
"home400": {"label": "Lego Room", "heos_name": "Lego Room"},
"living_room_group": {"label": "Living Room", "heos_name": "Denon Home 200 L"},
}
HOST_KEY = "avr" # always the group host
ROOM_KEYS = ["home400", "living_room_group"] # the cards, in order
```
`HEOS_HOST` only needs to point at **one** device: HEOS is distributed, so any
unit can see and control the whole network. `AVR_HOST` must be the AVR itself.
`AVR_INPUT_CODES` narrows the input picker to the sources you actually use
(and sets their order); leave it empty to list everything the AVR reports.
## Add it to the iOS home screen
Open the page in Safari → Share → **Add to Home Screen**. It then launches
full-screen with no browser chrome, which is the point of the exercise.
Safari will only offer that over plain HTTP on the LAN, which is fine here;
if you ever put it behind a domain name, give it HTTPS.
## Run it as a service
```ini
# /etc/systemd/system/heos-panel.service
[Unit]
Description=HEOS panel
After=network-online.target
[Service]
ExecStart=/usr/bin/python3 /home/pi/heos/app.py
WorkingDirectory=/home/pi/heos
Restart=always
User=pi
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now heos-panel
```
## How the grouping actually works
Worth knowing, because HEOS makes two things easy to get wrong.
**`set_group` replaces a group wholesale.** There is no "add this player".
Joining a second room therefore re-sends every member of the group, and the
host's `pid` has to come first — that is what makes the AVR the leader whose
content everyone plays.
**Your Home 200 pair is a group, not a speaker.** Merging it into the AVR
means sending *both* speakers' pids; sending only the leader would leave the
second Home 200 playing on its own. And once merged, the pair's own `gid`
stops existing, so:
- unmerging re-issues `set_group` with the pair's two pids, rebuilding it
- volume falls back to setting both players directly, since there is no
group volume to set any more
The panel learns the pair's members the first time it sees them un-merged and
remembers them in `members.json`, which is what lets it rebuild the pair after
a restart. If you would rather pin them down, list them in `config.py`:
```python
"living_room_group": {
"label": "Living Room",
"heos_name": "Denon Home 200 L",
"players": ["Denon Home 200 L", "Denon Home 200 R"], # leader first
},
```
Leaving a room deliberately does *not* rewrite the AVR's group, so the other
room's music does not restart.
## Two protocols, not one
| | HEOS CLI (port 1255) | Denon Telnet (port 23) |
| --- | --- | --- |
| Speaks | JSON, `heos://player/...` | plain text, `SIGAME`, `SSFUN ?` |
| Used for | players, groups, volume | the AVR's **renamed** input list |
HEOS only knows generic input ids like `inputs/hdmi_in_1`; the names you gave
your sources live in the AVR's own protocol, which is why both are here.
The Telnet connection is held open, so input changes made with the physical
remote show up in the panel too. Some Denon models only accept **one** Telnet
connection at a time — if another integration (Home Assistant, say) already
holds it, the AVR card will read `offline` while the HEOS half keeps working.
## HTTP API
Used by the interface:
| | |
| --- | --- |
| `GET /api/state` | everything the UI draws, in one call |
| `GET /api/targets` | every player and group HEOS can see |
| `POST /api/volume` | `{"target": "home400", "delta": 2}` or `{"level": 25}` |
| `POST /api/mute` | `{"target": "home400"}` |
| `POST /api/group` | `{"target": "home400", "joined": true}` |
| `POST /api/group/none` | every room back on its own |
| `GET /api/avr/inputs` | your renamed sources |
| `POST /api/avr/input` | `{"code": "GAME"}` |
The original bridge's endpoints still answer, so existing Shortcuts and
scripts keep working: `/targets`, `/volume`, `/volume/{set,up,down,mute}`,
`/playback/{play,pause,stop,next,previous}`, `/group/{create,remove}`,
`/inputs`, `/input/{set,relay}`, `/avr/{raw,input,inputs}`, `/raw/<command>`.
Two of them are worth keeping for troubleshooting:
```
GET /raw/browse/browse?sid=1027 # any heos:// command, raw reply
GET /avr/raw?cmd=SSFUN ? # any Telnet command, every line back
```
## Working on it
```bash
python3 app.py --demo # fake speakers, real interface
python3 -m unittest discover -s tests -t . # runs against a fake HEOS network
python3 tools/make_icons.py # redraw the home-screen icon
```
## Layout
```
app.py Flask: the UI, the API, and the old bridge's routes
controller.py what a room is, what grouping means, volume
heos.py HEOS CLI client (persistent socket, reconnects itself)
avr.py Denon Telnet client + the renamed input list
config.py your devices and preferences
demo.py fake speakers for --demo
templates/ static/ the interface
tests/ fake HEOS + AVR servers, and tests against them
```
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""HEOS panel: a phone-sized web remote plus the HTTP bridge it runs on.
pip3 install -r requirements.txt
python3 app.py # http://<pi-ip>:5005/
Everything the UI does goes through /api/*. The flatter, query-string
endpoints from the original heos_bridge.py (/volume/up?target=..., and
friends) are still here so existing Shortcuts and scripts keep working.
"""
import argparse
from functools import wraps
from flask import Flask, jsonify, render_template, request
import config
from avr import AvrError
from controller import Controller, TargetError
from heos import HeosError
app = Flask(__name__)
controller: Controller = None
def handle_errors(view):
"""One place to turn our three failure modes into sensible JSON."""
@wraps(view)
def wrapped(*args, **kwargs):
try:
return view(*args, **kwargs)
except (TargetError, ValueError) as exc:
return jsonify({"error": str(exc)}), 400
except (HeosError, AvrError) as exc:
return jsonify({"error": str(exc)}), 502
return wrapped
@app.after_request
def no_store(response):
"""It is a LAN remote: never let a phone show a cached volume, and
never let iOS pin an old copy of the UI to the home screen."""
response.headers["Cache-Control"] = "no-store"
return response
def _payload() -> dict:
return request.get_json(silent=True) or request.form.to_dict() or request.args.to_dict()
def _target_from(data: dict, field: str = "target") -> str:
key = data.get(field)
if not key:
raise ValueError(f"Missing '{field}'. Valid rooms: {', '.join(config.TARGETS)}")
if key not in config.TARGETS:
raise ValueError(f"Unknown room '{key}'. Valid rooms: {', '.join(config.TARGETS)}")
return key
# --- The UI -----------------------------------------------------------
@app.route("/")
def index():
return render_template(
"index.html",
app_name=config.APP_NAME,
host=config.TARGETS[config.HOST_KEY],
rooms=[{"key": key, **config.TARGETS[key]} for key in config.ROOM_KEYS],
step=config.VOLUME_STEP,
)
@app.get("/manifest.webmanifest")
def manifest():
"""Rendered rather than static, so APP_NAME is only written down once."""
return app.response_class(
render_template("manifest.webmanifest", app_name=config.APP_NAME),
mimetype="application/manifest+json",
)
# --- API the UI talks to ----------------------------------------------
@app.get("/api/state")
@handle_errors
def api_state():
return jsonify(controller.state())
@app.get("/api/targets")
@handle_errors
def api_targets():
"""Diagnostic: every player and group HEOS can see, with its exact
name -- this is what you copy into config.py."""
found = controller.scan()
return jsonify({
"players": [
{"kind": "player", "name": p.get("name"), "pid": p.get("pid"), "model": p.get("model", "")}
for p in found["players"]
],
"groups": [
{"kind": "group", "name": g.get("name"), "gid": g.get("gid"),
"players": [{"name": m.get("name"), "pid": m.get("pid"), "role": m.get("role")}
for m in g.get("players", [])]}
for g in found["groups"]
],
})
@app.post("/api/volume")
@handle_errors
def api_volume():
data = _payload()
key = _target_from(data)
if data.get("level") is not None:
return jsonify({"target": key, "level": controller.set_volume(key, int(data["level"]))})
if data.get("delta") is not None:
return jsonify({"target": key, "level": controller.nudge_volume(key, int(data["delta"]))})
raise ValueError("Provide either 'delta' or 'level'")
@app.post("/api/mute")
@handle_errors
def api_mute():
key = _target_from(_payload())
controller.toggle_mute(key)
return jsonify({"target": key, "ok": True})
@app.post("/api/group")
@handle_errors
def api_group():
"""Join or leave the AVR's group. The AVR is always the host, so its
content is what the joined rooms start playing."""
data = _payload()
key = _target_from(data)
joined = data.get("joined")
if isinstance(joined, str):
joined = joined.lower() in ("1", "true", "yes", "on")
if joined is None:
raise ValueError("Provide 'joined': true to merge with the AVR, false to split off")
return jsonify({"joined": controller.join(key) if joined else controller.leave(key)})
@app.post("/api/group/none")
@handle_errors
def api_group_none():
"""Every room back on its own."""
return jsonify({"joined": controller.set_membership([])})
@app.get("/api/avr/inputs")
@handle_errors
def api_avr_inputs():
refresh = request.args.get("refresh") in ("1", "true", "yes")
return jsonify(controller.avr.inputs(refresh=refresh))
@app.post("/api/avr/input")
@handle_errors
def api_avr_set_input():
code = _payload().get("code")
if not code:
raise ValueError("Provide 'code', e.g. GAME -- see GET /api/avr/inputs")
return jsonify(controller.avr.select_input(code))
# --- The original bridge's API, unchanged -----------------------------
@app.get("/targets")
@handle_errors
def legacy_targets():
found = controller.scan()
return jsonify(
[{"kind": "player", "name": p.get("name"), "id": p.get("pid"), "model": p.get("model", "")}
for p in found["players"]]
+ [{"kind": "group", "name": g.get("name"), "id": g.get("gid"),
"members": [m.get("name") for m in g.get("players", [])]}
for g in found["groups"]]
)
@app.get("/volume")
@handle_errors
def legacy_get_volume():
return jsonify({"level": controller.volume(_target_from(request.args))})
@app.post("/volume/set")
@handle_errors
def legacy_set_volume():
level = request.args.get("level", type=int)
if level is None or not 0 <= level <= 100:
raise ValueError("provide integer 'level' between 0 and 100")
return jsonify({"level": controller.set_volume(_target_from(request.args), level)})
@app.post("/volume/up")
@handle_errors
def legacy_volume_up():
step = request.args.get("step", default=config.VOLUME_STEP, type=int)
return jsonify({"level": controller.nudge_volume(_target_from(request.args), step)})
@app.post("/volume/down")
@handle_errors
def legacy_volume_down():
step = request.args.get("step", default=config.VOLUME_STEP, type=int)
return jsonify({"level": controller.nudge_volume(_target_from(request.args), -step)})
@app.post("/volume/mute")
@handle_errors
def legacy_mute():
controller.toggle_mute(_target_from(request.args))
return jsonify({"ok": True})
@app.post("/playback/<state>")
@handle_errors
def legacy_playback(state):
key = _target_from(request.args)
if state in ("play", "pause", "stop"):
controller.play_state(key, state)
return jsonify({"state": state})
if state in ("next", "previous"):
controller.skip(key, state)
return jsonify({"ok": True})
raise ValueError(f"Unknown playback action '{state}'")
@app.post("/group/create")
@handle_errors
def legacy_group_create():
host = _target_from(request.args, "host")
members = [m.strip() for m in request.args.get("members", "").split(",") if m.strip()]
if not members:
raise ValueError("provide '?host=<room>&members=<comma-separated rooms>'")
for key in members:
_target_from({"target": key})
controller.group_targets(host, members)
return jsonify({"host": host, "members": members})
@app.post("/group/remove")
@handle_errors
def legacy_group_remove():
controller.ungroup(_target_from(request.args))
return jsonify({"ok": True})
@app.get("/inputs")
@handle_errors
def legacy_inputs():
return jsonify(controller.heos_inputs(_target_from(request.args)))
@app.post("/input/set")
@handle_errors
def legacy_input_set():
input_id = request.args.get("input")
if not input_id:
raise ValueError("provide '?input=' -- see GET /inputs for valid values")
controller.play_heos_input(_target_from(request.args), input_id)
return jsonify({"input": input_id})
@app.post("/input/relay")
@handle_errors
def legacy_input_relay():
input_id = request.args.get("input")
source = request.args.get("from")
if not input_id or not source:
raise ValueError("provide '?from=<source room>&input=<input id>'")
controller.play_heos_input(_target_from(request.args), input_id, _target_from(request.args, "from"))
return jsonify({"input": input_id, "from": source})
@app.get("/raw/<path:subpath>")
@handle_errors
def legacy_raw(subpath):
"""Diagnostic: forward any heos:// command as-is.
e.g. GET /raw/browse/browse?sid=1027"""
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")
@handle_errors
def legacy_avr_inputs():
return jsonify(controller.avr.inputs(refresh=True))
@app.route("/avr/input", methods=["GET", "POST"])
@handle_errors
def legacy_avr_input():
if request.method == "GET":
return jsonify(controller.avr.current_input() or {"error": "AVR not reachable"})
code = request.args.get("input")
if not code:
raise ValueError("provide '?input=<code>', e.g. GAME, TV, CD, AUX1")
return jsonify(controller.avr.select_input(code))
def main():
global controller
parser = argparse.ArgumentParser(description="HEOS panel")
parser.add_argument("--port", type=int, default=config.WEB_PORT)
parser.add_argument("--demo", action="store_true",
help="run with fake speakers, for working on the UI away from the kit")
args = parser.parse_args()
if args.demo:
from demo import DemoController
controller = DemoController(config)
else:
controller = Controller(config)
# 0.0.0.0 so your phone can reach it over the LAN.
app.run(host="0.0.0.0", port=args.port, threaded=True)
if __name__ == "__main__":
main()
+219
View File
@@ -0,0 +1,219 @@
"""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
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
@property
def connected(self) -> bool:
return self.telnet.connected
def inputs(self, refresh: bool = False) -> list:
"""Your renamed source list. Cached: it only changes when you
rename something in the AVR's own 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
sources = self._inputs or []
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.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)}
+66
View File
@@ -0,0 +1,66 @@
"""Everything you might want to change lives here.
Copy the values out of `GET /api/targets` (or the old `/targets`) the
first time you run this, so the names below match exactly what HEOS
reports for your own devices.
"""
# --- Network ----------------------------------------------------------
# Any ONE HEOS device's IP is enough: HEOS is a distributed system, so
# whichever unit you connect to can see and control every player and
# group on the network.
HEOS_HOST = "192.168.0.10"
HEOS_PORT = 1255
# The AVR's classic Denon Telnet control port. Completely separate from
# HEOS, and the only place your *renamed* input list actually lives --
# HEOS itself only knows a fixed set of generic input identifiers.
AVR_HOST = "192.168.0.10"
AVR_PORT = 23
# Port the panel itself listens on.
WEB_PORT = 5005
# --- Rooms ------------------------------------------------------------
# key -> how to find it on the network, and how to label it in the UI.
#
# heos_name : the EXACT name HEOS reports for that player or group.
# players : only for a target that is a HEOS *group* (a stereo pair
# or an In-Room Group). List its member players, leader
# first. Leave it out and the panel learns the members the
# first time it sees the group un-merged, then remembers
# them in members.json -- which is what lets it rebuild the
# pair after you unmerge it from the AVR.
TARGETS = {
"avr": {
"label": "Home Cinema",
"heos_name": "Home Cinema", # AVR-X3800H
},
"home400": {
"label": "Lego Room",
"heos_name": "Lego Room", # Denon Home 400
},
"living_room_group": {
"label": "Living Room",
"heos_name": "Denon Home 200 L", # 2x Denon Home 200, In-Room Group
# "players": ["Denon Home 200 L", "Denon Home 200 R"],
},
}
# The AVR is always the group host: its content takes over every room
# that joins, which is the whole point of the merge buttons.
HOST_KEY = "avr"
# The rooms that get a card with volume + a join/leave button, in order.
ROOM_KEYS = ["home400", "living_room_group"]
# --- Behaviour --------------------------------------------------------
# How many HEOS volume points one tap of +/- moves.
VOLUME_STEP = 2
# Limit the input picker to the sources you actually use, by their SI
# code (see GET /api/avr/inputs for the full list). Empty = show all.
AVR_INPUT_CODES = []
# Shown as the app's name on the iOS home screen.
APP_NAME = "HEOS"
+394
View File
@@ -0,0 +1,394 @@
"""The actual behaviour of the panel, on top of the two protocol clients.
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
you merge it into the AVR's group, at which point that gid stops
existing and its two players are just two members of the AVR's group.
Two consequences drive most of the code below:
* Merging must send EVERY member pid of the pair, not just its leader,
or the second Home 200 gets left behind.
* Unmerging must re-issue set_group with the pair's own pids to put
the pair back together, so we have to remember what they were.
"""
import json
import threading
import time
from pathlib import Path
from avr import AvrControl, AvrError
from heos import HeosClient, HeosError, parse_message
MEMBERS_FILE = Path(__file__).with_name("members.json")
class TargetError(ValueError):
"""We cannot find that room on the network right now."""
class Controller:
def __init__(self, cfg):
self.cfg = cfg
self.heos = HeosClient(cfg.HEOS_HOST, cfg.HEOS_PORT)
self.avr = AvrControl(cfg.AVR_HOST, cfg.AVR_PORT, cfg.AVR_INPUT_CODES)
self._lock = threading.RLock()
self._members_file = Path(getattr(cfg, "MEMBERS_FILE", MEMBERS_FILE))
self._learned = _load_learned(self._members_file)
self._players = []
self._groups = []
self._scanned_at = 0.0
threading.Thread(target=self._keep_warm, daemon=True).start()
def _keep_warm(self):
"""HEOS hangs up on idle connections; a heartbeat keeps the first
button press of the evening as quick as the second."""
while True:
time.sleep(240)
try:
self.heos.heart_beat()
except HeosError:
pass
# -- network picture -----------------------------------------------
def scan(self) -> dict:
"""Re-read every player and group, and remember what the rooms
are made of while we can see them."""
with self._lock:
self._players = self.heos.command("player/get_players").get("payload", [])
self._groups = self.heos.command("group/get_groups").get("payload", [])
self._scanned_at = time.monotonic()
self._learn_members()
return {"players": self._players, "groups": self._groups}
def _fresh(self, max_age: float = 2.0):
if time.monotonic() - self._scanned_at > max_age:
self.scan()
def _player_pid(self, name: str):
for player in self._players:
if player.get("name") == name:
return player.get("pid")
return None
def _group_named(self, name: str):
for group in self._groups:
if group.get("name") == name:
return group
return None
@staticmethod
def _ordered_pids(group: dict) -> list:
"""Member pids with the leader first -- HEOS makes the first pid
in a set_group call the leader, so the order is not cosmetic."""
players = group.get("players", [])
leaders = [p for p in players if p.get("role") == "leader"]
others = [p for p in players if p.get("role") != "leader"]
return [p.get("pid") for p in leaders + others]
def _learn_members(self):
"""Record what each room's group is made of whenever we catch it
standing on its own, so we can rebuild it after a merge."""
host_pid = self._host_pid()
changed = False
for key in self.cfg.ROOM_KEYS:
if "players" in self.cfg.TARGETS[key]:
continue # configured by hand, nothing to learn
group = self._group_named(self.cfg.TARGETS[key]["heos_name"])
if not group:
continue
pids = self._ordered_pids(group)
if host_pid in pids:
continue # currently merged with the AVR: not its own shape
if self._learned.get(key) != pids:
self._learned[key] = pids
changed = True
if changed:
_save_learned(self._members_file, self._learned)
# -- resolving rooms to pids ---------------------------------------
def _host_pid(self):
"""The AVR is always a plain player. Resolving it by player name
matters: once it leads a group, HEOS also reports a *group* under
the very same name."""
target = self.cfg.TARGETS[self.cfg.HOST_KEY]
pid = self._player_pid(target["heos_name"])
if pid is None:
raise TargetError(f"No HEOS player named '{target['heos_name']}' (the AVR) was found")
return pid
def member_pids(self, key: str) -> list:
"""Every player that makes up a room, leader first."""
if key not in self.cfg.TARGETS:
raise TargetError(f"Unknown room '{key}'")
if key == self.cfg.HOST_KEY:
return [self._host_pid()]
target = self.cfg.TARGETS[key]
name = target["heos_name"]
if "players" in target:
pids = []
for player_name in target["players"]:
pid = self._player_pid(player_name)
if pid is None:
raise TargetError(f"No HEOS player named '{player_name}' was found")
pids.append(pid)
return pids
# A group under this name wins over a player under the same name:
# a stereo pair is usually named after its left-hand speaker.
host_pid = self._host_pid()
group = self._group_named(name)
if group:
pids = self._ordered_pids(group)
if host_pid not in pids:
return pids
known = self._learned.get(key)
if known:
live = {p.get("pid") for p in self._players}
if all(pid in live for pid in known):
return known
pid = self._player_pid(name)
if pid is not None:
return [pid]
raise TargetError(
f"No HEOS player or group named '{name}' was found. "
f"Check GET /api/targets for the names HEOS actually reports."
)
# -- volume ---------------------------------------------------------
def _volume_handles(self, key: str) -> list:
"""Where volume for this room lives right now, as (scope, id).
A room that is its own HEOS group has a single group volume. Once
it is merged into the AVR's group that gid is gone, and the only
knobs left are the member players' own volumes.
"""
pids = self.member_pids(key)
if len(pids) > 1:
wanted = set(pids)
for group in self._groups:
if {p.get("pid") for p in group.get("players", [])} == wanted:
return [("group", group["gid"])]
return [("player", pid) for pid in pids]
@staticmethod
def _id_param(scope: str) -> str:
return "pid" if scope == "player" else "gid"
def _read_volume(self, scope, obj_id) -> int:
reply = self.heos.command(f"{scope}/get_volume", **{self._id_param(scope): obj_id})
return int(parse_message(reply["heos"]["message"]).get("level", -1))
def volume(self, key: str) -> int:
with self._lock:
self._fresh()
scope, obj_id = self._volume_handles(key)[0]
return self._read_volume(scope, obj_id)
def set_volume(self, key: str, level: int) -> int:
level = max(0, min(100, int(level)))
with self._lock:
self._fresh()
for scope, obj_id in self._volume_handles(key):
self.heos.command(
f"{scope}/set_volume", **{self._id_param(scope): obj_id}, level=level
)
return level
def nudge_volume(self, key: str, delta: int) -> int:
"""Move volume by delta and report where it landed.
Absolute rather than HEOS's own volume_up/down, because the UI
coalesces a fast burst of taps into one call and volume_up caps
its step at 10.
"""
with self._lock:
self._fresh()
handles = self._volume_handles(key)
current = self._read_volume(*handles[0])
level = max(0, min(100, current + int(delta)))
for scope, obj_id in handles:
self.heos.command(
f"{scope}/set_volume", **{self._id_param(scope): obj_id}, level=level
)
return level
def toggle_mute(self, key: str):
with self._lock:
self._fresh()
for scope, obj_id in self._volume_handles(key):
self.heos.command(f"{scope}/toggle_mute", **{self._id_param(scope): obj_id})
# -- grouping --------------------------------------------------------
def _host_group_pids(self) -> set:
"""Every pid currently in the AVR's group (empty if it is alone)."""
host_pid = self._host_pid()
for group in self._groups:
pids = {p.get("pid") for p in group.get("players", [])}
if host_pid in pids and len(pids) > 1:
return pids
return set()
def joined_keys(self) -> list:
joined, host_pids = [], self._host_group_pids()
for key in self.cfg.ROOM_KEYS:
try:
if host_pids & set(self.member_pids(key)):
joined.append(key)
except TargetError:
continue
return joined
def group_targets(self, host_key: str, member_keys: list) -> list:
"""One set_group call: the host's pid first -- that is what makes it
the leader whose content everyone else plays -- then every member
pid of every room listed."""
with self._lock:
self._fresh()
pids = list(self.member_pids(host_key))
for key in member_keys:
pids.extend(self.member_pids(key))
self.heos.command("group/set_group", pid=",".join(str(p) for p in pids))
return pids
def ungroup(self, key: str) -> list:
"""Stand a room back up on its own. For the Home 200 pair this
re-forms the pair rather than leaving two lone speakers behind."""
with self._lock:
self._fresh()
pids = self.member_pids(key)
self.heos.command("group/set_group", pid=",".join(str(p) for p in pids))
return pids
def join(self, key: str) -> list:
"""Add a room to the AVR's group. set_group replaces a group
wholesale, so the call has to name everyone who is already in it
as well as the newcomer."""
with self._lock:
self.scan()
wanted = set(self.joined_keys()) | {key}
self.group_targets(self.cfg.HOST_KEY, [k for k in self.cfg.ROOM_KEYS if k in wanted])
self.scan()
return self.joined_keys()
def leave(self, key: str) -> list:
"""Remove one room. Deliberately does not rewrite the AVR's group:
whatever is still joined keeps playing without a hiccup."""
with self._lock:
self.scan()
if key in self.joined_keys():
self.ungroup(key)
self.scan()
return self.joined_keys()
def set_membership(self, joined: list) -> list:
"""Make the AVR's group contain exactly these rooms and no others."""
with self._lock:
self.scan()
wanted = [k for k in self.cfg.ROOM_KEYS if k in joined]
for key in list(self.joined_keys()):
if key not in wanted:
self.ungroup(key)
if wanted:
self.group_targets(self.cfg.HOST_KEY, wanted)
self.scan()
return self.joined_keys()
# -- playback (kept for the original bridge's API) --------------------
def playback_pid(self, key: str):
return self.member_pids(key)[0]
def play_state(self, key: str, state: str):
with self._lock:
self._fresh()
self.heos.command("player/set_play_state", pid=self.playback_pid(key), state=state)
def skip(self, key: str, direction: str):
with self._lock:
self._fresh()
command = "play_next" if direction == "next" else "play_previous"
self.heos.command(f"player/{command}", pid=self.playback_pid(key))
def heos_inputs(self, key: str) -> list:
"""Physical inputs as HEOS sees them (generic ids, not your names)."""
with self._lock:
self._fresh()
reply = self.heos.command("browse/browse", sid=self.playback_pid(key))
return [{"name": i.get("name"), "input_id": i.get("mid")} for i in reply.get("payload", [])]
def play_heos_input(self, key: str, input_id: str, source_key: str = None):
with self._lock:
self._fresh()
params = {"pid": self.playback_pid(key), "input": input_id}
if source_key:
params["spid"] = self.playback_pid(source_key)
self.heos.command("browse/play_input", **params)
# -- one snapshot for the UI ------------------------------------------
def state(self) -> dict:
snapshot = {
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]},
"rooms": [],
"avr": {"connected": False, "input": None, "inputs": []},
"heos_ok": True,
"errors": [],
}
with self._lock:
try:
self.scan()
joined = set(self.joined_keys())
for key in self.cfg.ROOM_KEYS:
room = {
"key": key,
"label": self.cfg.TARGETS[key]["label"],
"available": True,
"grouped": key in joined,
"volume": None,
"error": None,
}
try:
scope, obj_id = self._volume_handles(key)[0]
room["volume"] = self._read_volume(scope, obj_id)
except (TargetError, HeosError, KeyError) as exc:
room["available"] = False
room["error"] = str(exc)
snapshot["rooms"].append(room)
except (HeosError, TargetError) as exc:
snapshot["heos_ok"] = False
snapshot["errors"].append(str(exc))
snapshot["rooms"] = [
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": False,
"grouped": False, "volume": None, "error": str(exc)}
for key in self.cfg.ROOM_KEYS
]
try:
snapshot["avr"] = {
"connected": self.avr.connected,
"inputs": self.avr.inputs(),
"input": self.avr.current_input(),
}
except (AvrError, OSError) as exc:
snapshot["errors"].append(str(exc))
return snapshot
def _load_learned(path: Path) -> dict:
try:
return json.loads(path.read_text())
except (OSError, ValueError):
return {}
def _save_learned(path: Path, data: dict):
try:
path.write_text(json.dumps(data, indent=2))
except OSError:
pass # a read-only checkout just means we re-learn next time
+119
View File
@@ -0,0 +1,119 @@
"""A pretend HEOS network, for `python3 app.py --demo`.
Lets you work on the interface on a laptop, with no speakers on the
network -- and lets the panel be tested without waking the house up.
"""
import time
class _FakeAvr:
INPUTS = [
{"code": "MPLAY", "name": "Apple TV"},
{"code": "GAME", "name": "PlayStation"},
{"code": "SAT/CBL", "name": "TV Box"},
{"code": "BD", "name": "Blu-ray"},
{"code": "TUNER", "name": "Radio"},
{"code": "PHONO", "name": "Turntable"},
]
def __init__(self, allowed_codes=()):
self.allowed_codes = list(allowed_codes or [])
self.connected = True
self._code = "MPLAY"
def inputs(self, refresh=False):
sources = list(self.INPUTS)
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 name_for(self, code):
return next((s["name"] for s in self.INPUTS if s["code"] == code), code)
def current_input(self):
return {"code": self._code, "name": self.name_for(self._code)}
def select_input(self, code):
time.sleep(0.15) # the real AVR is not instant either
self._code = code
return self.current_input()
class DemoController:
def __init__(self, cfg):
self.cfg = cfg
self.avr = _FakeAvr(cfg.AVR_INPUT_CODES)
self.heos = None
self._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.TARGETS)}
self._joined = set()
# -- what the UI uses ---------------------------------------------
def state(self):
return {
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]},
"rooms": [
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": True,
"grouped": key in self._joined, "volume": self._volume[key], "error": None}
for key in self.cfg.ROOM_KEYS
],
"avr": {"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input()},
"heos_ok": True,
"errors": [],
"demo": True,
}
def volume(self, key):
return self._volume[key]
def set_volume(self, key, level):
self._volume[key] = max(0, min(100, int(level)))
return self._volume[key]
def nudge_volume(self, key, delta):
return self.set_volume(key, self._volume[key] + int(delta))
def toggle_mute(self, key):
return None
def join(self, key):
self._joined.add(key)
return self.joined_keys()
def leave(self, key):
self._joined.discard(key)
return self.joined_keys()
def set_membership(self, joined):
self._joined = {k for k in self.cfg.ROOM_KEYS if k in joined}
return self.joined_keys()
def joined_keys(self):
return [k for k in self.cfg.ROOM_KEYS if k in self._joined]
# -- enough of the rest to keep the legacy routes answering --------
def scan(self):
return {
"players": [{"name": t["heos_name"], "pid": 1000 + i, "model": "Demo"}
for i, t in enumerate(self.cfg.TARGETS.values())],
"groups": [],
}
def group_targets(self, host_key, member_keys):
return self.set_membership(member_keys)
def ungroup(self, key):
return self.leave(key)
def play_state(self, key, state):
return None
def skip(self, key, direction):
return None
def heos_inputs(self, key):
return [{"name": s["name"], "input_id": f"inputs/{s['code'].lower()}"} for s in self.avr.inputs()]
def play_heos_input(self, key, input_id, source_key=None):
return None
+126
View File
@@ -0,0 +1,126 @@
"""HEOS CLI client (TCP port 1255).
HEOS is not JSON-RPC or anything else standard: you send one
`heos://group/command?a=1&b=2` line and read back one JSON line. This
keeps the socket open between commands -- the panel fires a command on
every button press, and a fresh TCP handshake per press is what made
the original bridge feel sluggish.
"""
import json
import socket
import threading
import time
from urllib.parse import unquote_plus
class HeosError(RuntimeError):
"""The device answered, but said no (or never answered at all)."""
def parse_message(message: str) -> dict:
"""Turn a HEOS `heos.message` string into a dict.
e.g. "pid=12345&level=23" -> {"pid": "12345", "level": "23"}
"""
result = {}
for part in message.split("&"):
if "=" in part:
key, value = part.split("=", 1)
result[key] = unquote_plus(value)
elif part:
result[part] = ""
return result
class HeosClient:
"""One persistent, lock-guarded, self-healing HEOS connection."""
def __init__(self, host: str, port: int = 1255, timeout: float = 5.0):
self.host = host
self.port = port
self.timeout = timeout
self._sock = None
self._buffer = b""
self._lock = threading.Lock()
# -- connection management -----------------------------------------
def _connect(self):
self._close()
sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
sock.settimeout(self.timeout)
self._sock = sock
self._buffer = b""
def _close(self):
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
self._buffer = b""
def _read_line(self, deadline: float) -> bytes:
while b"\r\n" not in self._buffer:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("no reply from HEOS in time")
self._sock.settimeout(remaining)
chunk = self._sock.recv(4096)
if not chunk:
raise ConnectionError("HEOS closed the connection")
self._buffer += chunk
line, self._buffer = self._buffer.split(b"\r\n", 1)
return line
# -- the one method everything else goes through -------------------
def command(self, path: str, **params) -> dict:
"""Send `heos://<path>?<params>` and return the parsed reply.
Retries once on a socket-level problem, because HEOS quietly
drops connections that have been idle for a few minutes.
"""
with self._lock:
for attempt in (1, 2):
try:
if self._sock is None:
self._connect()
return self._exchange(path, params)
except (OSError, ValueError) as exc:
# ValueError covers a desynced stream (bad JSON): in
# both cases the fix is the same, start a fresh socket.
self._close()
if attempt == 2:
raise HeosError(f"cannot reach HEOS at {self.host}: {exc}") from exc
def _exchange(self, path: str, params: dict) -> dict:
query = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
command = f"heos://{path}" + (f"?{query}" if query else "")
self._sock.sendall(command.encode("utf-8") + b"\r\n")
# Browse calls can take a while, hence the generous overall budget.
deadline = time.monotonic() + self.timeout * 3
while True:
reply = json.loads(self._read_line(deadline).decode("utf-8"))
heos = reply.get("heos", {})
if heos.get("command") != path:
continue # an event or a late reply to something else
if "under process" in heos.get("message", ""):
continue # placeholder ack; the real payload follows
if heos.get("result") == "fail":
raise HeosError(_failure_text(heos))
return reply
def heart_beat(self):
"""Keep the socket warm so the first press after an idle spell is
as fast as the rest."""
self.command("system/heart_beat")
def _failure_text(heos: dict) -> str:
message = parse_message(heos.get("message", ""))
text = message.get("text") or "unknown error"
eid = message.get("eid")
return f"HEOS refused '{heos.get('command')}': {text}" + (f" (eid {eid})" if eid else "")
-488
View File
@@ -1,488 +0,0 @@
#!/usr/bin/env python3
"""
HEOS <-> HTTP bridge for a mixed set of HEOS targets:
- 2x Denon Home 200, combined as a HEOS "In-Room Group" -- this is a
GROUP (addressed by "gid"), not a single player, and uses the
heos://group/... commands rather than heos://player/...
- 1x Denon Home 400 -- a normal player (addressed by "pid")
- 1x Denon AVR-X3800H -- also a normal player (its HEOS module has
its own pid)
You only need to connect to ONE of these devices' CLI port (1255) --
HEOS is a distributed system, so connecting to any single unit lets
you see and control every player/group on the network. The IP below
just needs to point at *one* of your four devices.
Runs a tiny Flask server on the Raspberry Pi. Translates simple HTTP
calls into the correct HEOS CLI commands over a raw TCP socket.
Setup:
pip3 install flask
python3 heos_bridge.py
Then, to see what HEOS actually calls each of your devices/groups
(needed to fill in TARGETS below correctly):
GET http://<pi-ip>:5005/targets
Once TARGETS is filled in:
GET http://<pi-ip>:5005/volume?target=living_room_group
POST http://<pi-ip>:5005/volume/set?target=kitchen&level=25
POST http://<pi-ip>:5005/volume/up?target=avr&step=5
POST http://<pi-ip>:5005/volume/down?target=living_room_group&step=5
POST http://<pi-ip>:5005/volume/mute?target=kitchen
POST http://<pi-ip>:5005/playback/play?target=avr
POST http://<pi-ip>:5005/playback/pause?target=avr
POST http://<pi-ip>:5005/playback/stop?target=avr
POST http://<pi-ip>:5005/playback/next?target=avr
POST http://<pi-ip>:5005/playback/previous?target=avr
GET http://<pi-ip>:5005/inputs?target=avr
POST http://<pi-ip>:5005/input/set?target=avr&input=inputs/hdmi_in_1
POST http://<pi-ip>:5005/input/relay?target=home400&from=avr&input=inputs/tv
POST http://<pi-ip>:5005/group/create?host=avr&members=home400,living_room_group
POST http://<pi-ip>:5005/group/remove?target=home400
The AVR's classic Telnet control protocol (port 23) is separate from HEOS
(port 1255) and is where its *renamed* input list actually lives -- HEOS
itself only knows a fixed generic set of input identifiers, not your
custom names. These endpoints talk to the AVR directly over Telnet:
GET http://<pi-ip>:5005/avr/raw?cmd=SSFUN ? (explore renamed sources)
GET http://<pi-ip>:5005/avr/input (current input, via SI?)
POST http://<pi-ip>:5005/avr/input?input=GAME (select input, via SIGAME)
"""
import json
import socket
from flask import Flask, request, jsonify
# --- Configuration ---------------------------------------------------
# Any one of your devices' IPs works as the connection point.
SPEAKER_IP = "192.168.0.10" # <-- set to any one HEOS device's IP
HEOS_PORT = 1255
AVR_IP = "192.168.0.10" # <-- your AVR's IP (same device SPEAKER_IP points to)
AVR_TELNET_PORT = 23
# Map a short friendly key (used in the ?target= query param) to the
# EXACT name HEOS shows for that player or group (whatever you named
# it in the HEOS app).
#
# Run GET /targets first, copy the "name" values you see there, and
# paste them in on the right-hand side below.
TARGETS = {
"living_room_group": "Denon Home 200 L", # <-- Denon Home 200 In-Room Group
"home400": "Lego Room", # <-- Denon Home 400
"avr": "Home Cinema", # <-- AVR-X3800H
}
# ----------------------------------------------------------------------
app = Flask(__name__)
# name -> ("player", pid) or ("group", gid)
_id_cache = {}
# gid -> a member pid to target for playback commands (groups have no
# play/pause/stop command of their own -- only players do)
_group_leader_pid = {}
def _heos_request(command: str, timeout: float = 4.0) -> dict:
"""Open a TCP connection, send one HEOS CLI command, read the JSON reply.
Some commands (notably deeper 'browse' calls) reply immediately with a
placeholder ack ("command under process") and send the real payload as
a second message on the same connection shortly after. We wait for that
follow-up instead of returning the placeholder.
"""
with socket.create_connection((SPEAKER_IP, HEOS_PORT), timeout=timeout) as sock:
sock.sendall((command + "\r\n").encode("utf-8"))
sock.settimeout(timeout)
def read_one_line(sock_timeout):
sock.settimeout(sock_timeout)
buf = b""
while b"\r\n" not in buf:
chunk = sock.recv(4096)
if not chunk:
break
buf += chunk
return buf.split(b"\r\n", 1)[0]
line = read_one_line(timeout)
reply = json.loads(line.decode("utf-8"))
if "under process" in reply.get("heos", {}).get("message", ""):
line = read_one_line(timeout * 3) # the real payload can take a bit longer
reply = json.loads(line.decode("utf-8"))
return reply
def _refresh_targets() -> list:
"""Fetch every player AND every group currently on the HEOS network."""
_id_cache.clear()
_group_leader_pid.clear()
discovered = []
players_reply = _heos_request("heos://player/get_players")
for p in players_reply.get("payload", []):
_id_cache[p["name"]] = ("player", p["pid"])
discovered.append({"kind": "player", "name": p["name"], "id": p["pid"],
"model": p.get("model", "")})
groups_reply = _heos_request("heos://group/get_groups")
for g in groups_reply.get("payload", []):
_id_cache[g["name"]] = ("group", g["gid"])
members = g.get("players", [])
if members:
# prefer the member with role "leader" if present, else just the first
leader = next((m for m in members if m.get("role") == "leader"), members[0])
_group_leader_pid[g["gid"]] = leader.get("pid")
discovered.append({"kind": "group", "name": g["name"], "id": g["gid"],
"members": [m.get("name") for m in members]})
return discovered
def _resolve(target_key: str):
"""Resolve a friendly key (from TARGETS) to ('player'|'group', id)."""
if target_key not in TARGETS:
raise ValueError(f"Unknown target '{target_key}'. Valid keys: {', '.join(TARGETS)}")
heos_name = TARGETS[target_key]
if heos_name not in _id_cache:
_refresh_targets() # id may have changed, e.g. group re-created after a reboot
if heos_name not in _id_cache:
raise ValueError(
f"No HEOS player or group named '{heos_name}' found. "
f"Check GET /targets for the exact current names."
)
return _id_cache[heos_name]
def _cmd_group(kind: str) -> str:
return "player" if kind == "player" else "group"
def _id_param(kind: str) -> str:
return "pid" if kind == "player" else "gid"
def _volume_status(kind: str, obj_id) -> dict:
cmd = f"heos://{_cmd_group(kind)}/get_volume?{_id_param(kind)}={obj_id}"
reply = _heos_request(cmd)
message = dict(
part.split("=", 1) for part in reply["heos"]["message"].split("&") if "=" in part
)
return {"level": int(message.get("level", -1))}
@app.route("/targets", methods=["GET"])
def list_targets():
"""Diagnostic: see every HEOS player/group on the network and its exact name."""
return jsonify(_refresh_targets())
def _require_target_key():
key = request.args.get("target")
if not key:
raise ValueError(f"Missing '?target=' query param. Valid keys: {', '.join(TARGETS)}")
return key
@app.route("/volume", methods=["GET"])
def get_volume():
try:
kind, obj_id = _resolve(_require_target_key())
return jsonify(_volume_status(kind, obj_id))
except ValueError as e:
return jsonify({"error": str(e)}), 400
@app.route("/volume/set", methods=["POST"])
def set_volume():
level = request.args.get("level", type=int)
if level is None or not (0 <= level <= 100):
return jsonify({"error": "provide integer 'level' between 0 and 100"}), 400
try:
kind, obj_id = _resolve(_require_target_key())
except ValueError as e:
return jsonify({"error": str(e)}), 400
_heos_request(f"heos://{_cmd_group(kind)}/set_volume?{_id_param(kind)}={obj_id}&level={level}")
return jsonify({"level": level})
@app.route("/volume/up", methods=["POST"])
def volume_up():
step = request.args.get("step", default=5, type=int)
try:
kind, obj_id = _resolve(_require_target_key())
except ValueError as e:
return jsonify({"error": str(e)}), 400
_heos_request(f"heos://{_cmd_group(kind)}/volume_up?{_id_param(kind)}={obj_id}&step={step}")
return jsonify(_volume_status(kind, obj_id))
@app.route("/volume/down", methods=["POST"])
def volume_down():
step = request.args.get("step", default=5, type=int)
try:
kind, obj_id = _resolve(_require_target_key())
except ValueError as e:
return jsonify({"error": str(e)}), 400
_heos_request(f"heos://{_cmd_group(kind)}/volume_down?{_id_param(kind)}={obj_id}&step={step}")
return jsonify(_volume_status(kind, obj_id))
@app.route("/group/create", methods=["POST"])
def create_group():
"""Group rooms together. The HOST's currently playing content takes over
the whole group; every other member's own playback is replaced by it.
Order matters: POST /group/create?host=avr&members=home400,living_room_group
makes the AVR the host -- Lego Room and the Living Room pair will start
playing whatever the AVR is playing. Swap host/members to merge the
other way."""
host_key = request.args.get("host")
members_param = request.args.get("members", "")
member_keys = [m.strip() for m in members_param.split(",") if m.strip()]
if not host_key or not member_keys:
return jsonify({"error": "provide '?host=<target>&members=<comma-separated targets>'"}), 400
try:
host_kind, host_id = _resolve(host_key)
host_pid = _playback_pid(host_kind, host_id)
member_pids = []
for key in member_keys:
kind, obj_id = _resolve(key)
member_pids.append(_playback_pid(kind, obj_id))
except ValueError as e:
return jsonify({"error": str(e)}), 400
# Host's pid MUST come first -- that's what makes it the leader whose
# content the whole group plays.
pid_list = ",".join(str(p) for p in [host_pid] + member_pids)
_heos_request(f"heos://group/set_group?pid={pid_list}")
return jsonify({"host": host_key, "members": member_keys})
@app.route("/group/remove", methods=["POST"])
def remove_from_group():
"""Take a room back out of whatever dynamic group it's currently in.
POST /group/remove?target=home400"""
try:
kind, obj_id = _resolve(_require_target_key())
pid = _playback_pid(kind, obj_id)
except ValueError as e:
return jsonify({"error": str(e)}), 400
# A set_group call with a single pid removes that player from any group.
_heos_request(f"heos://group/set_group?pid={pid}")
return jsonify({"ok": True})
@app.route("/volume/mute", methods=["POST"])
def toggle_mute():
try:
kind, obj_id = _resolve(_require_target_key())
except ValueError as e:
return jsonify({"error": str(e)}), 400
_heos_request(f"heos://{_cmd_group(kind)}/toggle_mute?{_id_param(kind)}={obj_id}")
return jsonify({"ok": True})
def _playback_pid(kind: str, obj_id):
"""Playback commands only exist under 'player', so a group needs to be
translated to one of its member pids."""
if kind == "player":
return obj_id
pid = _group_leader_pid.get(obj_id)
if pid is None:
raise ValueError("Could not determine a playable member for this group")
return pid
def _set_play_state(state: str):
try:
kind, obj_id = _resolve(_require_target_key())
pid = _playback_pid(kind, obj_id)
except ValueError as e:
return jsonify({"error": str(e)}), 400
_heos_request(f"heos://player/set_play_state?pid={pid}&state={state}")
return jsonify({"state": state})
@app.route("/playback/play", methods=["POST"])
def play():
return _set_play_state("play")
@app.route("/playback/pause", methods=["POST"])
def pause():
return _set_play_state("pause")
@app.route("/playback/stop", methods=["POST"])
def stop():
return _set_play_state("stop")
@app.route("/playback/next", methods=["POST"])
def next_track():
try:
kind, obj_id = _resolve(_require_target_key())
pid = _playback_pid(kind, obj_id)
except ValueError as e:
return jsonify({"error": str(e)}), 400
_heos_request(f"heos://player/play_next?pid={pid}")
return jsonify({"ok": True})
@app.route("/playback/previous", methods=["POST"])
def previous_track():
try:
kind, obj_id = _resolve(_require_target_key())
pid = _playback_pid(kind, obj_id)
except ValueError as e:
return jsonify({"error": str(e)}), 400
_heos_request(f"heos://player/play_previous?pid={pid}")
return jsonify({"ok": True})
@app.route("/raw/<path:subpath>", methods=["GET"])
def raw(subpath):
"""Diagnostic: forward any heos:// command as-is and return the raw reply.
e.g. GET /raw/browse/browse?sid=1027"""
query = request.query_string.decode()
cmd = f"heos://{subpath}" + (f"?{query}" if query else "")
return jsonify(_heos_request(cmd))
@app.route("/inputs", methods=["GET"])
def list_inputs():
"""List the physical inputs available on a target (mainly useful for the AVR).
Speakers with no physical inputs will just return an empty list."""
try:
kind, obj_id = _resolve(_require_target_key())
pid = _playback_pid(kind, obj_id)
except ValueError as e:
return jsonify({"error": str(e)}), 400
# Each device's own sid (as listed under the fixed "HEOS aux inputs"
# source, sid=1027) equals its pid -- browsing that lists its inputs.
reply = _heos_request(f"heos://browse/browse?sid={pid}")
items = reply.get("payload", [])
return jsonify([{"name": i.get("name"), "input_id": i.get("mid")} for i in items])
@app.route("/input/set", methods=["POST"])
def set_input():
input_id = request.args.get("input")
if not input_id:
return jsonify({"error": "provide '?input=' -- see GET /inputs for valid values"}), 400
try:
kind, obj_id = _resolve(_require_target_key())
pid = _playback_pid(kind, obj_id)
except ValueError as e:
return jsonify({"error": str(e)}), 400
_heos_request(f"heos://browse/play_input?pid={pid}&input={input_id}")
return jsonify({"input": input_id})
@app.route("/input/relay", methods=["POST"])
def relay_input():
"""Push one device's input onto another, e.g. play the AVR's 'TV' input
on the Home 400: POST /input/relay?target=home400&from=avr&input=inputs/tv"""
input_id = request.args.get("input")
from_key = request.args.get("from")
if not input_id or not from_key:
return jsonify({"error": "provide '?from=<source target>&input=<input id>'"}), 400
try:
dest_kind, dest_id = _resolve(_require_target_key())
dest_pid = _playback_pid(dest_kind, dest_id)
src_kind, src_id = _resolve(from_key)
src_pid = _playback_pid(src_kind, src_id)
except ValueError as e:
return jsonify({"error": str(e)}), 400
_heos_request(
f"heos://browse/play_input?pid={dest_pid}&spid={src_pid}&input={input_id}"
)
return jsonify({"input": input_id, "from": from_key})
# --- AVR Telnet control (port 23) -------------------------------------
# Completely separate protocol from HEOS. Commands are short plain-text
# strings terminated by \r (not \r\n), e.g. "SI?" (query input),
# "SIGAME" (select the GAME input), "SSFUN ?" (list renamed sources).
# The AVR sends back one or more lines; we collect everything that
# arrives within a short window since queries can return multiple lines.
def _denon_telnet_request(command: str, timeout: float = 3.0) -> list:
with socket.create_connection((AVR_IP, AVR_TELNET_PORT), timeout=timeout) as sock:
sock.sendall((command + "\r").encode("utf-8"))
sock.settimeout(timeout)
buf = b""
try:
while True:
chunk = sock.recv(4096)
if not chunk:
break
buf += chunk
except socket.timeout:
pass # normal: we just stop once nothing more arrives in time
return [line for line in buf.decode("utf-8", errors="replace").split("\r") if line]
def _parse_ssfun(lines: list) -> list:
"""Parse SSFUN ? output like 'SSFUNBD Blu-ray ' into
[{"code": "BD", "name": "Blu-ray"}, ...], skipping the 'SSFUN END' terminator."""
result = []
for line in lines:
if not line.startswith("SSFUN"):
continue
rest = line[len("SSFUN"):]
if rest.strip() == "END":
continue
parts = rest.split(" ", 1)
if len(parts) != 2:
continue
code, name = parts[0], parts[1].strip()
result.append({"code": code, "name": name})
return result
@app.route("/avr/raw", methods=["GET"])
def avr_raw():
"""Diagnostic: send any raw Telnet command to the AVR and see every
line it sends back, e.g. GET /avr/raw?cmd=SSFUN ?"""
cmd = request.args.get("cmd")
if not cmd:
return jsonify({"error": "provide '?cmd=<raw telnet command>'"}), 400
return jsonify({"lines": _denon_telnet_request(cmd)})
@app.route("/avr/input", methods=["GET"])
def avr_get_input():
lines = _denon_telnet_request("SI?")
return jsonify({"lines": lines})
@app.route("/avr/inputs", methods=["GET"])
def avr_list_inputs():
"""Friendly parsed version of /avr/raw?cmd=SSFUN ? -- your actual
renamed input list, with the SI codes to use with /avr/input."""
lines = _denon_telnet_request("SSFUN ?")
return jsonify(_parse_ssfun(lines))
@app.route("/avr/input", methods=["POST"])
def avr_set_input():
input_code = request.args.get("input")
if not input_code:
return jsonify({"error": "provide '?input=<code>', e.g. GAME, TV, CD, AUX1"}), 400
lines = _denon_telnet_request(f"SI{input_code}")
return jsonify({"input": input_code, "lines": lines})
if __name__ == "__main__":
# 0.0.0.0 so your phone can reach it over the LAN
app.run(host="0.0.0.0", port=5005)
+1
View File
@@ -0,0 +1 @@
flask>=3.0
+275
View File
@@ -0,0 +1,275 @@
/* The panel. Every control acts immediately and reconciles with what the
speakers report a moment later, because a remote that waits for a
network round trip before it looks like it did anything feels broken. */
const STEP = Number(document.documentElement.dataset.step) || 2;
const POLL_MS = 5000;
const el = (sel, root = document) => root.querySelector(sel);
const els = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const ui = {
foot: el('[data-role="foot"]'),
toast: el('[data-role="toast"]'),
refresh: el('[data-role="refresh"]'),
splitAll: el('[data-role="split-all"]'),
avrStatus: el('[data-role="avr-status"]'),
inputButton: el('[data-role="input-button"]'),
inputName: el('[data-role="input-name"]'),
sheet: el('[data-role="sheet"]'),
options: el('[data-role="options"]'),
};
const rooms = {};
let inputs = [];
let currentInput = null;
/* --- transport -------------------------------------------------------- */
async function api(path, body) {
const options = body === undefined
? {}
: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
const response = await fetch(path, options);
let data = {};
try { data = await response.json(); } catch (_) { /* empty or not JSON */ }
if (!response.ok) throw new Error(data.error || `${response.status} ${response.statusText}`);
return data;
}
let toastTimer;
function toast(message) {
ui.toast.textContent = message;
ui.toast.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { ui.toast.hidden = true; }, 4000);
}
/* --- rooms ------------------------------------------------------------ */
els('.room').forEach((node) => {
const key = node.dataset.room;
const room = {
key,
node,
level: el('[data-role="level"]', node),
bar: el('[data-role="bar"]', node),
toggle: el('[data-role="group"]', node),
toggleLabel: el('[data-role="group-label"]', node),
steps: els('.step', node),
volume: null,
grouped: false,
available: false,
pending: 0, // taps not yet sent
inflight: false,
busy: false, // a grouping change is in flight
};
rooms[key] = room;
room.steps.forEach((button) => {
const direction = Number(button.dataset.delta);
holdable(button, () => nudge(key, direction));
});
room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
});
function paintRoom(room) {
const known = room.volume !== null && room.volume !== undefined;
room.level.textContent = known ? room.volume : '—';
room.bar.style.width = `${known ? room.volume : 0}%`;
room.node.classList.toggle('offline', !room.available);
room.steps.forEach((button) => { button.disabled = !room.available; });
room.toggle.disabled = !room.available;
room.toggle.classList.toggle('busy', room.busy);
room.toggle.setAttribute('aria-pressed', String(room.grouped));
room.toggleLabel.textContent = room.grouped ? 'Grouped with AVR' : 'Separate';
}
/* Press and hold to keep moving, accelerating as you hold. */
function holdable(node, action) {
let timer = null;
let delay = 420;
const stop = () => { clearTimeout(timer); timer = null; delay = 420; };
const tick = () => {
action();
delay = Math.max(130, delay * 0.72);
timer = setTimeout(tick, delay);
};
node.addEventListener('pointerdown', (event) => {
if (event.button > 0 || node.disabled) return;
event.preventDefault(); // also suppresses the click that would double-fire
action();
timer = setTimeout(tick, delay);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach((type) => node.addEventListener(type, stop));
}
function nudge(key, direction) {
const room = rooms[key];
if (!room.available) return;
room.volume = Math.max(0, Math.min(100, (room.volume ?? 0) + direction * STEP));
room.pending += direction * STEP;
paintRoom(room);
flushVolume(key);
}
/* A burst of taps collapses into one call, so holding + does not queue up
thirty requests the speakers then have to chew through. */
async function flushVolume(key) {
const room = rooms[key];
if (room.inflight || !room.pending) return;
const delta = room.pending;
room.pending = 0;
room.inflight = true;
try {
const data = await api('/api/volume', { target: key, delta });
if (!room.pending) {
room.volume = data.level;
paintRoom(room);
}
} catch (error) {
toast(error.message);
refresh();
} finally {
room.inflight = false;
if (room.pending) flushVolume(key);
}
}
async function setGrouped(key, joined) {
const room = rooms[key];
if (room.busy || !room.available) return;
room.busy = true;
room.grouped = joined; // show the new state while the speakers catch up
paintRoom(room);
try {
const data = await api('/api/group', { target: key, joined });
applyJoined(data.joined || []);
} catch (error) {
toast(error.message);
} finally {
room.busy = false;
refresh();
}
}
function applyJoined(joined) {
Object.values(rooms).forEach((room) => {
room.grouped = joined.includes(room.key);
paintRoom(room);
});
}
ui.splitAll.addEventListener('click', async () => {
try {
applyJoined([]);
const data = await api('/api/group/none');
applyJoined(data.joined || []);
} catch (error) {
toast(error.message);
} finally {
refresh();
}
});
/* --- the input picker -------------------------------------------------- */
ui.inputButton.addEventListener('click', openSheet);
el('[data-role="scrim"]').addEventListener('click', closeSheet);
el('[data-role="sheet-close"]').addEventListener('click', closeSheet);
function sheetOpen() { return !ui.sheet.hidden; }
function openSheet() {
if (!inputs.length) {
toast('No inputs reported by the AVR yet');
return;
}
ui.options.innerHTML = '';
inputs.forEach((source) => {
const option = document.createElement('button');
option.className = 'option';
option.setAttribute('aria-current', String(currentInput && currentInput.code === source.code));
option.innerHTML = '<span></span><span class="code"></span>';
option.firstChild.textContent = source.name;
option.lastChild.textContent = source.code;
option.addEventListener('click', () => chooseInput(source));
ui.options.appendChild(option);
});
ui.sheet.hidden = false;
}
function closeSheet() { ui.sheet.hidden = true; }
async function chooseInput(source) {
closeSheet();
currentInput = source;
ui.inputName.textContent = source.name;
try {
const data = await api('/api/avr/input', { code: source.code });
currentInput = data;
ui.inputName.textContent = data.name;
} catch (error) {
toast(error.message);
refresh();
}
}
/* --- state ------------------------------------------------------------- */
function render(state) {
(state.rooms || []).forEach((incoming) => {
const room = rooms[incoming.key];
if (!room) return;
room.available = incoming.available;
room.grouped = incoming.grouped;
// Do not stomp on a volume the user is in the middle of changing.
if (!room.pending && !room.inflight) room.volume = incoming.volume;
paintRoom(room);
});
const avr = state.avr || {};
inputs = avr.inputs || [];
currentInput = avr.input || null;
ui.inputName.textContent = currentInput ? currentInput.name : '—';
ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline';
ui.avrStatus.classList.toggle('on', Boolean(avr.connected));
const problems = state.errors || [];
ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : '');
ui.foot.classList.toggle('bad', problems.length > 0);
}
let refreshing = false;
async function refresh() {
if (refreshing) return;
refreshing = true;
try {
render(await api('/api/state'));
} catch (error) {
ui.foot.textContent = error.message;
ui.foot.classList.add('bad');
} finally {
refreshing = false;
}
}
function busy() {
return sheetOpen() || Object.values(rooms).some((r) => r.pending || r.inflight || r.busy);
}
ui.refresh.addEventListener('click', () => {
ui.refresh.classList.add('spin');
setTimeout(() => ui.refresh.classList.remove('spin'), 700);
refresh();
});
setInterval(() => {
if (document.visibilityState === 'visible' && !busy()) refresh();
}, POLL_MS);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') refresh();
});
Object.values(rooms).forEach(paintRoom);
refresh();
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+195
View File
@@ -0,0 +1,195 @@
/* A remote lives in the dark next to a TV, so the panel is dark too --
and every control is sized for a thumb, not a cursor. */
:root {
color-scheme: dark;
--bg: #0a0d14;
--card: #151a25;
--edge: #232b3b;
--raised: #1e2634;
--ink: #eef2f9;
--muted: #8d98ad;
--accent: #5b8def;
--live: #3ddc97;
--warn: #ff7a6b;
--radius: 22px;
}
* { box-sizing: border-box; }
html { background: var(--bg); }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.4 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
user-select: none;
overscroll-behavior-y: contain;
}
button {
font: inherit;
color: inherit;
border: 0;
background: none;
cursor: pointer;
touch-action: manipulation;
}
svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.app {
max-width: 520px;
margin: 0 auto;
padding: max(12px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
display: flex;
flex-direction: column;
gap: 14px;
}
/* --- header ---------------------------------------------------------- */
.top { display: flex; align-items: center; justify-content: space-between; padding: 6px 4px 0; }
.top h1 { margin: 0; font-size: 22px; font-weight: 650; letter-spacing: .02em; }
.icon-button {
width: 44px; height: 44px; border-radius: 50%;
display: grid; place-items: center;
color: var(--muted); background: var(--card);
}
.icon-button:active { background: var(--raised); color: var(--ink); }
.icon-button.spin svg { animation: spin .7s linear; }
@keyframes spin { to { transform: rotate(360deg); } }
/* --- cards ----------------------------------------------------------- */
.card {
background: var(--card);
border: 1px solid var(--edge);
border-radius: var(--radius);
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.card.offline { opacity: .5; }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.card-head h2 { margin: 0; font-size: 17px; font-weight: 600; }
.level { color: var(--muted); font-size: 13px; }
.level b {
color: var(--ink);
font-size: 26px;
font-weight: 640;
font-variant-numeric: tabular-nums;
}
.pill {
font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); background: var(--raised);
padding: 4px 9px; border-radius: 999px;
}
.pill.on { color: var(--live); }
/* --- volume ---------------------------------------------------------- */
.volume { display: flex; align-items: center; gap: 14px; }
.step {
flex: 0 0 auto;
width: 78px; height: 62px;
border-radius: 18px;
background: var(--raised);
font-size: 30px; font-weight: 500; line-height: 1;
display: grid; place-items: center;
}
.step:active { background: var(--accent); transform: scale(.96); }
.step:disabled { opacity: .4; }
.meter { flex: 1; height: 8px; border-radius: 999px; background: #0c111b; overflow: hidden; }
.meter i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--accent); transition: width .12s ease-out; }
/* --- join / leave the AVR -------------------------------------------- */
.toggle {
height: 52px; border-radius: 16px;
background: var(--raised);
display: flex; align-items: center; justify-content: center; gap: 10px;
font-size: 15px; font-weight: 550;
color: var(--muted);
}
.toggle .dot { width: 9px; height: 9px; border-radius: 50%; background: currentColor; opacity: .6; }
.toggle[aria-pressed="true"] { background: var(--accent); color: #fff; }
.toggle[aria-pressed="true"] .dot { background: #fff; opacity: 1; }
.toggle:active { transform: scale(.985); }
.toggle:disabled { opacity: .5; }
.toggle.busy { opacity: .6; }
/* --- source card ------------------------------------------------------ */
.source-button {
display: flex; align-items: center; gap: 12px;
width: 100%; min-height: 64px;
padding: 10px 14px;
border-radius: 16px;
background: var(--raised);
text-align: left;
}
.source-button:active { transform: scale(.985); }
.source-button .eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
.source-button .value { flex: 1; font-size: 19px; font-weight: 600; }
.source-button .chevron { color: var(--muted); }
/* --- misc ------------------------------------------------------------- */
.wide { width: 100%; height: 50px; border-radius: 16px; font-size: 15px; }
.ghost { background: transparent; border: 1px solid var(--edge); color: var(--muted); }
.ghost:active { background: var(--card); color: var(--ink); }
.foot { margin: 2px 4px 0; min-height: 18px; font-size: 12px; color: var(--muted); text-align: center; }
.foot.bad { color: var(--warn); }
/* --- input sheet ------------------------------------------------------ */
.sheet-wrap { position: fixed; inset: 0; z-index: 10; display: flex; flex-direction: column; justify-content: flex-end; }
.scrim { position: absolute; inset: 0; background: rgba(4, 6, 11, .6); backdrop-filter: blur(3px); }
.sheet {
position: relative;
background: var(--card);
border: 1px solid var(--edge);
border-radius: 28px 28px 0 0;
padding: 10px 16px calc(16px + env(safe-area-inset-bottom));
max-height: 82vh;
display: flex; flex-direction: column; gap: 12px;
animation: rise .22s cubic-bezier(.22, .68, .3, 1);
}
@keyframes rise { from { transform: translateY(14%); opacity: .4; } }
.grabber { width: 38px; height: 4px; border-radius: 999px; background: var(--edge); margin: 2px auto 4px; }
.sheet h3 { margin: 0 4px; font-size: 14px; font-weight: 600; color: var(--muted); }
.options { display: flex; flex-direction: column; gap: 8px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
.option {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
min-height: 58px; padding: 0 16px;
border-radius: 16px; background: var(--raised);
font-size: 17px; text-align: left;
}
.option:active { transform: scale(.985); }
.option[aria-current="true"] { background: var(--accent); color: #fff; }
.option .code { font-size: 12px; color: var(--muted); }
.option[aria-current="true"] .code { color: rgba(255, 255, 255, .8); }
/* --- toast ------------------------------------------------------------ */
.toast {
position: fixed; left: 50%; transform: translateX(-50%);
bottom: calc(22px + env(safe-area-inset-bottom));
z-index: 20; max-width: 90vw;
padding: 12px 16px; border-radius: 14px;
background: #2b1f24; border: 1px solid #52303a; color: #ffd9d4;
font-size: 14px; box-shadow: 0 10px 30px rgba(0, 0, 0, .45);
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
+80
View File
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="en" data-step="{{ step }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<title>{{ app_name }}</title>
<!-- Added to the iOS home screen, this opens full-screen with no browser chrome. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ app_name }}">
<meta name="theme-color" content="#0a0d14">
<link rel="manifest" href="{{ url_for('manifest') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icon-180.png') }}">
<link rel="icon" href="{{ url_for('static', filename='icon-180.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='panel.css') }}">
</head>
<body>
<div class="app">
<header class="top">
<h1>{{ app_name }}</h1>
<button class="icon-button" data-role="refresh" aria-label="Refresh">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.34-5.66M20 4v5h-5"/></svg>
</button>
</header>
<section class="card source">
<div class="card-head">
<h2>{{ host.label }}</h2>
<span class="pill" data-role="avr-status">offline</span>
</div>
<button class="source-button" data-role="input-button">
<span class="eyebrow">Input</span>
<span class="value" data-role="input-name"></span>
<svg class="chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
</button>
</section>
{% for room in rooms %}
<section class="card room" data-room="{{ room.key }}">
<div class="card-head">
<h2>{{ room.label }}</h2>
<span class="level"><b data-role="level"></b></span>
</div>
<div class="volume">
<button class="step" data-delta="-1" aria-label="{{ room.label }}: volume down">&minus;</button>
<div class="meter"><i data-role="bar"></i></div>
<button class="step" data-delta="1" aria-label="{{ room.label }}: volume up">+</button>
</div>
<button class="toggle" data-role="group" aria-pressed="false">
<span class="dot" aria-hidden="true"></span>
<span data-role="group-label">Separate</span>
</button>
</section>
{% endfor %}
<button class="wide ghost" data-role="split-all">Separate everything</button>
<p class="foot" data-role="foot"></p>
</div>
<div class="sheet-wrap" data-role="sheet" hidden>
<div class="scrim" data-role="scrim"></div>
<div class="sheet" role="dialog" aria-modal="true" aria-label="{{ host.label }} input">
<div class="grabber" aria-hidden="true"></div>
<h3>{{ host.label }} input</h3>
<div class="options" data-role="options"></div>
<button class="wide ghost" data-role="sheet-close">Cancel</button>
</div>
</div>
<div class="toast" data-role="toast" hidden></div>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{
"name": "{{ app_name }}",
"short_name": "{{ app_name }}",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0d14",
"theme_color": "#0a0d14",
"icons": [
{ "src": "/static/icon-180.png", "sizes": "180x180", "type": "image/png" },
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
View File
+170
View File
@@ -0,0 +1,170 @@
"""Stand-in Denon hardware: just enough HEOS and Telnet to test against.
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
out by experimenting on the speakers at eleven at night.
"""
import json
import socket
import threading
class FakeHeos(threading.Thread):
"""A HEOS CLI server on localhost, with four players and a pair."""
NAMES = {1: "Home Cinema", 2: "Lego Room", 3: "Denon Home 200 L", 4: "Denon Home 200 R"}
def __init__(self):
super().__init__(daemon=True)
self.groups = {3: [3, 4]} # gid -> pids, leader first
self.volumes = {pid: 20 for pid in self.NAMES}
self.group_volumes = {3: 25}
self.commands = [] # everything we were asked to do
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(8)
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(4096)
except OSError:
return
if not chunk:
return
buffer += chunk
while b"\r\n" in buffer:
line, buffer = buffer.split(b"\r\n", 1)
reply = self.handle(line.decode().strip())
conn.sendall(json.dumps(reply).encode() + b"\r\n")
# -- command handling ------------------------------------------------
def handle(self, command: str) -> dict:
self.commands.append(command)
path, _, query = command[len("heos://"):].partition("?")
args = dict(part.split("=", 1) for part in query.split("&") if "=" in part)
if path == "player/get_players":
return self._ok(path, payload=[
{"name": name, "pid": pid, "model": "Fake"} for pid, name in self.NAMES.items()
])
if path == "group/get_groups":
payload = []
for gid, pids in self.groups.items():
payload.append({
"name": self.NAMES[gid], "gid": gid,
"players": [
{"name": self.NAMES[pid], "pid": pid,
"role": "leader" if pid == gid else "member"}
for pid in pids
],
})
return self._ok(path, payload=payload)
if path == "group/set_group":
pids = [int(p) for p in args["pid"].split(",")]
for gid in list(self.groups):
self.groups[gid] = [p for p in self.groups[gid] if p not in pids]
if len(self.groups[gid]) < 2:
del self.groups[gid] # HEOS dissolves a group of one
if len(pids) > 1:
self.groups[pids[0]] = pids
self.group_volumes.setdefault(pids[0], 25)
return self._ok(path, message=args["pid"])
if path.endswith("/get_volume"):
store, key = self._store(path, args)
return self._ok(path, message=f"{key[0]}={key[1]}&level={store[key[1]]}")
if path.endswith("/set_volume"):
store, key = self._store(path, args)
store[key[1]] = int(args["level"])
return self._ok(path, message=f"{key[0]}={key[1]}&level={args['level']}")
if path.endswith("/toggle_mute") or path == "system/heart_beat":
return self._ok(path)
return {"heos": {"command": path, "result": "fail", "message": "eid=2&text=Not+supported"}}
def _store(self, path, args):
if path.startswith("group/"):
gid = int(args["gid"])
if gid not in self.groups:
raise KeyError(gid)
return self.group_volumes, ("gid", gid)
return self.volumes, ("pid", int(args["pid"]))
@staticmethod
def _ok(path, message="", payload=None):
reply = {"heos": {"command": path, "result": "success", "message": message}}
if payload is not None:
reply["payload"] = payload
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")]
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 == "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 []
+166
View File
@@ -0,0 +1,166 @@
"""Run against the fake hardware in fakes.py:
python3 -m unittest discover -s tests -t .
"""
import sys
import tempfile
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from controller import Controller # noqa: E402
from tests.fakes import FakeAvr, FakeHeos # noqa: E402
PAIR = {3, 4} # the two Home 200s
AVR_PID = 1
HOME400_PID = 2
def build(tmpdir):
heos, avr = FakeHeos(), FakeAvr()
cfg = SimpleNamespace(
HEOS_HOST="127.0.0.1", HEOS_PORT=heos.port,
AVR_HOST="127.0.0.1", AVR_PORT=avr.port,
HOST_KEY="avr",
ROOM_KEYS=["home400", "living_room_group"],
TARGETS={
"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"},
"home400": {"label": "Lego Room", "heos_name": "Lego Room"},
"living_room_group": {"label": "Living Room", "heos_name": "Denon Home 200 L"},
},
AVR_INPUT_CODES=[],
MEMBERS_FILE=str(Path(tmpdir) / "members.json"),
)
return Controller(cfg), heos, avr
class PanelTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.panel, self.heos, self.avr = build(self.tmp.name)
def group_pids(self):
return {gid: set(pids) for gid, pids in self.heos.groups.items()}
# -- resolving ------------------------------------------------------
def test_pair_resolves_to_both_speakers(self):
"""The bug this replaces: grouping used only the pair's leader,
which left the second Home 200 behind."""
self.panel.scan()
self.assertEqual(set(self.panel.member_pids("living_room_group")), PAIR)
self.assertEqual(self.panel.member_pids("home400"), [HOME400_PID])
def test_avr_resolves_to_a_player_even_while_it_leads_a_group(self):
self.panel.join("home400")
# HEOS now reports a *group* named "Home Cinema" as well as the player.
self.assertEqual(self.panel.member_pids("avr"), [AVR_PID])
# -- grouping -------------------------------------------------------
def test_joining_takes_the_whole_pair(self):
self.panel.join("living_room_group")
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID} | PAIR})
self.assertEqual(self.panel.joined_keys(), ["living_room_group"])
def test_joining_keeps_whoever_is_already_grouped(self):
self.panel.join("home400")
self.panel.join("living_room_group")
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, HOME400_PID} | PAIR})
self.assertEqual(self.panel.joined_keys(), ["home400", "living_room_group"])
def test_leaving_rebuilds_the_stereo_pair(self):
self.panel.join("living_room_group")
self.panel.leave("living_room_group")
self.assertEqual(self.group_pids(), {3: PAIR}) # pair back, AVR alone
self.assertEqual(self.panel.joined_keys(), [])
def test_leaving_one_room_does_not_disturb_the_other(self):
self.panel.join("home400")
self.panel.join("living_room_group")
before = [c for c in self.heos.commands if "set_group" in c]
self.panel.leave("home400")
after = [c for c in self.heos.commands if "set_group" in c]
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID} | PAIR})
# Exactly one new set_group: the remaining room is never regrouped,
# which is what stops its music restarting.
self.assertEqual(len(after) - len(before), 1)
def test_separate_everything(self):
self.panel.set_membership(["home400", "living_room_group"])
self.panel.set_membership([])
self.assertEqual(self.group_pids(), {3: PAIR})
self.assertEqual(self.panel.joined_keys(), [])
def test_membership_survives_a_restart_while_merged(self):
"""Once merged, the pair's own group is gone from HEOS, so a fresh
process has to fall back on what it learned earlier."""
self.panel.join("living_room_group")
reborn = Controller(self.panel.cfg)
reborn.scan()
self.assertEqual(set(reborn.member_pids("living_room_group")), PAIR)
reborn.leave("living_room_group")
self.assertEqual(self.group_pids(), {3: PAIR})
# -- volume ---------------------------------------------------------
def test_pair_uses_group_volume_when_it_stands_alone(self):
self.panel.scan()
self.assertEqual(self.panel.set_volume("living_room_group", 42), 42)
self.assertEqual(self.heos.group_volumes[3], 42)
def test_pair_uses_player_volume_once_merged(self):
"""Its gid stops existing the moment it joins the AVR, so the old
bridge's get_volume?gid= call would simply fail here."""
self.panel.join("living_room_group")
self.assertEqual(self.panel.set_volume("living_room_group", 31), 31)
self.assertEqual(self.heos.volumes[3], 31)
self.assertEqual(self.heos.volumes[4], 31)
self.assertEqual(self.panel.volume("living_room_group"), 31)
def test_nudge_clamps_at_the_ends(self):
self.panel.set_volume("home400", 98)
self.assertEqual(self.panel.nudge_volume("home400", 5), 100)
self.panel.set_volume("home400", 1)
self.assertEqual(self.panel.nudge_volume("home400", -9), 0)
# -- the AVR ---------------------------------------------------------
def test_renamed_inputs_and_selection(self):
deadline = time.time() + 5
while not self.panel.avr.connected and time.time() < deadline:
time.sleep(0.05)
self.assertTrue(self.panel.avr.connected)
self.assertEqual(
self.panel.avr.inputs(),
[{"code": "MPLAY", "name": "Apple TV"},
{"code": "GAME", "name": "PlayStation"},
{"code": "SAT/CBL", "name": "TV Box"}],
)
self.assertEqual(self.panel.avr.current_input(), {"code": "MPLAY", "name": "Apple TV"})
self.assertEqual(self.panel.avr.select_input("GAME"), {"code": "GAME", "name": "PlayStation"})
self.assertEqual(self.avr.input, "GAME")
# -- the whole snapshot the UI renders -------------------------------
def test_state_snapshot(self):
self.panel.join("home400")
state = self.panel.state()
self.assertTrue(state["heos_ok"])
self.assertEqual([r["key"] for r in state["rooms"]], ["home400", "living_room_group"])
self.assertEqual([r["grouped"] for r in state["rooms"]], [True, False])
self.assertTrue(all(isinstance(r["volume"], int) for r in state["rooms"]))
def test_state_reports_trouble_instead_of_blowing_up(self):
self.panel.heos.host = "127.0.0.1"
self.panel.heos.port = 1 # nothing is listening there
self.panel.heos._close()
state = self.panel.state()
self.assertFalse(state["heos_ok"])
self.assertTrue(state["errors"])
self.assertTrue(all(r["available"] is False for r in state["rooms"]))
if __name__ == "__main__":
unittest.main()
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Draw the home-screen icon, with no image library to install.
python3 tools/make_icons.py
Writes static/icon-180.png (what iOS uses for the home screen) and
static/icon-512.png (Android / the web manifest). Re-run it if you want
different colours -- they are the two constants below.
"""
import math
import struct
import zlib
from pathlib import Path
TOP = (0x1D, 0x27, 0x3C) # background gradient, top
BOTTOM = (0x0A, 0x0D, 0x14) # background gradient, bottom
GLYPH = (0xEE, 0xF2, 0xF9) # the wave itself
STATIC = Path(__file__).resolve().parent.parent / "static"
SAMPLES = 3 # supersampling per axis, for smooth edges
def coverage(x: float, y: float, size: float) -> float:
"""How much of the glyph covers this point: a dot plus three arcs
opening to the right, i.e. the usual 'sound coming out' mark."""
cx, cy = 0.33 * size, 0.5 * size
dx, dy = x - cx, y - cy
distance = math.hypot(dx, dy)
if distance <= 0.075 * size:
return 1.0
angle = abs(math.degrees(math.atan2(dy, dx)))
if angle > 50:
return 0.0
half = 0.024 * size
for radius in (0.17, 0.27, 0.37):
if abs(distance - radius * size) <= half:
return 1.0
return 0.0
def render(size: int) -> bytes:
rows = bytearray()
step = 1.0 / SAMPLES
for py in range(size):
rows.append(0) # PNG filter type 0 for this scanline
mix = py / max(1, size - 1)
background = tuple(
round(TOP[i] + (BOTTOM[i] - TOP[i]) * mix) for i in range(3)
)
for px in range(size):
hits = 0
for sy in range(SAMPLES):
for sx in range(SAMPLES):
if coverage(px + (sx + 0.5) * step, py + (sy + 0.5) * step, size):
hits += 1
alpha = hits / (SAMPLES * SAMPLES)
if alpha == 0:
rows.extend(background)
else:
rows.extend(
round(background[i] + (GLYPH[i] - background[i]) * alpha) for i in range(3)
)
return bytes(rows)
def write_png(path: Path, size: int, raw: bytes):
def chunk(kind: bytes, data: bytes) -> bytes:
return (struct.pack(">I", len(data)) + kind + data
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF))
header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8-bit truecolour
path.write_bytes(
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b"")
)
if __name__ == "__main__":
for size in (180, 512):
target = STATIC / f"icon-{size}.png"
write_png(target, size, render(size))
print(f"wrote {target} ({target.stat().st_size} bytes)")