A tap now moves to the next multiple of VOLUME_STEP rather than adding it, so 23 goes to 25 and 25 goes to 30 and the levels stay round. The panel counts taps and lets the speakers do the rounding from whatever level they are actually at, since the phone's copy can be seconds old; the same rule is mirrored in JS so the optimistic number never has to correct itself when the reply lands. The input picker also asks the AVR which sources are still switched on (SSSOD ?) and leaves out the ones deleted in its setup menu. Sources it does not mention are kept, so a model that ignores the command shows its whole list rather than nothing; deleted sources also keep their names, in case the AVR is sitting on one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
- 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
# /etc/systemd/system/heos-panel.service
[Unit]
Description=HEOS panel
After=network-online.target
[Service]
ExecStart=/home/pi/heos/.venv/bin/python /home/pi/heos/app.py
WorkingDirectory=/home/pi/heos
Restart=always
User=pi
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now heos-panel
How the grouping actually works
Worth knowing, because HEOS makes two things easy to get wrong.
set_group replaces a group wholesale. There is no "add this player".
Joining a second room therefore re-sends every member of the group, and the
host's pid has to come first — that is what makes the AVR the leader whose
content everyone plays.
Your Home 200 pair is a group, not a speaker. Merging it into the AVR
means sending both speakers' pids; sending only the leader would leave the
second Home 200 playing on its own. And once merged, the pair's own gid
stops existing, so:
- unmerging re-issues
set_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.
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 # redraw the home-screen icon
Layout
app.py Flask: the UI, the API, and the old bridge's routes
controller.py what a room is, what grouping means, volume
heos.py HEOS CLI client (persistent socket, reconnects itself)
avr.py Denon Telnet client + the renamed input list
config.py your devices and preferences
demo.py fake speakers for --demo
templates/ static/ the interface
tests/ fake HEOS + AVR servers, and tests against them