17 KiB
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 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
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.
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.
- Create an app at the
Spotify Developer dashboard
(any name), and add this Redirect URI in its settings:
http://127.0.0.1:8899/callback. Spotify allows plainhttpfor a127.0.0.1redirect specifically, which is why the login below needs no HTTPS setup. - 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.
- 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):
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
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 claritaSPOTIFY_CLIENT_IDandSPOTIFY_CLIENT_SECRET(the same both times) plus that account's ownSPOTIFY_FIFOU_REFRESH_TOKENorSPOTIFY_CLARITA_REFRESH_TOKEN. - Put those four lines in a
.envfile in this directory (already git-ignored, and excluded from the deploy rsync the same waymembers.jsonis — see "What survives a deploy" below).config.pyreads it directly, so no shell-sourcing step or restart-in-the-right- terminal gotcha — justpython3 app.pyas normal. A real exported environment variable (or systemd'sEnvironmentFile, 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:
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:
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
sudoresolves fromPATH. It compares that string against the sudoers line without following symlinks, so on a system where/binlinks to/usr/binthe 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:
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:
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:
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
/heosaway from the filesystem, so the source under/var/www/html/heosstops being served as static files. - The panel still answers directly on
<pi-ip>:5443. Start it with--host 127.0.0.1if 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_groupwith 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:
"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 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
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