Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffb1447209 | ||
|
|
467cae4dd5 | ||
|
|
f6c98abe90 | ||
|
|
6d132f53ff | ||
|
|
6213bcf23f | ||
|
|
aa1b734b3a | ||
|
|
1a1fcc84d6 | ||
|
|
4affd18b3b | ||
|
|
a1fb7a318a | ||
|
|
e3b2804aa0 | ||
|
|
375bcfdd76 | ||
|
|
86fdf9a4d3 |
@@ -0,0 +1,97 @@
|
||||
# runs-on must match the label the runner was registered with -- `heos`
|
||||
# here, the way Livetrail uses `livetrail`. A job asking for a label
|
||||
# nobody offers sits in the queue rather than failing.
|
||||
#
|
||||
# The runner has to be in host mode on the machine that serves the panel:
|
||||
# it writes into DEPLOY_PATH and restarts the service. See
|
||||
# deploy/heos-panel.service for the unit and the one sudoers line the
|
||||
# restart needs.
|
||||
#
|
||||
# DEPLOY_PATH is also where you edit: an rsync --delete lands on top of
|
||||
# whatever is sitting there uncommitted, so commit before you push.
|
||||
|
||||
name: Deploy HEOS panel
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: heos
|
||||
|
||||
env:
|
||||
DEPLOY_PATH: /var/www/html/heos
|
||||
SERVICE: heos-panel
|
||||
PANEL_URL: http://127.0.0.1:5005/ # WEB_PORT in config.py
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v python3
|
||||
command -v rsync
|
||||
command -v curl
|
||||
|
||||
- name: Check deploy path
|
||||
run: |
|
||||
test -d "$DEPLOY_PATH"
|
||||
test -w "$DEPLOY_PATH"
|
||||
|
||||
- name: Check the restart is allowed without a password
|
||||
run: sudo -n systemctl is-active "$SERVICE" || true
|
||||
|
||||
# A throwaway virtualenv in the workspace: the one under
|
||||
# $DEPLOY_PATH/.venv is what the running panel imports from, and a
|
||||
# test run has no business touching it.
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m venv .venv-ci
|
||||
.venv-ci/bin/pip install --quiet --upgrade pip
|
||||
.venv-ci/bin/pip install --quiet -r requirements.txt
|
||||
|
||||
# Runs against the fake HEOS and AVR servers in tests/fakes.py, so it
|
||||
# needs no speakers and touches nothing on the network.
|
||||
- name: Run tests
|
||||
run: .venv-ci/bin/python -m unittest discover -s tests -t . --verbose
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
rsync -azc --no-times --delete \
|
||||
--exclude "/.git/" \
|
||||
--exclude "/.gitea/" \
|
||||
--exclude "/.venv/" \
|
||||
--exclude "/.venv-ci/" \
|
||||
--exclude "/members.json" \
|
||||
--exclude "__pycache__/" \
|
||||
./ "$DEPLOY_PATH/"
|
||||
|
||||
# members.json is the stereo pair's learned membership and .venv is
|
||||
# the runtime -- both are excluded above, so --delete leaves them be.
|
||||
|
||||
- name: Install runtime dependencies
|
||||
run: |
|
||||
test -d "$DEPLOY_PATH/.venv" || python3 -m venv "$DEPLOY_PATH/.venv"
|
||||
"$DEPLOY_PATH/.venv/bin/pip" install --quiet -r "$DEPLOY_PATH/requirements.txt"
|
||||
|
||||
- name: Restart
|
||||
run: sudo systemctl restart "$SERVICE"
|
||||
|
||||
- name: Wait for the panel to answer
|
||||
run: |
|
||||
# The home page renders from config alone, so this proves the app
|
||||
# came back up without waiting on the speakers to reply.
|
||||
for attempt in $(seq 1 20); do
|
||||
if curl -fsS -o /dev/null "$PANEL_URL"; then
|
||||
echo "panel is up after ${attempt}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "panel did not come back -- last of its log:"
|
||||
sudo systemctl status "$SERVICE" --no-pager --lines 30 || true
|
||||
exit 1
|
||||
+10
@@ -136,3 +136,13 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
|
||||
# ---> Python / this project
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
.venv-ci/
|
||||
|
||||
# Learned HEOS group membership, written at runtime
|
||||
members.json
|
||||
|
||||
@@ -1,3 +1,253 @@
|
||||
# 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. A tap lands
|
||||
on the next multiple of `VOLUME_STEP` — from 23 it goes to 25, not 28 —
|
||||
so the levels stay round. Hold to keep moving.
|
||||
- **Group** either room with the AVR — the AVR is always the host, so its
|
||||
sound takes over whatever joins it. A room that joins moves *into* the
|
||||
Home Cinema card, so one glance says what is playing together
|
||||
- **Ungroup** either room again, or all of them at once
|
||||
- **Change the AVR's input**, listed under the names you gave them, minus
|
||||
the sources you deleted in the AVR's setup menu
|
||||
|
||||
## 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
|
||||
cd /var/www/html/heos
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
Then open `http://<pi-ip>:5005/`.
|
||||
|
||||
The virtual environment is not optional on a current Raspberry Pi OS:
|
||||
`pip install` straight into the system Python is refused there with
|
||||
`externally-managed-environment`. It also keeps Flask out of the way of
|
||||
anything else running on the Pi.
|
||||
|
||||
Every later `python3 ...` command here assumes the environment is active
|
||||
(`source .venv/bin/activate`); `deactivate` when you are done. The service
|
||||
below calls the environment's Python directly, so it does not care.
|
||||
|
||||
## 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.
|
||||
|
||||
`VOLUME_STEP` is the grid the volume buttons snap to, not simply how much
|
||||
they add: at 5, a tap moves 23 to 25 and 25 to 30.
|
||||
|
||||
The input picker already leaves out sources switched off in the AVR's own
|
||||
setup menu (it asks the AVR with `SSSOD ?`). `AVR_INPUT_CODES` narrows it
|
||||
further to the sources you actually use, and sets their order; leave it empty
|
||||
to list everything the AVR still has switched on.
|
||||
|
||||
## 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
|
||||
|
||||
`deploy/heos-panel.service` runs the panel out of its own virtualenv and
|
||||
restarts it if it dies:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/heos-panel.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now heos-panel
|
||||
```
|
||||
|
||||
Edit `User=` and the paths in it if you keep the panel somewhere else.
|
||||
|
||||
## Deploying from Gitea
|
||||
|
||||
`.gitea/workflows/deploy.yml` runs the tests on every push to `main`, then
|
||||
rsyncs the tree into place, installs anything new from `requirements.txt`,
|
||||
restarts the service and waits for the panel to answer again.
|
||||
|
||||
It needs a runner **in host mode on the machine that serves the panel**,
|
||||
registered with the label `heos` (`runs-on:` must match, or the job queues
|
||||
forever), running as the user that owns the directory. Restarting needs
|
||||
one sudoers line:
|
||||
|
||||
```bash
|
||||
echo 'franzz ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart heos-panel' \
|
||||
| sudo tee /etc/sudoers.d/heos-panel
|
||||
sudo chmod 440 /etc/sudoers.d/heos-panel
|
||||
```
|
||||
|
||||
The rsync excludes `.venv` and `members.json`, so the runtime and the
|
||||
learned stereo-pair membership survive a deploy. It does *not* exclude
|
||||
`config.py`: your device names live in git, so commit changes to them
|
||||
rather than editing the deployed copy. And since the deploy path is also
|
||||
where you edit, `--delete` lands on top of anything uncommitted sitting
|
||||
there.
|
||||
|
||||
## Behind a reverse proxy, at /heos
|
||||
|
||||
`deploy/heos.conf` reverse-proxies `/heos` to the panel with Apache:
|
||||
|
||||
```bash
|
||||
sudo a2enmod proxy proxy_http headers
|
||||
sudo cp deploy/heos.conf /etc/apache2/conf-available/heos.conf
|
||||
sudo a2enconf heos
|
||||
sudo apachectl configtest && sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
`deploy/heos.nginx.conf` is the same thing for nginx: copy it to
|
||||
`/etc/nginx/snippets/heos.conf`, `include snippets/heos.conf;` inside the
|
||||
`server` block, then `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
|
||||
Both ship restricted to the local network — this controls the speakers, and
|
||||
it usually hangs off a host with a public certificate. Delete the
|
||||
`RequireAny` block (Apache) or the `allow`/`deny` lines (nginx) to open it
|
||||
up.
|
||||
|
||||
The app works at either address without being told which. The proxy sends
|
||||
`X-Forwarded-Prefix: /heos`, and every URL the app generates — stylesheet,
|
||||
icons, the manifest's `start_url`, every `fetch` — picks up that prefix.
|
||||
Serve it straight from port 5005 and the same URLs come out as `/...`.
|
||||
That header is what the `headers` module is for; without it the page loads
|
||||
and nothing on it works.
|
||||
|
||||
Two things worth knowing:
|
||||
|
||||
- The proxy block takes `/heos` away from the filesystem, so the source
|
||||
under `/var/www/html/heos` stops being served as static files.
|
||||
- The panel still answers directly on `<pi-ip>:5005`. Start it with
|
||||
`--host 127.0.0.1` if you want Apache to be the only way in.
|
||||
|
||||
## 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", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute) |
|
||||
| `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"}` |
|
||||
|
||||
`POST /volume/up` and `/volume/down` take one snapped tap by default; pass
|
||||
`?step=3` and they move that many raw points instead, as they always did.
|
||||
|
||||
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 # re-render the icons from static/logo.svg
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/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
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
import config
|
||||
from avr import AvrError
|
||||
from controller import Controller, TargetError
|
||||
from heos import HeosError
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Served straight from port 5005 this changes nothing. Behind a reverse
|
||||
# proxy that mounts us on a sub-path (Apache at /heos, say) it reads the
|
||||
# X-Forwarded-Prefix that proxy sets, so every URL the app generates is
|
||||
# /heos/... instead of /..., and the page works either way.
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||||
|
||||
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("steps") is not None:
|
||||
# Taps, not points: each one lands on the next multiple of VOLUME_STEP.
|
||||
return jsonify({"target": key, "level": controller.step_volume(key, int(data["steps"]))})
|
||||
if data.get("delta") is not None:
|
||||
return jsonify({"target": key, "level": controller.nudge_volume(key, int(data["delta"]))})
|
||||
raise ValueError("Provide one of 'steps', '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():
|
||||
key = _target_from(request.args)
|
||||
step = request.args.get("step", type=int)
|
||||
# No ?step= means one tap of the panel's own button, snapping to the
|
||||
# next multiple; an explicit ?step= stays a raw number of points.
|
||||
level = controller.step_volume(key, 1) if step is None else controller.nudge_volume(key, step)
|
||||
return jsonify({"level": level})
|
||||
|
||||
|
||||
@app.post("/volume/down")
|
||||
@handle_errors
|
||||
def legacy_volume_down():
|
||||
key = _target_from(request.args)
|
||||
step = request.args.get("step", type=int)
|
||||
level = controller.step_volume(key, -1) if step is None else controller.nudge_volume(key, -step)
|
||||
return jsonify({"level": level})
|
||||
|
||||
|
||||
@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("--host", default="0.0.0.0",
|
||||
help="0.0.0.0 so your phone can reach it directly; "
|
||||
"127.0.0.1 to allow only a local reverse proxy")
|
||||
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)
|
||||
|
||||
app.run(host=args.host, port=args.port, threaded=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Denon AVR control over the classic Telnet protocol (TCP port 23).
|
||||
|
||||
Nothing to do with HEOS. Commands are short plain-text strings ending in
|
||||
a bare \\r: "SI?" asks which input is selected, "SIGAME" selects GAME,
|
||||
"SSFUN ?" lists the sources *under the names you gave them* -- which is
|
||||
the only reason we bother with this protocol at all, since HEOS only
|
||||
ever reports generic identifiers like "inputs/hdmi_in_1".
|
||||
|
||||
The AVR also pushes a line at us whenever anything changes, including
|
||||
changes made from the physical remote. So instead of polling, we hold
|
||||
the connection open, read continuously, and keep the last value of each
|
||||
status prefix. Asking for the current input is then free.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Status prefixes worth remembering from the AVR's chatter.
|
||||
_TRACKED = ("SI", "PW", "MV", "MU")
|
||||
|
||||
_LOG_LIMIT = 200
|
||||
|
||||
|
||||
class AvrError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DenonTelnet:
|
||||
"""Persistent listener + request/response on one Telnet connection."""
|
||||
|
||||
def __init__(self, host: str, port: int = 23, connect_timeout: float = 3.0):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.connect_timeout = connect_timeout
|
||||
self.status = {} # "SI" -> "MPLAY"
|
||||
self.connected = False
|
||||
self.last_error = None
|
||||
self._sock = None
|
||||
self._send_lock = threading.Lock()
|
||||
self._cv = threading.Condition()
|
||||
self._log = [] # [(seq, line)], newest last
|
||||
self._seq = 0
|
||||
threading.Thread(target=self._listen_forever, daemon=True).start()
|
||||
|
||||
# -- background reader ---------------------------------------------
|
||||
def _listen_forever(self):
|
||||
backoff = 1.0
|
||||
while True:
|
||||
try:
|
||||
self._open()
|
||||
backoff = 1.0
|
||||
self._read_forever()
|
||||
except OSError as exc:
|
||||
self._drop(exc)
|
||||
time.sleep(backoff)
|
||||
backoff = min(30.0, backoff * 2)
|
||||
|
||||
def _open(self):
|
||||
sock = socket.create_connection((self.host, self.port), timeout=self.connect_timeout)
|
||||
sock.settimeout(60.0)
|
||||
self._sock = sock
|
||||
self.connected = True
|
||||
self.last_error = None
|
||||
# Prime the status cache so the first page load knows the input.
|
||||
for probe in ("PW?", "SI?"):
|
||||
self.send(probe)
|
||||
|
||||
def _read_forever(self):
|
||||
buffer = b""
|
||||
while True:
|
||||
try:
|
||||
chunk = self._sock.recv(1024)
|
||||
except socket.timeout:
|
||||
continue # the AVR is simply quiet; nothing has changed
|
||||
if not chunk:
|
||||
raise ConnectionError("AVR closed the connection")
|
||||
buffer += chunk
|
||||
while b"\r" in buffer:
|
||||
raw, buffer = buffer.split(b"\r", 1)
|
||||
self._ingest(raw.decode("utf-8", "replace").strip())
|
||||
|
||||
def _drop(self, exc):
|
||||
self.connected = False
|
||||
self.last_error = str(exc)
|
||||
if self._sock is not None:
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._sock = None
|
||||
|
||||
def _ingest(self, line: str):
|
||||
if not line:
|
||||
return
|
||||
with self._cv:
|
||||
self._seq += 1
|
||||
self._log.append((self._seq, line))
|
||||
del self._log[:-_LOG_LIMIT]
|
||||
for prefix in _TRACKED:
|
||||
# SSFUN* also starts with 'SS', never with a tracked prefix,
|
||||
# so a plain startswith is safe here.
|
||||
if line.startswith(prefix) and len(line) > len(prefix):
|
||||
self.status[prefix] = line[len(prefix):]
|
||||
break
|
||||
self._cv.notify_all()
|
||||
|
||||
# -- sending -------------------------------------------------------
|
||||
def send(self, command: str):
|
||||
sock = self._sock
|
||||
if sock is None:
|
||||
raise AvrError(f"AVR at {self.host} is not connected ({self.last_error or 'no connection'})")
|
||||
with self._send_lock:
|
||||
sock.sendall(command.encode("utf-8") + b"\r")
|
||||
time.sleep(0.05) # the AVR wants a beat between commands
|
||||
|
||||
def request(self, command: str, prefix: str = None, until=None, timeout: float = 2.5) -> list:
|
||||
"""Send a command and collect the reply lines it triggers.
|
||||
|
||||
Returns as soon as a matching line arrives (or, with `until`, as
|
||||
soon as that terminator line does), so a query costs milliseconds
|
||||
rather than a fixed timeout.
|
||||
"""
|
||||
with self._cv:
|
||||
cursor = self._seq
|
||||
self.send(command)
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
with self._cv:
|
||||
while True:
|
||||
lines = [
|
||||
line for seq, line in self._log
|
||||
if seq > cursor and (prefix is None or line.startswith(prefix))
|
||||
]
|
||||
if lines and (until is None or any(until(line) for line in lines)):
|
||||
return lines
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return lines
|
||||
self._cv.wait(remaining)
|
||||
|
||||
def recent_lines(self) -> list:
|
||||
with self._cv:
|
||||
return [line for _, line in self._log]
|
||||
|
||||
|
||||
def parse_ssfun(lines: list) -> list:
|
||||
"""Parse `SSFUN ?` output -- 'SSFUNBD Blu-ray ' and friends --
|
||||
into [{"code": "BD", "name": "Blu-ray"}, ...]."""
|
||||
sources = []
|
||||
for line in lines:
|
||||
if not line.startswith("SSFUN"):
|
||||
continue
|
||||
rest = line[len("SSFUN"):]
|
||||
if rest.strip() in ("END", ""):
|
||||
continue
|
||||
parts = rest.split(" ", 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
code, name = parts[0].strip(), parts[1].strip()
|
||||
if code and name:
|
||||
sources.append({"code": code, "name": name})
|
||||
return sources
|
||||
|
||||
|
||||
def parse_sssod(lines: list) -> dict:
|
||||
"""Parse `SSSOD ?` output -- 'SSSODTUNER DEL' and friends -- into
|
||||
{"TUNER": False, "CD": True, ...}, i.e. which sources you have left
|
||||
switched on in the AVR's own setup menu."""
|
||||
usage = {}
|
||||
for line in lines:
|
||||
if not line.startswith("SSSOD"):
|
||||
continue
|
||||
rest = line[len("SSSOD"):].strip()
|
||||
if rest in ("END", ""):
|
||||
continue
|
||||
code, _, value = rest.rpartition(" ")
|
||||
code = code.strip()
|
||||
if code:
|
||||
usage[code] = value.strip().upper() != "DEL"
|
||||
return usage
|
||||
|
||||
|
||||
class AvrControl:
|
||||
"""The input list and the current input, in the names you chose."""
|
||||
|
||||
def __init__(self, host: str, port: int = 23, allowed_codes=()):
|
||||
self.telnet = DenonTelnet(host, port)
|
||||
self.allowed_codes = list(allowed_codes or [])
|
||||
self._inputs = None
|
||||
self._usage = None
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self.telnet.connected
|
||||
|
||||
def all_inputs(self, refresh: bool = False) -> list:
|
||||
"""Every source the AVR knows, under your names, deleted ones
|
||||
included. Cached: it only changes when you edit the setup menu."""
|
||||
if self._inputs is None or refresh:
|
||||
lines = self.telnet.request(
|
||||
"SSFUN ?", prefix="SSFUN",
|
||||
until=lambda line: line.strip() == "SSFUN END",
|
||||
timeout=3.0,
|
||||
)
|
||||
sources = parse_ssfun(lines)
|
||||
if sources:
|
||||
self._inputs = sources
|
||||
if self._usage is None or refresh:
|
||||
lines = self.telnet.request(
|
||||
"SSSOD ?", prefix="SSSOD",
|
||||
until=lambda line: line.strip() == "SSSOD END",
|
||||
timeout=3.0,
|
||||
)
|
||||
self._usage = parse_sssod(lines)
|
||||
return self._inputs or []
|
||||
|
||||
def inputs(self, refresh: bool = False) -> list:
|
||||
"""What the picker offers: the sources you can actually select.
|
||||
|
||||
Sources you deleted in the AVR's setup menu are left out -- they
|
||||
are exactly the ones you never want to land on. Anything SSSOD
|
||||
does not mention is kept, so a model that does not answer that
|
||||
command shows its whole list rather than nothing at all.
|
||||
"""
|
||||
sources = [s for s in self.all_inputs(refresh) if self._usage.get(s["code"], True)]
|
||||
if self.allowed_codes:
|
||||
order = {code: i for i, code in enumerate(self.allowed_codes)}
|
||||
sources = sorted(
|
||||
(s for s in sources if s["code"] in order),
|
||||
key=lambda s: order[s["code"]],
|
||||
)
|
||||
return sources
|
||||
|
||||
def current_input(self) -> dict:
|
||||
"""{"code": "MPLAY", "name": "Apple TV"} -- the name comes from the
|
||||
cached source list, the code from the AVR's own push messages."""
|
||||
code = self.telnet.status.get("SI")
|
||||
if code is None:
|
||||
lines = self.telnet.request("SI?", prefix="SI")
|
||||
code = lines[0][2:] if lines else None
|
||||
if code is None:
|
||||
return None
|
||||
return {"code": code, "name": self.name_for(code)}
|
||||
|
||||
def name_for(self, code: str) -> str:
|
||||
for source in self.all_inputs():
|
||||
if source["code"] == code:
|
||||
return source["name"]
|
||||
return code
|
||||
|
||||
def select_input(self, code: str) -> dict:
|
||||
self.telnet.request(f"SI{code}", prefix="SI", timeout=1.5)
|
||||
self.telnet.status["SI"] = code # trust our own command immediately
|
||||
return {"code": code, "name": self.name_for(code)}
|
||||
@@ -0,0 +1,69 @@
|
||||
"""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 --------------------------------------------------------
|
||||
# The grid the volume buttons snap to. A tap moves to the next multiple of
|
||||
# this rather than adding it, so at 5 a level of 23 goes to 25, not 28.
|
||||
VOLUME_STEP = 5
|
||||
|
||||
# Sources you deleted in the AVR's setup menu are hidden from the picker
|
||||
# automatically. This narrows it further to the ones you actually use, by
|
||||
# their SI code (see GET /api/avr/inputs), and sets their order in the
|
||||
# list. Empty = every source the AVR still has switched on.
|
||||
AVR_INPUT_CODES = []
|
||||
|
||||
# Shown as the app's name on the iOS home screen.
|
||||
APP_NAME = "HEOS"
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
"""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")
|
||||
|
||||
|
||||
def stepped_level(current: int, steps: int, size: int) -> int:
|
||||
"""Where the volume lands after `steps` taps of +/-.
|
||||
|
||||
A tap moves to the next multiple of `size` rather than adding `size`,
|
||||
so a level of 23 with a step of 5 goes 23 -> 25 -> 30 on the way up
|
||||
and 23 -> 20 -> 15 on the way down. Levels that are already on a
|
||||
multiple simply move a whole step.
|
||||
"""
|
||||
if steps > 0:
|
||||
landing = (current // size + steps) * size
|
||||
elif steps < 0:
|
||||
aligned = current // size * size
|
||||
first = current - size if aligned == current else aligned
|
||||
landing = first - (-steps - 1) * size
|
||||
else:
|
||||
return current
|
||||
return max(0, min(100, landing))
|
||||
|
||||
|
||||
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 step_volume(self, key: str, steps: int) -> int:
|
||||
"""Move by whole taps, landing on multiples of VOLUME_STEP.
|
||||
|
||||
Counted in taps rather than points so the speakers' own level is
|
||||
what gets rounded, even when the phone's copy of it is a few
|
||||
seconds stale.
|
||||
"""
|
||||
with self._lock:
|
||||
self._fresh()
|
||||
handles = self._volume_handles(key)
|
||||
level = stepped_level(
|
||||
self._read_volume(*handles[0]), int(steps), self.cfg.VOLUME_STEP
|
||||
)
|
||||
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
|
||||
@@ -0,0 +1,124 @@
|
||||
"""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
|
||||
|
||||
from controller import stepped_level
|
||||
|
||||
|
||||
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 step_volume(self, key, steps):
|
||||
return self.set_volume(key, stepped_level(self._volume[key], int(steps), self.cfg.VOLUME_STEP))
|
||||
|
||||
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
|
||||
@@ -0,0 +1,31 @@
|
||||
# The HEOS panel as a service.
|
||||
#
|
||||
# sudo cp /var/www/html/heos/deploy/heos-panel.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now heos-panel
|
||||
#
|
||||
# So the deploy workflow can restart it without a password prompt:
|
||||
#
|
||||
# echo 'franzz ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart heos-panel' \
|
||||
# | sudo tee /etc/sudoers.d/heos-panel
|
||||
# sudo chmod 440 /etc/sudoers.d/heos-panel
|
||||
#
|
||||
# (that user is whoever the Gitea runner runs as -- see .gitea/workflows/deploy.yml)
|
||||
|
||||
[Unit]
|
||||
Description=HEOS panel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=franzz
|
||||
Group=www-data
|
||||
WorkingDirectory=/var/www/html/heos
|
||||
ExecStart=/var/www/html/heos/.venv/bin/python /var/www/html/heos/app.py
|
||||
# Add --host 127.0.0.1 above to allow only the reverse proxy in.
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,31 @@
|
||||
# HEOS panel behind Apache, at /heos
|
||||
#
|
||||
# sudo a2enmod proxy proxy_http headers
|
||||
# sudo cp /var/www/html/heos/deploy/heos.conf /etc/apache2/conf-available/heos.conf
|
||||
# sudo a2enconf heos
|
||||
# sudo apachectl configtest && sudo systemctl reload apache2
|
||||
#
|
||||
# Apache reaches the panel on port 5005 (WEB_PORT in config.py). If you
|
||||
# want it reachable ONLY through Apache, start it with --host 127.0.0.1;
|
||||
# by default it also answers directly on the LAN at <pi-ip>:5005.
|
||||
# This block also takes /heos away from the filesystem, so the source in
|
||||
# /var/www/html/heos stops being reachable as static files.
|
||||
|
||||
<Location /heos>
|
||||
# The panel controls the speakers, and it hangs off a vhost with a
|
||||
# public certificate. Keep it to the house unless you mean otherwise:
|
||||
# delete this RequireAny block to let it answer from anywhere.
|
||||
<RequireAny>
|
||||
Require ip 192.168.0.0/24
|
||||
Require ip 127.0.0.1
|
||||
Require ip ::1
|
||||
</RequireAny>
|
||||
|
||||
# Tells the app it is mounted on a sub-path, so every link, icon and
|
||||
# fetch it generates is /heos/... rather than /... Without this the
|
||||
# page loads and nothing on it works.
|
||||
RequestHeader set X-Forwarded-Prefix /heos
|
||||
|
||||
ProxyPass http://127.0.0.1:5005
|
||||
ProxyPassReverse http://127.0.0.1:5005
|
||||
</Location>
|
||||
@@ -0,0 +1,57 @@
|
||||
# HEOS panel behind nginx, at /heos
|
||||
#
|
||||
# sudo cp /var/www/html/heos/deploy/heos.nginx.conf /etc/nginx/snippets/heos.conf
|
||||
# then inside the server { } block that serves the site:
|
||||
# include snippets/heos.conf;
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# nginx reaches the panel on port 5005 (WEB_PORT in config.py). If you want
|
||||
# it reachable ONLY through nginx, start it with --host 127.0.0.1; by
|
||||
# default it also answers directly on the LAN at <pi-ip>:5005.
|
||||
|
||||
# A bare /heos would miss the location below and fall through to the
|
||||
# filesystem, so send it to the slashed form first.
|
||||
location = /heos {
|
||||
return 301 /heos/;
|
||||
}
|
||||
|
||||
location /heos/ {
|
||||
# The panel controls the speakers, and it usually hangs off a host
|
||||
# with a public certificate. Keep it to the house unless you mean
|
||||
# otherwise: drop these four lines to let it answer from anywhere.
|
||||
allow 192.168.0.0/24;
|
||||
allow 127.0.0.1;
|
||||
allow ::1;
|
||||
deny all;
|
||||
|
||||
# The trailing slash on proxy_pass is what strips /heos/ back off
|
||||
# before the request reaches the app.
|
||||
proxy_pass http://127.0.0.1:5005/;
|
||||
|
||||
# Tells the app it is mounted on a sub-path, so every link, icon and
|
||||
# fetch it generates is /heos/... rather than /... Without this the
|
||||
# page loads and nothing on it works.
|
||||
proxy_set_header X-Forwarded-Prefix /heos;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
# Giving it a host of its own instead? Then it is not on a sub-path, and
|
||||
# the X-Forwarded-Prefix line above is the one thing to leave out:
|
||||
#
|
||||
# server {
|
||||
# server_name heos.example.com;
|
||||
# location / {
|
||||
# proxy_pass http://127.0.0.1:5005;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
# }
|
||||
@@ -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 "")
|
||||
@@ -0,0 +1 @@
|
||||
flask>=3.0
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
/* 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 HOST_LABEL = document.documentElement.dataset.host || 'the AVR';
|
||||
// Where the app is mounted: "/" on its own port, "/heos/" behind a proxy.
|
||||
const BASE = document.documentElement.dataset.base || '/';
|
||||
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"]'),
|
||||
joined: el('[data-role="joined"]'),
|
||||
joinedRooms: el('[data-role="joined-rooms"]'),
|
||||
};
|
||||
|
||||
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(BASE + path.replace(/^\//, ''), 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,
|
||||
slot: el(`.room-slot[data-slot="${key}"]`),
|
||||
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,
|
||||
taps: 0, // button presses 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));
|
||||
// Where the card sits already says whether it is grouped, so the button
|
||||
// says what tapping it does instead.
|
||||
room.toggleLabel.textContent = room.grouped ? 'Leave' : `Join ${HOST_LABEL}`;
|
||||
placeRoom(room);
|
||||
}
|
||||
|
||||
/* A merged room moves into the host's card, because that is what merging
|
||||
means: one group, playing one thing. Leaving puts the card back in its
|
||||
own slot, which is why the slots exist. */
|
||||
function placeRoom(room) {
|
||||
const target = room.grouped ? ui.joinedRooms : room.slot;
|
||||
if (room.node.parentElement !== target) target.appendChild(room.node);
|
||||
}
|
||||
|
||||
function paintGrouping() {
|
||||
const order = Object.keys(rooms);
|
||||
const inside = Array.from(ui.joinedRooms.children);
|
||||
// Keep them in the order the cards are declared, not the order they joined.
|
||||
inside
|
||||
.slice()
|
||||
.sort((a, b) => order.indexOf(a.dataset.room) - order.indexOf(b.dataset.room))
|
||||
.forEach((node) => ui.joinedRooms.appendChild(node));
|
||||
ui.joined.hidden = inside.length === 0;
|
||||
ui.splitAll.hidden = inside.length === 0;
|
||||
}
|
||||
|
||||
/* 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));
|
||||
}
|
||||
|
||||
/* A tap lands on the next multiple of STEP rather than adding STEP, so a
|
||||
level of 23 goes to 25 on the way up and 20 on the way down. Mirrors
|
||||
stepped_level() in controller.py -- the panel guesses with it, the
|
||||
speakers are the ones that decide. */
|
||||
function nextLevel(current, direction) {
|
||||
if (direction > 0) return Math.min(100, (Math.floor(current / STEP) + 1) * STEP);
|
||||
const aligned = Math.floor(current / STEP) * STEP;
|
||||
return Math.max(0, aligned === current ? current - STEP : aligned);
|
||||
}
|
||||
|
||||
function nudge(key, direction) {
|
||||
const room = rooms[key];
|
||||
if (!room.available) return;
|
||||
room.volume = nextLevel(room.volume ?? 0, direction);
|
||||
room.taps += direction;
|
||||
paintRoom(room);
|
||||
if (room.taps === 0) refresh(); // taps cancelled out; take the real level
|
||||
else 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.taps) return;
|
||||
const steps = room.taps;
|
||||
room.taps = 0;
|
||||
room.inflight = true;
|
||||
try {
|
||||
const data = await api('/api/volume', { target: key, steps });
|
||||
if (!room.taps) {
|
||||
room.volume = data.level;
|
||||
paintRoom(room);
|
||||
}
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
refresh();
|
||||
} finally {
|
||||
room.inflight = false;
|
||||
if (room.taps) 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);
|
||||
paintGrouping();
|
||||
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);
|
||||
});
|
||||
paintGrouping();
|
||||
}
|
||||
|
||||
ui.splitAll.addEventListener('click', async () => {
|
||||
try {
|
||||
applyJoined([]);
|
||||
const data = await api('/api/group/none', {}); // {} so it is a POST, as the route requires
|
||||
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.taps && !room.inflight) room.volume = incoming.volume;
|
||||
paintRoom(room);
|
||||
});
|
||||
paintGrouping();
|
||||
|
||||
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.taps || 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);
|
||||
paintGrouping();
|
||||
refresh();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox=".33 -.05 775.27 799.83" fill="#fff">
|
||||
<title>HEOS</title>
|
||||
<path d="m653.17 350.24v102.24c22.53 0 40.76-18.33 40.76-40.89v-20.45c0-22.57-18.23-40.9-40.76-40.9zm81.6-61.28v224.8c22.52 0 40.83-18.27 40.83-40.9v-143c0-22.63-18.31-40.9-40.83-40.9zm-652.84 102.18v20.45c0 22.56 18.23 40.89 40.83 40.89v-102.24c-22.6 0-40.83 18.33-40.83 40.9zm-81.6-61.28v143c0 22.63 18.24 40.9 40.76 40.9v-224.8c-22.52 0-40.76 18.27-40.76 40.9z"/>
|
||||
<path d="m163.52 248.06v306.53c0 45.13 36.55 81.73 81.6 81.73v-469.99c-45.05 0-81.6 36.61-81.6 81.73z"/>
|
||||
<path d="m530.74 166.33v469.99c45.05 0 81.6-36.6 81.6-81.73v-306.53c0-45.12-36.55-81.73-81.6-81.73z"/>
|
||||
<path d="m428.73 431.97h-81.6c-22.52 0-40.83 18.33-40.83 40.89v245.26c0 45.12 36.54 81.66 81.66 81.66 45.06 0 81.6-36.54 81.6-81.66v-245.26c0-22.56-18.31-40.89-40.83-40.89z"/>
|
||||
<path d="m428.73 367.76h-81.6c-22.52 0-40.83-18.34-40.83-40.9v-245.25c0-45.13 36.54-81.66 81.66-81.66 45.06 0 81.6 36.53 81.6 81.66v245.25c0 22.56-18.31 40.9-40.83 40.9z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,221 @@
|
||||
/* 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; }
|
||||
|
||||
/* Must beat every display rule below it. `hidden` is a plain UA style, so
|
||||
any author rule -- .sheet-wrap's display:flex, say -- silently wins, and
|
||||
an overlay that never hides sits over the whole panel eating taps. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
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; line-height: 0; }
|
||||
.top .logo { height: 34px; width: auto; display: block; }
|
||||
|
||||
.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); }
|
||||
|
||||
/* --- rooms merged into the host's card --------------------------------- */
|
||||
.joined { display: flex; flex-direction: column; gap: 12px; }
|
||||
.joined-title { margin: 0 2px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
||||
.joined-rooms { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
/* The card stops being a card in here: one border around the group, not
|
||||
one around every room in it. */
|
||||
.joined .card.room { background: none; border: 0; border-radius: 0; padding: 0; gap: 12px; }
|
||||
.joined .card.room + .card.room { border-top: 1px solid var(--edge); padding-top: 14px; }
|
||||
.joined .card-head h2 { font-size: 15px; font-weight: 550; color: var(--muted); }
|
||||
.joined .level b { font-size: 22px; }
|
||||
.joined .step { height: 54px; }
|
||||
.joined .toggle,
|
||||
.joined .toggle[aria-pressed="true"] {
|
||||
height: 40px; font-size: 13px; font-weight: 500;
|
||||
background: none; border: 1px solid var(--edge); color: var(--muted);
|
||||
}
|
||||
.joined .toggle .dot { display: none; }
|
||||
.joined .toggle:active { background: var(--raised); color: var(--ink); }
|
||||
|
||||
/* --- 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; }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-step="{{ step }}" data-host="{{ host.label }}" data-base="{{ url_for('index') }}">
|
||||
<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><img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ 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>
|
||||
|
||||
<div class="joined" data-role="joined" hidden>
|
||||
<p class="joined-title">Playing together</p>
|
||||
<div class="joined-rooms" data-role="joined-rooms"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% for room in rooms %}
|
||||
<div class="room-slot" data-slot="{{ room.key }}">
|
||||
<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">−</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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<button class="wide ghost" data-role="split-all" hidden>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>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "{{ app_name }}",
|
||||
"short_name": "{{ app_name }}",
|
||||
"start_url": "{{ url_for('index') }}",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0d14",
|
||||
"theme_color": "#0a0d14",
|
||||
"icons": [
|
||||
{ "src": "{{ url_for('static', filename='icon-180.png') }}", "sizes": "180x180", "type": "image/png" },
|
||||
{ "src": "{{ url_for('static', filename='icon-512.png') }}", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
"""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"), ("DVD", "Old DVD")]
|
||||
DELETED = {"DVD"} # switched off in the AVR's setup menu
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(daemon=True)
|
||||
self.input = "MPLAY"
|
||||
self.server = socket.socket()
|
||||
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.server.bind(("127.0.0.1", 0))
|
||||
self.server.listen(4)
|
||||
self.port = self.server.getsockname()[1]
|
||||
self.start()
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
try:
|
||||
conn, _ = self.server.accept()
|
||||
except OSError:
|
||||
return
|
||||
threading.Thread(target=self._serve, args=(conn,), daemon=True).start()
|
||||
|
||||
def _serve(self, conn):
|
||||
buffer = b""
|
||||
with conn:
|
||||
while True:
|
||||
try:
|
||||
chunk = conn.recv(1024)
|
||||
except OSError:
|
||||
return
|
||||
if not chunk:
|
||||
return
|
||||
buffer += chunk
|
||||
while b"\r" in buffer:
|
||||
line, buffer = buffer.split(b"\r", 1)
|
||||
for reply in self.handle(line.decode().strip()):
|
||||
conn.sendall(reply.encode() + b"\r")
|
||||
|
||||
def handle(self, command: str) -> list:
|
||||
if command == "SSFUN ?":
|
||||
# The real AVR pads the names out with spaces.
|
||||
return [f"SSFUN{code} {name} " for code, name in self.SOURCES] + ["SSFUN END"]
|
||||
if command == "SSSOD ?":
|
||||
return [f"SSSOD{code} {'DEL' if code in self.DELETED else 'USE'}"
|
||||
for code, _ in self.SOURCES] + ["SSSOD END"]
|
||||
if command == "SI?":
|
||||
return [f"SI{self.input}"]
|
||||
if command.startswith("SI"):
|
||||
self.input = command[2:]
|
||||
return [f"SI{self.input}"]
|
||||
if command == "PW?":
|
||||
return ["PWON"]
|
||||
return []
|
||||
@@ -0,0 +1,213 @@
|
||||
"""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, stepped_level # 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=[],
|
||||
VOLUME_STEP=5,
|
||||
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)
|
||||
|
||||
# -- volume in whole steps -------------------------------------------
|
||||
def test_a_tap_lands_on_the_next_multiple(self):
|
||||
self.panel.set_volume("home400", 23)
|
||||
self.assertEqual(self.panel.step_volume("home400", 1), 25)
|
||||
self.panel.set_volume("home400", 23)
|
||||
self.assertEqual(self.panel.step_volume("home400", -1), 20)
|
||||
|
||||
def test_a_level_already_on_a_multiple_moves_a_whole_step(self):
|
||||
self.panel.set_volume("home400", 25)
|
||||
self.assertEqual(self.panel.step_volume("home400", 1), 30)
|
||||
self.panel.set_volume("home400", 25)
|
||||
self.assertEqual(self.panel.step_volume("home400", -1), 20)
|
||||
|
||||
def test_a_burst_of_taps_snaps_once_then_moves_whole_steps(self):
|
||||
self.panel.set_volume("living_room_group", 23)
|
||||
self.assertEqual(self.panel.step_volume("living_room_group", 3), 35)
|
||||
|
||||
# -- 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"})
|
||||
|
||||
# "Old DVD" is deleted in the AVR's setup menu, so the picker skips
|
||||
# it -- but it keeps its name, in case the AVR is sitting on it.
|
||||
self.assertNotIn("DVD", [s["code"] for s in self.panel.avr.inputs()])
|
||||
self.assertIn({"code": "DVD", "name": "Old DVD"}, self.panel.avr.all_inputs())
|
||||
self.assertEqual(self.panel.avr.name_for("DVD"), "Old DVD")
|
||||
|
||||
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"]))
|
||||
|
||||
|
||||
class StepArithmeticTest(unittest.TestCase):
|
||||
"""The rule on its own -- no speakers involved."""
|
||||
|
||||
def test_up_from_between_multiples(self):
|
||||
self.assertEqual([stepped_level(23, n, 5) for n in (1, 2, 3)], [25, 30, 35])
|
||||
|
||||
def test_down_from_between_multiples(self):
|
||||
self.assertEqual([stepped_level(23, -n, 5) for n in (1, 2, 3)], [20, 15, 10])
|
||||
|
||||
def test_from_a_multiple(self):
|
||||
self.assertEqual(stepped_level(25, 1, 5), 30)
|
||||
self.assertEqual(stepped_level(25, -1, 5), 20)
|
||||
|
||||
def test_clamped_to_the_ends(self):
|
||||
self.assertEqual(stepped_level(98, 1, 5), 100)
|
||||
self.assertEqual(stepped_level(2, -1, 5), 0)
|
||||
self.assertEqual(stepped_level(0, -1, 5), 0)
|
||||
|
||||
def test_no_taps_changes_nothing(self):
|
||||
self.assertEqual(stepped_level(23, 0, 5), 23)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render static/logo.svg into the home-screen icons.
|
||||
|
||||
python3 tools/make_icons.py
|
||||
|
||||
Writes static/icon-180.png (what iOS uses on the home screen) and
|
||||
static/icon-512.png (Android / the web manifest): the white mark on a
|
||||
transparent background, which iOS lays over black on the home screen.
|
||||
|
||||
The PNGs are committed, so this only needs running if the logo changes.
|
||||
It rasterises with headless Chromium, which is a heavy thing to install
|
||||
for one job -- if you have librsvg to hand, this does the same:
|
||||
|
||||
rsvg-convert -w 512 -h 512 static/logo.svg -o static/icon-512.png
|
||||
|
||||
Otherwise: pip install playwright && playwright install chromium
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
STATIC = Path(__file__).resolve().parent.parent / "static"
|
||||
LOGO = STATIC / "logo.svg"
|
||||
SIZES = (180, 512)
|
||||
MARGIN = 0.14 # breathing room, so iOS's rounded mask never clips it
|
||||
|
||||
PAGE = """<!doctype html>
|
||||
<style>
|
||||
html, body {{ margin: 0; background: transparent; }}
|
||||
body {{ width: {size}px; height: {size}px; display: grid; place-items: center; }}
|
||||
img {{ width: {inner}px; height: {inner}px; object-fit: contain; }}
|
||||
</style>
|
||||
<img src="logo.svg">
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
sys.exit("needs playwright: pip install playwright && playwright install chromium")
|
||||
|
||||
if not LOGO.exists():
|
||||
sys.exit(f"no logo at {LOGO}")
|
||||
|
||||
scratch = STATIC / "_icon.html"
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=["--no-sandbox"])
|
||||
for size in SIZES:
|
||||
inner = round(size * (1 - 2 * MARGIN))
|
||||
scratch.write_text(PAGE.format(size=size, inner=inner))
|
||||
page = browser.new_page(viewport={"width": size, "height": size})
|
||||
page.goto(scratch.as_uri())
|
||||
page.wait_for_timeout(120) # let the SVG paint
|
||||
target = STATIC / f"icon-{size}.png"
|
||||
page.screenshot(path=target, omit_background=True)
|
||||
page.close()
|
||||
print(f"wrote {target} ({target.stat().st_size} bytes)")
|
||||
browser.close()
|
||||
finally:
|
||||
scratch.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user