Files
franzz ec684b7803
Deploy HEOS panel / deploy (push) Successful in 26s
Make progress bar cursor draggable
2026-09-16 23:19:08 +02:00

158 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](#configure-spotify-optional) 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.
```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.
Make sure the CI/CD's \<user\> can restart the service:
```bash
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:
```bash
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:
```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
```
#### Nginx
`deploy/heos.nginx.conf` reverse-proxies `/heos` to the panel with Nginx:
```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
```
## 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](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 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](#configure-spotify-optional) configured) |
| `POST /api/spotify/resume` | `{"target": "lego_room", "account": "account1"}` — transfers that account's current playback there and resumes it |
## Demo/Tests
```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
```