Merge the bridge and a home-screen interface into one app
The HEOS app is unpleasant to use for the four things that actually get
done in this house, so this is those four things on one screen: volume
for the Home 400 and the Living Room pair, joining either to the AVR,
splitting them off again, and picking the AVR's input.
The bridge's logic moves in mostly intact, split into a HEOS client, an
AVR client and a controller, with two grouping bugs fixed on the way:
* Merging a room into the AVR sent only the pair's leader pid, which
left the second Home 200 behind. Group targets now expand to every
member pid, and unmerging re-forms the pair rather than leaving two
lone speakers. The members are learned while the pair is visible and
remembered in members.json so a restart can still rebuild it.
* Volume for the pair used its gid, which stops existing the moment it
joins the AVR's group. It now falls back to the member players.
Both sockets are kept open rather than reconnecting per command, which
is what made every button press feel slow; the AVR connection doubles as
a listener, so input changes made with the physical remote show up too.
The original query-string endpoints still answer, so anything already
pointed at the bridge keeps working.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,180 @@
|
||||
# heos
|
||||
# HEOS panel
|
||||
|
||||
Fix Denon Heos interface
|
||||
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 (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
|
||||
|
||||
## 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
|
||||
pip3 install -r requirements.txt
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
Then open `http://<pi-ip>:5005/`.
|
||||
|
||||
## 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.
|
||||
|
||||
`AVR_INPUT_CODES` narrows the input picker to the sources you actually use
|
||||
(and sets their order); leave it empty to list everything the AVR reports.
|
||||
|
||||
## 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=/usr/bin/python3 /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", "delta": 2}` or `{"level": 25}` |
|
||||
| `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"}` |
|
||||
|
||||
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 # redraw the home-screen icon
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user