franzz ce8596945e
Deploy HEOS panel / deploy (push) Failing after 44s
Fix service restarter test
2026-09-15 00:08:00 +02:00
2026-09-14 23:48:51 +02:00
2026-09-14 20:52:23 +02:00
2026-09-15 00:08:00 +02:00

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.
  • 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

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.

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:

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 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 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:

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 and members.json, so the runtime and the stereo pair's learned membership 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.conf reverse-proxies /heos to the panel with Apache:

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:

"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

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
S
Description
Fix Denon Heos interface
Readme GPL-3.0
700 KiB
Languages
Python 66.2%
JavaScript 18.5%
CSS 9.5%
HTML 5.8%