379 lines
17 KiB
Markdown
379 lines
17 KiB
Markdown
# HEOS panel
|
||
|
||
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.
|
||
- **Play or pause** either room, or skip a track, while it is playing
|
||
Spotify — the buttons only show up then, since an AVR input has nothing
|
||
to pause or skip. A room that is grouped shares the AVR's
|
||
transport, so pausing it pauses the group — HEOS's doing, not the panel's:
|
||
a group has one thing playing, by definition
|
||
- **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
|
||
- **Resume Spotify** on a room from Fifou's or Clarita's account, one
|
||
button each — the reverse of connecting to it from the Spotify app.
|
||
Optional; see [Spotify](#spotify) below
|
||
|
||
## 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** |
|
||
|
||
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>:5443/`.
|
||
|
||
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>:5443/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 — including the AVR's own inputs,
|
||
so there is nothing AVR-specific to configure beyond its entry in `TARGETS`.
|
||
|
||
`VOLUME_STEP` is the grid the volume buttons snap to, not simply how much
|
||
they add: at 5, a tap moves 23 to 25 and 25 to 30.
|
||
|
||
`AVR_INPUT_CODES` narrows the input picker to the sources you actually use,
|
||
by their HEOS input id (`GET /api/avr/inputs` shows the exact strings, e.g.
|
||
`inputs/aux_in_1`), and sets their order; leave it empty to list everything
|
||
HEOS reports for it.
|
||
|
||
## Spotify
|
||
|
||
Spotify dropped native, browsable HEOS integration years ago — it is
|
||
Connect-only on HEOS now, and Connect only works phone → speaker: the
|
||
Spotify app pushes playback to a room, and there is nothing in HEOS that
|
||
asks for the reverse. So "Resume Spotify" doesn't go through HEOS at all;
|
||
it calls Spotify's own Web API to transfer the account's current playback
|
||
onto the room's Spotify Connect receiver, which the receiver already
|
||
advertises on the LAN whether or not anything is playing. This needs
|
||
Spotify Premium and a one-time login, since Spotify has no way to grant
|
||
that without a human approving it once.
|
||
|
||
1. Create an app at the
|
||
[Spotify Developer dashboard](https://developer.spotify.com/dashboard)
|
||
(any name), and add this Redirect URI in its settings:
|
||
`http://127.0.0.1:8899/callback`. Spotify allows plain `http` for a
|
||
`127.0.0.1` redirect specifically, which is why the login below needs no
|
||
HTTPS setup.
|
||
2. While the app is in development mode, Spotify only lets accounts you
|
||
have listed log in to it: add both accounts' email addresses under the
|
||
app's **User Management**.
|
||
3. Run the one-time login once per account, from a machine with a browser
|
||
(your laptop is fine — it doesn't have to be the Pi):
|
||
```bash
|
||
python3 tools/spotify_auth.py --client-id <id> --client-secret <secret> --account fifou
|
||
python3 tools/spotify_auth.py --client-id <id> --client-secret <secret> --account clarita
|
||
```
|
||
Log in as that account each time — use a private window for the second
|
||
run, or Spotify just approves whichever account the browser is already
|
||
logged in as. Each run prints `SPOTIFY_CLIENT_ID` and
|
||
`SPOTIFY_CLIENT_SECRET` (the same both times) plus that account's own
|
||
`SPOTIFY_FIFOU_REFRESH_TOKEN` or `SPOTIFY_CLARITA_REFRESH_TOKEN`.
|
||
4. Put those four lines in a `.env` file in this directory (already
|
||
git-ignored, and excluded from the deploy rsync the same way
|
||
`members.json` is — see "What survives a deploy" below). `config.py`
|
||
reads it directly, so no shell-sourcing step or restart-in-the-right-
|
||
terminal gotcha — just `python3 app.py` as normal. A real exported
|
||
environment variable (or systemd's `EnvironmentFile`, in the service)
|
||
still overrides the file rather than the other way round.
|
||
|
||
`config.py` never sees the credentials themselves — it only reads them out
|
||
of the environment. Each speaker's card then gets one Spotify button per
|
||
account in `SPOTIFY_ACCOUNTS` that has a refresh token; an account without
|
||
one simply gets no button. The AVR's card has none. Whichever account is
|
||
playing on a room right now gets a border around its button — the panel
|
||
asks Spotify, but only while HEOS says a room is on Spotify. Each room resumes on the Spotify Connect device named
|
||
`spotify_name` in its `TARGETS` entry, falling back to `heos_name` if that
|
||
key is missing (they usually match). `GET /api/spotify/devices?account=fifou`
|
||
lists what Spotify actually calls each device, if a room's button ever says
|
||
one isn't visible.
|
||
|
||
A room's play/pause and next buttons only appear while HEOS reports it is
|
||
playing (or paused on) Spotify. To see what HEOS reports for a room, use
|
||
`GET /raw/player/get_now_playing_media?pid=<pid>` with a pid from
|
||
`/api/targets`: Spotify shows up as `"sid": 4`.
|
||
|
||
## 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, under
|
||
`gunicorn` rather than `python3 app.py`'s dev server, and restarts it if it
|
||
dies:
|
||
|
||
```bash
|
||
sudo cp deploy/heos-panel.service /etc/systemd/system/
|
||
sudo systemctl daemon-reload
|
||
sudo systemctl enable --now heos-panel
|
||
```
|
||
|
||
Edit `User=` and the paths in it if you keep the panel somewhere else.
|
||
|
||
`python3 app.py` (no gunicorn) is still the right way to run it by hand
|
||
while working on it — see `--demo` below — the dev-server warning it prints
|
||
is expected there and only matters for the service above.
|
||
|
||
## Deploying from Gitea
|
||
|
||
`.gitea/workflows/deploy.yml` checks out the push, runs the tests, rsyncs
|
||
the tree into place, installs anything new from `requirements.txt`,
|
||
restarts the service and waits for the panel to answer again. The tests run
|
||
before the rsync, so a failure leaves the server exactly as it was.
|
||
|
||
### The runner
|
||
|
||
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 a user
|
||
that can write to the deploy path. It also needs **node 20 or newer** on its
|
||
PATH: `actions/checkout` is a JavaScript action, and a host-mode runner has
|
||
nothing else to run one with — without it the job fails immediately with
|
||
`Cannot find: node in PATH`.
|
||
|
||
### Once, on the server
|
||
|
||
The deploy path does not need to be a checkout — an empty directory the
|
||
runner can write to is enough:
|
||
|
||
```bash
|
||
sudo mkdir -p /var/www/html/heos
|
||
sudo chown "$(id -un)": /var/www/html/heos
|
||
|
||
echo "$(id -un) ALL=(ALL) NOPASSWD: $(command -v systemctl) restart heos-panel" \
|
||
| sudo tee /etc/sudoers.d/heos-panel
|
||
sudo chmod 440 /etc/sudoers.d/heos-panel
|
||
```
|
||
|
||
Run those **as the user the runner runs as**, not as yourself — the first
|
||
step of the workflow prints who that is. Two things in that sudoers line are
|
||
easy to get wrong, and the workflow checks both before it deploys anything,
|
||
printing the line back at you with the right values filled in:
|
||
|
||
- the user has to be the runner's, and
|
||
- the path has to be the one `sudo` resolves from `PATH`. It compares that
|
||
string against the sudoers line without following symlinks, so on a system
|
||
where `/bin` links to `/usr/bin` the two spellings are not interchangeable.
|
||
|
||
Then push. That first run deploys the files and builds the virtualenv, and
|
||
stops at the restart, because the service does not exist yet. Install it
|
||
from the copy it just put there:
|
||
|
||
```bash
|
||
sudo cp /var/www/html/heos/deploy/heos-panel.service /etc/systemd/system/
|
||
sudo systemctl daemon-reload
|
||
sudo systemctl enable heos-panel
|
||
```
|
||
|
||
Edit `User=` and the paths in the unit first if the panel lives somewhere
|
||
else or runs as someone else. Re-run the workflow and it goes green.
|
||
|
||
### What survives a deploy
|
||
|
||
The rsync excludes `.venv`, `members.json` and `.env`, so the runtime, the
|
||
stereo pair's learned membership, and Spotify's credentials are left alone by
|
||
`--delete`. Everything else in the deploy path is made to match the repo,
|
||
`config.py` included: your device names live in git, so change them there and
|
||
push rather than editing the deployed copy.
|
||
|
||
## Behind a reverse proxy, at /heos
|
||
|
||
`deploy/heos.apache.conf` reverse-proxies `/heos` to the panel with Apache:
|
||
|
||
```bash
|
||
sudo a2enmod proxy proxy_http headers
|
||
sudo cp deploy/heos.apache.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 — a whole `server`
|
||
block for `rpioffice.nest.domain.com`, ready for `sites-available`:
|
||
|
||
```bash
|
||
sudo cp deploy/heos.nginx.conf /etc/nginx/sites-available/heos
|
||
sudo ln -s /etc/nginx/sites-available/heos /etc/nginx/sites-enabled/heos
|
||
sudo nginx -t && sudo systemctl reload nginx
|
||
sudo certbot --nginx -d rpioffice.nest.domain.com # optional, adds the 443 block
|
||
```
|
||
|
||
It serves the panel at `/heos` and refuses everything else on that host;
|
||
the file ends with the two-line change that puts it at the root instead.
|
||
|
||
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 5443 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>:5443`. 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.
|
||
|
||
**A newly joined room can stay silent on the AVR's input.** Joining alone
|
||
does not push audio to it — HEOS needs telling *again* which input is
|
||
playing before it streams that input to the new member, the same reselect
|
||
you'd otherwise do by hand in the HEOS app (Home → Sources → AV). `join()`
|
||
does this for you: it reads the AVR's current input back and replays it
|
||
through `browse/play_input` right after the group merge.
|
||
|
||
## One protocol, not two
|
||
|
||
Everything goes over the HEOS CLI (port 1255) — players, groups, volume, and
|
||
the AVR's own inputs. `browse/browse` on the AVR's pid lists its inputs under
|
||
whatever names you gave them in its setup menu; HEOS reports those renamed
|
||
labels itself, so there used to be a second client here for the AVR's Denon
|
||
Telnet port just to fetch them, and it is not needed any more.
|
||
|
||
Selecting an input goes through `browse/play_input`, not a raw `SI<code>`
|
||
Telnet command, for the same reason joining a room re-sends it (see above):
|
||
that is what actually tells HEOS to *stream* the input to whichever players
|
||
are grouped with the AVR, not just which jack the AVR itself is listening to.
|
||
|
||
## HTTP API
|
||
|
||
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/playback` | `{"target": "home400", "state": "pause"}`, or no `state` to toggle |
|
||
| `POST /api/skip` | `{"target": "home400", "direction": "next"}` — `previous` too |
|
||
| `POST /api/group` | `{"target": "home400", "joined": true}` |
|
||
| `POST /api/group/none` | every room back on its own |
|
||
| `GET /api/avr/inputs` | your renamed sources, over HEOS |
|
||
| `POST /api/avr/input` | `{"code": "inputs/aux_in_1"}` |
|
||
| `GET /api/spotify/devices?account=fifou` | every Spotify Connect receiver that account currently sees (needs [Spotify](#spotify) configured) |
|
||
| `POST /api/spotify/resume` | `{"target": "home400", "account": "fifou"}` — transfers that account's current playback there and resumes it |
|
||
|
||
`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/{input,inputs}`, `/raw/<command>`.
|
||
|
||
Worth keeping for troubleshooting:
|
||
|
||
```
|
||
GET /raw/browse/browse?sid=1027 # any heos:// command, raw reply
|
||
```
|
||
|
||
## 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, the AVR's inputs
|
||
heos.py HEOS CLI client (persistent socket, reconnects itself)
|
||
spotify.py Spotify Web API client, for the "Resume Spotify" button
|
||
config.py your devices and preferences
|
||
demo.py fake speakers for --demo
|
||
templates/ static/ the interface
|
||
tests/ fake HEOS and Spotify servers, and tests against them
|
||
tools/spotify_auth.py one-time Spotify login, prints the refresh token
|
||
```
|