Files
heos/README.md
T
franzz a7a8ab6f4e
Deploy HEOS panel / deploy (push) Failing after 0s
Simplify deployment
2026-09-14 23:48:51 +02:00

279 lines
11 KiB
Markdown
Raw 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 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
```bash
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.
```python
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:
```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.
## Deploying from Gitea
`.gitea/workflows/deploy.yml` fast-forwards the checkout on the server,
installs anything new from `requirements.txt`, runs the tests, restarts the
service and waits for the panel to answer again.
### Once, by hand on the server
The workflow only ever *updates* a checkout. It never creates one, so the
remote and whatever credentials reach it are set up a single time and stay
put. As the user the runner runs as:
```bash
git clone <this repo> /var/www/html/heos
cd /var/www/html/heos
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
sudo cp deploy/heos-panel.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now heos-panel
echo "$(id -un) ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart heos-panel" \
| sudo tee /etc/sudoers.d/heos-panel
sudo chmod 440 /etc/sudoers.d/heos-panel
```
Edit `User=` and the paths in the unit if the panel lives somewhere else or
runs as someone else. Until that clone exists the workflow stops on its
first real step and prints these commands back at you.
### 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 the
user that owns the checkout — it writes there, and it fetches with that
checkout's own git credentials.
Every step is plain shell. `actions/checkout` is a JavaScript action, and a
host-mode runner can only run those with `node` on its PATH; without one it
fails with `Cannot find: node in PATH`.
Nothing untracked is disturbed, so `.venv` and `members.json` — the runtime
and the stereo pair's learned membership — survive a deploy untouched. The
fast-forward is `--ff-only`, so a tracked file edited on the box without
being committed stops the deploy rather than being silently overwritten.
`config.py` is tracked: 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:
```bash
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`:
```python
"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
```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
```
## 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
```