franzz 4f858be918
Deploy HEOS panel / deploy (push) Successful in 25s
Fix icon background for iOS bookmarks
2026-09-17 15:07:30 +02:00
2026-09-16 13:43:31 +02:00
2026-09-15 22:07:43 +02:00
2026-09-17 15:00:23 +02:00
2026-09-16 23:19:08 +02:00
2026-09-15 22:07:43 +02:00
2026-09-16 13:43:31 +02:00
2026-09-16 13:43:31 +02:00
2026-09-16 23:19:08 +02:00
2026-09-16 18:06:14 +02:00
2026-09-16 18:06:14 +02:00
2026-09-16 23:19:08 +02:00
2026-09-16 15:03:45 +02:00
2026-09-14 20:52:23 +02:00
2026-09-16 23:19:08 +02:00
2026-09-15 17:20:24 +02:00
2026-09-16 23:19:08 +02:00
2026-09-16 23:19:08 +02:00

Heos app

The default HEOS app is so bad I had to make one myself.

A phone-sized web remote for a multi-room 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 fits on one screen:

  • Volume up/down for each room. 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, or drag the level along its bar to set it outright.
  • Navigate either room (play / pause / prev / next), while it is playing Spotify
  • 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 either account (the reverse of connecting to it from the Spotify app) — labeled with the familiar names you gave them, e.g. Fifou's or Clarita's. 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 player (pid) -- HEOS pairs them at the hardware level
Lego Room Denon Home 400 a player (pid)
Home Cinema Denon AVR-X3800H a player

See config.json.example.

Install

CI/CD

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.

Make sure the CI/CD's <user> can restart the service:

echo "<user> 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 the Gitea CI/CD.

Manually

Clone repo to /var/www/html/heos and:

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python3 app.py

Add a reverse proxy (optional)

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.

Apache

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

Nginx

deploy/heos.nginx.conf reverse-proxies /heos to the panel with Nginx:

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

Configure

Copy config.json.example to config.json and fill in the exact name HEOS reports for each device. The names come from whatever you typed in the HEOS app, so they rarely match the model names.

web_port is this app listening port.

Open http://<host-ip>:<web_port>/api/targets.

host_key is the group host into which the room will be grouped into (the AVR), and room_keys sets which rooms can be grouped (also set the card order on the app).

heos_host only needs to point at one device: HEOS is distributed, so any unit can see and control the whole network. heos_port, zidoo_host and zidoo_port all work the same way.

volume_step is the grid the volume buttons snap to: If set to 5, a tap moves 23 to 25 and 25 to 30.

Restart app flask/gunicorn.

Configure Spotify (Optional)

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

    python3 tools/spotify_auth.py --client-id <id> --client-secret <secret> --account 1
    python3 tools/spotify_auth.py --client-id <id> --client-secret <secret> --account 2
    

    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_ACCOUNT1_REFRESH_TOKEN or SPOTIFY_ACCOUNT2_REFRESH_TOKEN.

  4. Put those four lines in a .env file in this directory

  5. Restart app flask/gunicorn

A room's previous, play/pause and next buttons only appear while HEOS reports it is playing (or paused on) Spotify, and one of your SPOTIFY_ACCOUNTS is the one playing it — the same match that borders its button. 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. The song, artist and cover under each room's name come from that same reply (song, artist, image_url), whichever of them HEOS fills in. They stay while paused and go once the room stops. An AVR input shows none, since its "song" is just the input's name.

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.

HTTP API

Used by the interface:

HTTP Call Description
GET /api/state everything the UI draws, in one call
GET /api/targets every player and group HEOS can see
POST /api/volume {"target": "lego_room", "steps": 1} — taps, snapped to VOLUME_STEP. Also takes delta (raw points) or level (absolute)
POST /api/mute {"target": "lego_room"}
POST /api/playback {"target": "lego_room", "state": "pause"}, or no state to toggle
POST /api/skip {"target": "lego_room", "direction": "next"}previous too
POST /api/seek {"target": "lego_room", "position_ms": 90000} — the Zidoo's film for the AVR, a room's Spotify stream otherwise (HEOS itself cannot seek)
POST /api/group {"target": "lego_room", "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=account1 every Spotify Connect receiver that account currently sees (needs Spotify configured)
POST /api/spotify/resume {"target": "lego_room", "account": "account1"} — transfers that account's current playback there and resumes it

Demo/Tests

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
S
Description
Fix Denon Heos interface
Readme GPL-3.0
712 KiB
Languages
Python 67.6%
JavaScript 17.8%
CSS 9.1%
HTML 5.5%