The word HEOS at the top of the panel becomes the mark itself, and the home-screen icons are rendered from the same file so there is one place to change it. All white on transparent: the panel is dark, and iOS lays a transparent apple-touch-icon over black. make_icons.py no longer draws its own glyph -- it rasterises logo.svg with headless Chromium, which is heavier than the old arithmetic but the only way to render real paths without a system SVG library. The PNGs are committed, so it only has to run when the logo changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
7.4 KiB
Markdown
204 lines
7.4 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
|
||
- **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
|
||
|
||
```ini
|
||
# /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
|
||
```
|
||
|
||
```bash
|
||
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_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
|
||
```
|