279 lines
11 KiB
Markdown
279 lines
11 KiB
Markdown
# 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
|
||
```
|