Remove group management
Deploy HEOS panel / deploy (push) Successful in 25s

This commit is contained in:
2026-09-16 13:43:31 +02:00
parent 5b82a24ac4
commit 58a2dedc03
15 changed files with 358 additions and 588 deletions
+4
View File
@@ -0,0 +1,4 @@
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_ACCOUNT1_REFRESH_TOKEN=
SPOTIFY_ACCOUNT2_REFRESH_TOKEN=
+4 -4
View File
@@ -80,9 +80,9 @@ jobs:
- name: Run tests - name: Run tests
run: .venv-ci/bin/python -m unittest discover -s tests -t . --verbose run: .venv-ci/bin/python -m unittest discover -s tests -t . --verbose
# .venv, members.json and .env are excluded, so the runtime, the # .venv, .env and config.json are excluded, so the
# stereo pair's learned membership, and Spotify's credentials survive # runtime, the stereo pair's learned membership, Spotify's
# --delete untouched. # credentials, and your rooms/ports survive --delete untouched.
- name: Deploy to production - name: Deploy to production
run: | run: |
rsync -azc --no-times --delete \ rsync -azc --no-times --delete \
@@ -90,8 +90,8 @@ jobs:
--exclude "/.gitea/" \ --exclude "/.gitea/" \
--exclude "/.venv/" \ --exclude "/.venv/" \
--exclude "/.venv-ci/" \ --exclude "/.venv-ci/" \
--exclude "/members.json" \
--exclude "/.env" \ --exclude "/.env" \
--exclude "/config.json" \
--exclude "__pycache__/" \ --exclude "__pycache__/" \
./ "$DEPLOY_PATH/" ./ "$DEPLOY_PATH/"
+2 -2
View File
@@ -144,5 +144,5 @@ __pycache__/
venv/ venv/
.venv-ci/ .venv-ci/
# Learned HEOS group membership, written at runtime # Your own rooms/speakers/ports -- copy config.json.example to config.json
members.json config.json
+65 -293
View File
@@ -1,163 +1,33 @@
# HEOS panel # Heos app
A phone-sized web remote for a Denon HEOS system, meant to be added to the _The default HEOS app is so bad I had to make one myself._
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: 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.
- **Volume** up/down for the Home 400 and the Living Room pair. A tap lands Everything fits on one screen:
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 - **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.
its bar to set it outright. - **Navigate** either room (play / pause / prev / next), while it is playing Spotify
- **Play or pause** either room, or skip a track, while it is playing - **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
Spotify — the buttons only show up then, since an AVR input has nothing
to pause or skip. A room that is grouped shares the AVR's
transport, so pausing it pauses the group — HEOS's doing, not the panel's:
a group has one thing playing, by definition
- **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 - **Ungroup** either room again, or all of them at once
- **Change the AVR's input**, listed under the names you gave them, minus - **Change the AVR's input**, listed under the names you gave them, minus the sources you deleted in the AVR's setup menu
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
- **Resume Spotify** on a room from Fifou's or Clarita's account, one
button each — the reverse of connecting to it from the Spotify app.
Optional; see [Spotify](#spotify) below
## The kit it assumes ## The kit it assumes
| Room | Device | How HEOS addresses it | | Room | Device | How HEOS addresses it |
| --- | --- | --- | | --- | --- | --- |
| Living Room | 2× Denon Home 200 as an In-Room Group | a **group** (`gid`) | | 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`) | | Lego Room | Denon Home 400 | a **player** (`pid`) |
| Home Cinema | Denon AVR-X3800H | a **player** | | Home Cinema | Denon AVR-X3800H | a **player** |
Any other mix works — it is all in `config.py`. See `config.json.example`.
## Install ## Install
```bash ### CI/CD
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>:5443/`. `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.
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>:5443/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 — including the AVR's own inputs,
so there is nothing AVR-specific to configure beyond its entry in `TARGETS`.
`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.
`AVR_INPUT_CODES` narrows the input picker to the sources you actually use,
by their HEOS input id (`GET /api/avr/inputs` shows the exact strings, e.g.
`inputs/aux_in_1`), and sets their order; leave it empty to list everything
HEOS reports for it.
## Spotify
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 fifou
python3 tools/spotify_auth.py --client-id <id> --client-secret <secret> --account clarita
```
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_FIFOU_REFRESH_TOKEN` or `SPOTIFY_CLARITA_REFRESH_TOKEN`.
4. Put those four lines in a `.env` file in this directory (already
git-ignored, and excluded from the deploy rsync the same way
`members.json` is — see "What survives a deploy" below). `config.py`
reads it directly, so no shell-sourcing step or restart-in-the-right-
terminal gotcha — just `python3 app.py` as normal. A real exported
environment variable (or systemd's `EnvironmentFile`, in the service)
still overrides the file rather than the other way round.
`config.py` never sees the credentials themselves — it only reads them out
of the environment. Each speaker's card then gets one Spotify button per
account in `SPOTIFY_ACCOUNTS` that has a refresh token; an account without
one simply gets no button. The AVR's card has none. Whichever account is
playing on a room right now gets a border around its button — the panel
asks Spotify, but only while HEOS says a room is on Spotify. Each room resumes on the Spotify Connect device named
`spotify_name` in its `TARGETS` entry, falling back to `heos_name` if that
key is missing (they usually match). `GET /api/spotify/devices?account=fifou`
lists what Spotify actually calls each device, if a room's button ever says
one isn't visible.
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.
## Run it as a service
`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 ```bash
sudo cp deploy/heos-panel.service /etc/systemd/system/ sudo cp deploy/heos-panel.service /etc/systemd/system/
@@ -167,72 +37,34 @@ sudo systemctl enable --now heos-panel
Edit `User=` and the paths in it if you keep the panel somewhere else. Edit `User=` and the paths in it if you keep the panel somewhere else.
`python3 app.py` (no gunicorn) is still the right way to run it by hand Make sure the CI/CD's \<user\> can restart the service:
while working on it — see `--demo` below — the dev-server warning it prints
is expected there and only matters for the service above.
## Deploying from Gitea
`.gitea/workflows/deploy.yml` checks out the push, runs the tests, rsyncs
the tree into place, installs anything new from `requirements.txt`,
restarts the service and waits for the panel to answer again. The tests run
before the rsync, so a failure leaves the server exactly as it was.
### 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 a user
that can write to the deploy path. It also needs **node 20 or newer** on its
PATH: `actions/checkout` is a JavaScript action, and a host-mode runner has
nothing else to run one with — without it the job fails immediately with
`Cannot find: node in PATH`.
### Once, on the server
The deploy path does not need to be a checkout — an empty directory the
runner can write to is enough:
```bash ```bash
sudo mkdir -p /var/www/html/heos echo "<user> ALL=(ALL) NOPASSWD: $(command -v systemctl) restart heos-panel" \
sudo chown "$(id -un)": /var/www/html/heos
echo "$(id -un) ALL=(ALL) NOPASSWD: $(command -v systemctl) restart heos-panel" \
| sudo tee /etc/sudoers.d/heos-panel | sudo tee /etc/sudoers.d/heos-panel
sudo chmod 440 /etc/sudoers.d/heos-panel sudo chmod 440 /etc/sudoers.d/heos-panel
``` ```
Run those **as the user the runner runs as**, not as yourself — the first Run the Gitea CI/CD.
step of the workflow prints who that is. Two things in that sudoers line are
easy to get wrong, and the workflow checks both before it deploys anything,
printing the line back at you with the right values filled in:
- the user has to be the runner's, and ### Manually
- the path has to be the one `sudo` resolves from `PATH`. It compares that
string against the sudoers line without following symlinks, so on a system
where `/bin` links to `/usr/bin` the two spellings are not interchangeable.
Then push. That first run deploys the files and builds the virtualenv, and Clone repo to `/var/www/html/heos` and:
stops at the restart, because the service does not exist yet. Install it
from the copy it just put there:
```bash ```bash
sudo cp /var/www/html/heos/deploy/heos-panel.service /etc/systemd/system/ python3 -m venv .venv
sudo systemctl daemon-reload source .venv/bin/activate
sudo systemctl enable heos-panel pip install -r requirements.txt
python3 app.py
``` ```
Edit `User=` and the paths in the unit first if the panel lives somewhere ### Add a reverse proxy (optional)
else or runs as someone else. Re-run the workflow and it goes green.
### What survives a deploy 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.
The rsync excludes `.venv`, `members.json` and `.env`, so the runtime, the 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.
stereo pair's learned membership, and Spotify's credentials are left alone by
`--delete`. Everything else in the deploy path is made to match the repo,
`config.py` included: your device names live in git, so change them there and
push rather than editing the deployed copy.
## Behind a reverse proxy, at /heos #### Apache
`deploy/heos.apache.conf` reverse-proxies `/heos` to the panel with Apache: `deploy/heos.apache.conf` reverse-proxies `/heos` to the panel with Apache:
@@ -243,142 +75,82 @@ sudo a2enconf heos
sudo apachectl configtest && sudo systemctl reload apache2 sudo apachectl configtest && sudo systemctl reload apache2
``` ```
`deploy/heos.nginx.conf` is the same thing for nginx — a whole `server` #### Nginx
block for `rpioffice.nest.domain.com`, ready for `sites-available`:
`deploy/heos.nginx.conf` reverse-proxies `/heos` to the panel with Nginx:
```bash ```bash
sudo cp deploy/heos.nginx.conf /etc/nginx/sites-available/heos 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 ln -s /etc/nginx/sites-available/heos /etc/nginx/sites-enabled/heos
sudo nginx -t && sudo systemctl reload nginx sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d rpioffice.nest.domain.com # optional, adds the 443 block
``` ```
It serves the panel at `/heos` and refuses everything else on that host; ## Configure
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 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.
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 `web_port` is this app listening port.
`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 5443 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: Open `http://<host-ip>:<web_port>/api/targets`.
- The proxy block takes `/heos` away from the filesystem, so the source `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).
under `/var/www/html/heos` stops being served as static files.
- The panel still answers directly on `<pi-ip>:5443`. Start it with
`--host 127.0.0.1` if you want Apache to be the only way in.
## How the grouping actually works `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.
Worth knowing, because HEOS makes two things easy to get wrong. `volume_step` is the grid the volume buttons snap to: If set to `5`, a tap moves `23` to `25` and `25` to `30`.
**`set_group` replaces a group wholesale.** There is no "add this player". Restart app flask/gunicorn.
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 ## Configure Spotify (Optional)
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 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.
- 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 This needs Spotify Premium and a one-time login, since Spotify has no way to grant that without a human approving it once.
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 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.
"living_room_group": { 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**.
"label": "Living Room", 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):
"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 ```bash
room's music does not restart. 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
```
**A newly joined room can stay silent on the AVR's input.** Joining alone 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`.
does not push audio to it — HEOS needs telling *again* which input is 4. Put those four lines in a `.env` file in this directory
playing before it streams that input to the new member, the same reselect 5. Restart app flask/gunicorn
you'd otherwise do by hand in the HEOS app (Home → Sources → AV). `join()`
does this for you: it reads the AVR's current input back and replays it
through `browse/play_input` right after the group merge.
## One protocol, not two 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.
Everything goes over the HEOS CLI (port 1255) — players, groups, volume, and ## Add it to the iOS home screen
the AVR's own inputs. `browse/browse` on the AVR's pid lists its inputs under
whatever names you gave them in its setup menu; HEOS reports those renamed
labels itself, so there used to be a second client here for the AVR's Denon
Telnet port just to fetch them, and it is not needed any more.
Selecting an input goes through `browse/play_input`, not a raw `SI<code>` 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.
Telnet command, for the same reason joining a room re-sends it (see above):
that is what actually tells HEOS to *stream* the input to whichever players 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.
are grouped with the AVR, not just which jack the AVR itself is listening to.
## HTTP API ## HTTP API
Used by the interface: Used by the interface:
| | | | HTTP Call | Description |
| --- | --- | | --- | --- |
| `GET /api/state` | everything the UI draws, in one call | | `GET /api/state` | everything the UI draws, in one call |
| `GET /api/targets` | every player and group HEOS can see | | `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/volume` | `{"target": "lego_room", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute) |
| `POST /api/mute` | `{"target": "home400"}` | | `POST /api/mute` | `{"target": "lego_room"}` |
| `POST /api/playback` | `{"target": "home400", "state": "pause"}`, or no `state` to toggle | | `POST /api/playback` | `{"target": "lego_room", "state": "pause"}`, or no `state` to toggle |
| `POST /api/skip` | `{"target": "home400", "direction": "next"}` — `previous` too | | `POST /api/skip` | `{"target": "lego_room", "direction": "next"}` — `previous` too |
| `POST /api/group` | `{"target": "home400", "joined": true}` | | `POST /api/group` | `{"target": "lego_room", "joined": true}` |
| `POST /api/group/none` | every room back on its own | | `POST /api/group/none` | every room back on its own |
| `GET /api/avr/inputs` | your renamed sources, over HEOS | | `GET /api/avr/inputs` | your renamed sources, over HEOS |
| `POST /api/avr/input` | `{"code": "inputs/aux_in_1"}` | | `POST /api/avr/input` | `{"code": "inputs/aux_in_1"}` |
| `GET /api/spotify/devices?account=fifou` | every Spotify Connect receiver that account currently sees (needs [Spotify](#spotify) configured) | | `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": "home400", "account": "fifou"}` — transfers that account's current playback there and resumes it | | `POST /api/spotify/resume` | `{"target": "lego_room", "account": "account1"}` — transfers that account's current playback there and resumes it |
`POST /volume/up` and `/volume/down` take one snapped tap by default; pass ## Demo/Tests
`?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/{input,inputs}`, `/raw/<command>`.
Worth keeping for troubleshooting:
```
GET /raw/browse/browse?sid=1027 # any heos:// command, raw reply
```
## Working on it
```bash ```bash
python3 app.py --demo # fake speakers, real interface python3 app.py --demo # fake speakers, real interface
python3 -m unittest discover -s tests -t . # runs against a fake HEOS network 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 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, the AVR's inputs
heos.py HEOS CLI client (persistent socket, reconnects itself)
spotify.py Spotify Web API client, for the "Resume Spotify" button
config.py your devices and preferences
demo.py fake speakers for --demo
templates/ static/ the interface
tests/ fake HEOS and Spotify servers, and tests against them
tools/spotify_auth.py one-time Spotify login, prints the refresh token
```
+1 -1
View File
@@ -274,7 +274,7 @@ def api_avr_set_input():
@handle_errors @handle_errors
def api_spotify_devices(): def api_spotify_devices():
"""Diagnostic: every Spotify Connect receiver one account currently sees """Diagnostic: every Spotify Connect receiver one account currently sees
(?account=fifou) -- use this to fill in a target's spotify_name if it (?account=account1) -- use this to fill in a target's spotify_name if it
differs from heos_name.""" differs from heos_name."""
return jsonify(_spotify_from(request.args).devices()) return jsonify(_spotify_from(request.args).devices())
+29
View File
@@ -0,0 +1,29 @@
{
"web_port": 5443,
"heos_host": "REPLACE_WITH_HEOS_HOST",
"heos_port": 1255,
"zidoo_host": "REPLACE_WITH_ZIDOO_HOST",
"zidoo_port": 9529,
"targets": {
"avr": {
"label": "Home Cinema",
"heos_name": "REPLACE_WITH_EXACT_HEOS_NAME"
},
"lego_room": {
"label": "Lego Room",
"heos_name": "REPLACE_WITH_EXACT_HEOS_NAME"
},
"living_room": {
"label": "Living Room",
"heos_name": "REPLACE_WITH_EXACT_HEOS_NAME",
"spotify_name": "REPLACE_ONLY_IF_DIFFERENT_FROM_HEOS_NAME"
}
},
"host_key": "avr",
"room_keys": ["living_room", "lego_room"],
"volume_step": 5,
"spotify_accounts": {
"account1": "REPLACE_WITH_ACCOUNT_1_NAME",
"account2": "REPLACE_WITH_ACCOUNT_2_NAME"
}
}
+50 -48
View File
@@ -5,6 +5,7 @@ first time you run this, so the names below match exactly what HEOS
reports for your own devices. reports for your own devices.
""" """
import json
import os import os
from pathlib import Path from pathlib import Path
@@ -27,63 +28,62 @@ def _load_dotenv(path: Path):
_load_dotenv(Path(__file__).resolve().parent / ".env") _load_dotenv(Path(__file__).resolve().parent / ".env")
# --- Network ---------------------------------------------------------- # --- Rooms, ports & behaviour -------------------------------------------
# Any ONE HEOS device's IP is enough: HEOS is a distributed system, so # None of this is sensitive -- it doesn't give away anything about your
# whichever unit you connect to can see and control every player and # LAN or credentials -- but it's still yours, not the app's: buying a
# group on the network. # speaker, renaming a room, or changing a port is a config edit, not a
HEOS_HOST = "192.168.0.10" # code change. Lives in config.json rather than here or in .env; copy
HEOS_PORT = 1255 # config.json.example to get started.
# Port the panel itself listens on.
WEB_PORT = 5443
# --- Rooms ------------------------------------------------------------
# key -> how to find it on the network, and how to label it in the UI.
# #
# heos_name : the EXACT name HEOS reports for that player or group. # heos_name : the EXACT name HEOS reports for that player -- including
# players : only for a target that is a HEOS *group* (a stereo pair # an In-Room Group like a stereo pair, which HEOS pairs at
# or an In-Room Group). List its member players, leader # the hardware level into one player, one pid, always.
# first. Leave it out and the panel learns the members the
# first time it sees the group un-merged, then remembers
# them in members.json -- which is what lets it rebuild the
# pair after you unmerge it from the AVR.
# spotify_name : only needed if a room's Spotify Connect name differs # spotify_name : only needed if a room's Spotify Connect name differs
# from heos_name -- GET /api/spotify/devices?account=fifou shows what # from heos_name -- GET /api/spotify/devices?account=account1 shows what
# Spotify actually calls it. Defaults to heos_name. # Spotify actually calls it. Defaults to heos_name.
TARGETS = { _config_path = Path(__file__).resolve().parent / "config.json"
"avr": { try:
"label": "Home Cinema", _cfg = json.loads(_config_path.read_text())
"heos_name": "Home Cinema", # AVR-X3800H except FileNotFoundError:
}, raise SystemExit(
"home400": { f"{_config_path} not found -- copy config.json.example to config.json "
"label": "Lego Room", "and fill in your own rooms (see the README's Configure section)."
"heos_name": "Lego Room", # Denon Home 400 )
},
"living_room_group": { TARGETS = _cfg["targets"]
"label": "Living Room",
"heos_name": "Denon Home 200 L", # 2x Denon Home 200, In-Room Group
"spotify_name": "Living Room", # what Spotify Connect calls the pair
# "players": ["Denon Home 200 L", "Denon Home 200 R"],
},
}
# The AVR is always the group host: its content takes over every room # The AVR is always the group host: its content takes over every room
# that joins, which is the whole point of the merge buttons. # that joins, which is the whole point of the merge buttons.
HOST_KEY = "avr" HOST_KEY = _cfg["host_key"]
# The rooms that get a card with volume + a join/leave button, in order. # The rooms that get a card with volume + a join/leave button, in order.
ROOM_KEYS = ["home400", "living_room_group"] ROOM_KEYS = _cfg["room_keys"]
# --- Behaviour --------------------------------------------------------
# The grid the volume buttons snap to. A tap moves to the next multiple of # The grid the volume buttons snap to. A tap moves to the next multiple of
# this rather than adding it, so at 5 a level of 23 goes to 25, not 28. # this rather than adding it, so at 5 a level of 23 goes to 25, not 28.
VOLUME_STEP = 5 VOLUME_STEP = _cfg["volume_step"]
# Narrows the AVR's input picker to the sources you actually use, by their # Port the panel itself listens on.
# HEOS input id (see GET /api/avr/inputs for the exact strings, e.g. WEB_PORT = _cfg["web_port"]
# "inputs/aux_in_1"), and sets their order in the list. Empty = every
# source HEOS reports for it. # --- Network ----------------------------------------------------------
AVR_INPUT_CODES = [] # Any ONE HEOS device's IP is enough: HEOS is a distributed system, so
# whichever unit you connect to can see and control every player and
# group on the network. A LAN address isn't a credential, so it lives
# here rather than in .env -- just don't publish this file if your LAN
# is reachable from outside it.
HEOS_HOST = _cfg["heos_host"]
HEOS_PORT = _cfg["heos_port"]
# --- Zidoo (optional) ---------------------------------------------------
# A Zidoo media player plugged into one of the AVR's inputs. HEOS only
# knows that input is selected, not what the Zidoo is actually showing, so
# its "now playing" comes from the Zidoo's own HTTP API instead -- queried
# only while ZIDOO_INPUT_CODE is the AVR's selected input. Leave
# zidoo_host out of config.json if there is no Zidoo to ask.
ZIDOO_HOST = _cfg.get("zidoo_host")
ZIDOO_PORT = _cfg["zidoo_port"]
ZIDOO_INPUT_CODE = "inputs/mediaplayer" # see GET /api/avr/inputs
# Shown as the app's name on the iOS home screen. # Shown as the app's name on the iOS home screen.
APP_NAME = "Heos" APP_NAME = "Heos"
@@ -98,8 +98,10 @@ SPOTIFY_CLIENT_ID = os.environ.get("SPOTIFY_CLIENT_ID")
SPOTIFY_CLIENT_SECRET = os.environ.get("SPOTIFY_CLIENT_SECRET") SPOTIFY_CLIENT_SECRET = os.environ.get("SPOTIFY_CLIENT_SECRET")
# key -> button label and that account's refresh token, in button order. # key -> button label and that account's refresh token, in button order.
# An account without a token gets no button. # An account without a token gets no button. The label comes from
# spotify_accounts in config.json -- e.g. "account1": "Fifou".
_spotify_names = _cfg.get("spotify_accounts", {})
SPOTIFY_ACCOUNTS = { SPOTIFY_ACCOUNTS = {
"fifou": {"label": "Fifou", "refresh_token": os.environ.get("SPOTIFY_FIFOU_REFRESH_TOKEN")}, "account1": {"label": _spotify_names.get("account1", "Account 1"), "refresh_token": os.environ.get("SPOTIFY_ACCOUNT1_REFRESH_TOKEN")},
"clarita": {"label": "Clarita", "refresh_token": os.environ.get("SPOTIFY_CLARITA_REFRESH_TOKEN")}, "account2": {"label": _spotify_names.get("account2", "Account 2"), "refresh_token": os.environ.get("SPOTIFY_ACCOUNT2_REFRESH_TOKEN")},
} }
+31 -126
View File
@@ -1,15 +1,11 @@
"""The actual behaviour of the panel, on top of the HEOS CLI client. """The actual behaviour of the panel, on top of the HEOS CLI client.
The interesting part is grouping. A HEOS "In-Room Group" (your pair of The interesting part is grouping. Every room here -- including the
Home 200s) is addressed by a gid and behaves like one speaker -- until living room's L/R pair, which HEOS pairs at the hardware level into a
you merge it into the AVR's group, at which point that gid stops single pid -- is addressed by one player pid, merged or not. set_group
existing and its two players are just two members of the AVR's group. replaces a group wholesale rather than adding to it, so join() and
Two consequences drive most of the code below: leave() always have to name every room that should remain in the AVR's
group, not just the one being added or removed.
* Merging must send EVERY member pid of the pair, not just its leader,
or the second Home 200 gets left behind.
* Unmerging must re-issue set_group with the pair's own pids to put
the pair back together, so we have to remember what they were.
Everything, including the AVR's own inputs, goes over the one HEOS Everything, including the AVR's own inputs, goes over the one HEOS
connection now -- there used to be a second client here for the AVR's connection now -- there used to be a second client here for the AVR's
@@ -18,14 +14,11 @@ those same renamed names itself (browse/browse on the AVR's own pid),
so the Telnet side added a protocol for no remaining benefit. so the Telnet side added a protocol for no remaining benefit.
""" """
import json
import threading import threading
import time import time
from pathlib import Path
from heos import HeosClient, HeosError, parse_message from heos import HeosClient, HeosError, parse_message
from zidoo import ZidooClient
MEMBERS_FILE = Path(__file__).with_name("members.json")
def stepped_level(current: int, steps: int, size: int) -> int: def stepped_level(current: int, steps: int, size: int) -> int:
@@ -58,9 +51,9 @@ class Controller:
def __init__(self, cfg): def __init__(self, cfg):
self.cfg = cfg self.cfg = cfg
self.heos = HeosClient(cfg.HEOS_HOST, cfg.HEOS_PORT) self.heos = HeosClient(cfg.HEOS_HOST, cfg.HEOS_PORT)
zidoo_host = getattr(cfg, "ZIDOO_HOST", None)
self.zidoo = ZidooClient(zidoo_host, getattr(cfg, "ZIDOO_PORT", 9529)) if zidoo_host else None
self._lock = threading.RLock() self._lock = threading.RLock()
self._members_file = Path(getattr(cfg, "MEMBERS_FILE", MEMBERS_FILE))
self._learned = _load_learned(self._members_file)
self._players = [] self._players = []
self._groups = [] self._groups = []
self._scanned_at = 0.0 self._scanned_at = 0.0
@@ -78,13 +71,11 @@ class Controller:
# -- network picture ----------------------------------------------- # -- network picture -----------------------------------------------
def scan(self) -> dict: def scan(self) -> dict:
"""Re-read every player and group, and remember what the rooms """Re-read every player and group."""
are made of while we can see them."""
with self._lock: with self._lock:
self._players = self.heos.command("player/get_players").get("payload", []) self._players = self.heos.command("player/get_players").get("payload", [])
self._groups = self.heos.command("group/get_groups").get("payload", []) self._groups = self.heos.command("group/get_groups").get("payload", [])
self._scanned_at = time.monotonic() self._scanned_at = time.monotonic()
self._learn_members()
return {"players": self._players, "groups": self._groups} return {"players": self._players, "groups": self._groups}
def _fresh(self, max_age: float = 2.0): def _fresh(self, max_age: float = 2.0):
@@ -97,41 +88,6 @@ class Controller:
return player.get("pid") return player.get("pid")
return None return None
def _group_named(self, name: str):
for group in self._groups:
if group.get("name") == name:
return group
return None
@staticmethod
def _ordered_pids(group: dict) -> list:
"""Member pids with the leader first -- HEOS makes the first pid
in a set_group call the leader, so the order is not cosmetic."""
players = group.get("players", [])
leaders = [p for p in players if p.get("role") == "leader"]
others = [p for p in players if p.get("role") != "leader"]
return [p.get("pid") for p in leaders + others]
def _learn_members(self):
"""Record what each room's group is made of whenever we catch it
standing on its own, so we can rebuild it after a merge."""
host_pid = self._host_pid()
changed = False
for key in self.cfg.ROOM_KEYS:
if "players" in self.cfg.TARGETS[key]:
continue # configured by hand, nothing to learn
group = self._group_named(self.cfg.TARGETS[key]["heos_name"])
if not group:
continue
pids = self._ordered_pids(group)
if host_pid in pids:
continue # currently merged with the AVR: not its own shape
if self._learned.get(key) != pids:
self._learned[key] = pids
changed = True
if changed:
_save_learned(self._members_file, self._learned)
# -- resolving rooms to pids --------------------------------------- # -- resolving rooms to pids ---------------------------------------
def _host_pid(self): def _host_pid(self):
"""The AVR is always a plain player. Resolving it by player name """The AVR is always a plain player. Resolving it by player name
@@ -144,63 +100,25 @@ class Controller:
return pid return pid
def member_pids(self, key: str) -> list: def member_pids(self, key: str) -> list:
"""Every player that makes up a room, leader first.""" """The pid of the one player that is this room, as a list."""
if key not in self.cfg.TARGETS: if key not in self.cfg.TARGETS:
raise TargetError(f"Unknown room '{key}'") raise TargetError(f"Unknown room '{key}'")
if key == self.cfg.HOST_KEY: if key == self.cfg.HOST_KEY:
return [self._host_pid()] return [self._host_pid()]
target = self.cfg.TARGETS[key] name = self.cfg.TARGETS[key]["heos_name"]
name = target["heos_name"]
if "players" in target:
pids = []
for player_name in target["players"]:
pid = self._player_pid(player_name)
if pid is None:
raise TargetError(f"No HEOS player named '{player_name}' was found")
pids.append(pid)
return pids
# A group under this name wins over a player under the same name:
# a stereo pair is usually named after its left-hand speaker.
host_pid = self._host_pid()
group = self._group_named(name)
if group:
pids = self._ordered_pids(group)
if host_pid not in pids:
return pids
known = self._learned.get(key)
if known:
live = {p.get("pid") for p in self._players}
if all(pid in live for pid in known):
return known
pid = self._player_pid(name) pid = self._player_pid(name)
if pid is not None: if pid is None:
return [pid]
raise TargetError( raise TargetError(
f"No HEOS player or group named '{name}' was found. " f"No HEOS player named '{name}' was found. "
f"Check GET /api/targets for the names HEOS actually reports." f"Check GET /api/targets for the names HEOS actually reports."
) )
return [pid]
# -- volume --------------------------------------------------------- # -- volume ---------------------------------------------------------
def _volume_handles(self, key: str) -> list: def _volume_handles(self, key: str) -> list:
"""Where volume for this room lives right now, as (scope, id). """Where volume for this room lives right now, as (scope, id)."""
return [("player", pid) for pid in self.member_pids(key)]
A room that is its own HEOS group has a single group volume. Once
it is merged into the AVR's group that gid is gone, and the only
knobs left are the member players' own volumes.
"""
pids = self.member_pids(key)
if len(pids) > 1:
wanted = set(pids)
for group in self._groups:
if {p.get("pid") for p in group.get("players", [])} == wanted:
return [("group", group["gid"])]
return [("player", pid) for pid in pids]
@staticmethod @staticmethod
def _id_param(scope: str) -> str: def _id_param(scope: str) -> str:
@@ -302,8 +220,7 @@ class Controller:
return pids return pids
def ungroup(self, key: str) -> list: def ungroup(self, key: str) -> list:
"""Stand a room back up on its own. For the Home 200 pair this """Stand a room back up on its own."""
re-forms the pair rather than leaving two lone speakers behind."""
with self._lock: with self._lock:
self._fresh() self._fresh()
pids = self.member_pids(key) pids = self.member_pids(key)
@@ -466,17 +383,8 @@ class Controller:
def avr_inputs(self) -> list: def avr_inputs(self) -> list:
"""{"code", "name"} pairs for the picker -- heos_inputs()'s shape, """{"code", "name"} pairs for the picker -- heos_inputs()'s shape,
renamed to match what the UI and /api/avr/* already send and renamed to match what the UI and /api/avr/* already send and
expect. Narrowed and ordered by AVR_INPUT_CODES, same as before, expect."""
except the codes it matches are now HEOS's own ("inputs/aux_in_1"),
not the AVR's Telnet ones ("AUX1")."""
sources = self.heos_inputs(self.cfg.HOST_KEY) sources = self.heos_inputs(self.cfg.HOST_KEY)
codes = getattr(self.cfg, "AVR_INPUT_CODES", None)
if codes:
order = {code: i for i, code in enumerate(codes)}
sources = sorted(
(s for s in sources if s["input_id"] in order),
key=lambda s: order[s["input_id"]],
)
return [{"code": s["input_id"], "name": s["name"]} for s in sources] return [{"code": s["input_id"], "name": s["name"]} for s in sources]
def avr_current_input(self): def avr_current_input(self):
@@ -501,6 +409,15 @@ class Controller:
name = next((s["name"] for s in self.avr_inputs() if s["code"] == code), code) name = next((s["name"] for s in self.avr_inputs() if s["code"] == code), code)
return {"code": code, "name": name} return {"code": code, "name": name}
def zidoo_now_playing(self, current_input):
"""Whatever a Zidoo plugged into the AVR is showing, when it is the
AVR's selected input -- None otherwise, or if no Zidoo is
configured, or the Zidoo has nothing loaded."""
zidoo_code = getattr(self.cfg, "ZIDOO_INPUT_CODE", None)
if not (self.zidoo and zidoo_code and current_input and current_input["code"] == zidoo_code):
return None
return self.zidoo.now_playing()
# -- one snapshot for the UI ------------------------------------------ # -- one snapshot for the UI ------------------------------------------
def state(self) -> dict: def state(self) -> dict:
snapshot = { snapshot = {
@@ -552,26 +469,14 @@ class Controller:
] ]
try: try:
current_input = self.avr_current_input()
snapshot["avr"] = { snapshot["avr"] = {
"connected": self.avr_connected(), "connected": self.avr_connected(),
"inputs": self.avr_inputs(), "inputs": self.avr_inputs(),
"input": self.avr_current_input(), "input": current_input,
"now_playing": self.zidoo_now_playing(current_input),
} }
except (HeosError, TargetError) as exc: except (HeosError, TargetError) as exc:
snapshot["errors"].append(str(exc)) snapshot["errors"].append(str(exc))
return snapshot return snapshot
def _load_learned(path: Path) -> dict:
try:
return json.loads(path.read_text())
except (OSError, ValueError):
return {}
def _save_learned(path: Path, data: dict):
try:
path.write_text(json.dumps(data, indent=2))
except OSError:
pass # a read-only checkout just means we re-learn next time
+10 -9
View File
@@ -19,17 +19,12 @@ class _FakeAvr:
{"code": "PHONO", "name": "Turntable"}, {"code": "PHONO", "name": "Turntable"},
] ]
def __init__(self, allowed_codes=()): def __init__(self):
self.allowed_codes = list(allowed_codes or [])
self.connected = True self.connected = True
self._code = "MPLAY" self._code = "MPLAY"
def inputs(self, refresh=False): def inputs(self, refresh=False):
sources = list(self.INPUTS) return list(self.INPUTS)
if self.allowed_codes:
order = {code: i for i, code in enumerate(self.allowed_codes)}
sources = sorted((s for s in sources if s["code"] in order), key=lambda s: order[s["code"]])
return sources
def name_for(self, code): def name_for(self, code):
return next((s["name"] for s in self.INPUTS if s["code"] == code), code) return next((s["name"] for s in self.INPUTS if s["code"] == code), code)
@@ -46,7 +41,7 @@ class _FakeAvr:
class DemoController: class DemoController:
def __init__(self, cfg): def __init__(self, cfg):
self.cfg = cfg self.cfg = cfg
self.avr = _FakeAvr(cfg.AVR_INPUT_CODES) self.avr = _FakeAvr()
self.heos = None self.heos = None
self._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.TARGETS)} self._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.TARGETS)}
self._play = {key: "play" for key in cfg.TARGETS} self._play = {key: "play" for key in cfg.TARGETS}
@@ -71,7 +66,13 @@ class DemoController:
"error": None} "error": None}
for key in self.cfg.ROOM_KEYS for key in self.cfg.ROOM_KEYS
], ],
"avr": {"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input()}, "avr": {
"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input(),
# Standing in for a Zidoo plugged into this input, so the
# AVR card's now-playing block has something to show here too.
"now_playing": {"song": "Big Buck Bunny", "artist": None, "image": None}
if self.avr.current_input()["code"] == "MPLAY" else None,
},
"heos_ok": True, "heos_ok": True,
"errors": [], "errors": [],
"demo": True, "demo": True,
+33 -12
View File
@@ -23,8 +23,25 @@ const ui = {
options: el('[data-role="options"]'), options: el('[data-role="options"]'),
joined: el('[data-role="joined"]'), joined: el('[data-role="joined"]'),
joinedRooms: el('[data-role="joined-rooms"]'), joinedRooms: el('[data-role="joined-rooms"]'),
avrNowPlaying: el('[data-role="avr-now-playing"]'),
avrCover: el('[data-role="avr-cover"]'),
avrSong: el('[data-role="avr-song"]'),
avrArtist: el('[data-role="avr-artist"]'),
}; };
// Shares its shape with a room's {nowPlaying, cover, song, artist} refs, so
// renderTrack() below works for both -- a device plugged into the AVR (a
// Zidoo, say) is "now playing" the same way a room's own stream is.
const avrTrack = {
nowPlaying: ui.avrNowPlaying,
cover: ui.avrCover,
song: ui.avrSong,
artist: ui.avrArtist,
};
// Same reasoning as a room's own cover: drop one that will not load rather
// than leave a broken-image box.
avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; });
const rooms = {}; const rooms = {};
let inputs = []; let inputs = [];
let currentInput = null; let currentInput = null;
@@ -147,24 +164,27 @@ function paintRoom(room) {
placeRoom(room); placeRoom(room);
} }
/* Song, artist and cover, each only when HEOS has one. The cover's src is /* Song, artist and cover, each only when there is one. The cover's src is
only touched when the track changes, so a poll never makes it flicker. */ only touched when the track changes, so a poll never makes it flicker. */
function paintTrack(room) { function renderTrack(track, refs) {
const track = room.available ? room.track : null; refs.nowPlaying.hidden = !track;
room.nowPlaying.hidden = !track;
if (!track) return; if (!track) return;
room.song.textContent = track.song; refs.song.textContent = track.song;
room.artist.textContent = track.artist || ''; refs.artist.textContent = track.artist || '';
room.artist.hidden = !track.artist; refs.artist.hidden = !track.artist;
if (!track.image) { if (!track.image) {
room.cover.hidden = true; refs.cover.hidden = true;
room.cover.removeAttribute('src'); refs.cover.removeAttribute('src');
} else if (room.cover.getAttribute('src') !== track.image) { } else if (refs.cover.getAttribute('src') !== track.image) {
room.cover.hidden = false; refs.cover.hidden = false;
room.cover.src = track.image; refs.cover.src = track.image;
} }
} }
function paintTrack(room) {
renderTrack(room.available ? room.track : null, room);
}
/* A merged room moves into the host's card, because that is what merging /* A merged room moves into the host's card, because that is what merging
means: one group, playing one thing. Leaving puts the card back in its means: one group, playing one thing. Leaving puts the card back in its
own slot, which is why the slots exist. */ own slot, which is why the slots exist. */
@@ -467,6 +487,7 @@ function render(state) {
ui.inputName.textContent = currentInput ? currentInput.name : '—'; ui.inputName.textContent = currentInput ? currentInput.name : '—';
ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline'; ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline';
ui.avrStatus.classList.toggle('on', Boolean(avr.connected)); ui.avrStatus.classList.toggle('on', Boolean(avr.connected));
renderTrack(avr.now_playing || null, avrTrack);
const problems = state.errors || []; const problems = state.errors || [];
ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : ''); ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : '');
+8
View File
@@ -53,6 +53,14 @@
<svg class="chevron" viewBox="0 0 320 512" aria-hidden="true"><path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z"/></svg> <svg class="chevron" viewBox="0 0 320 512" aria-hidden="true"><path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z"/></svg>
</button> </button>
<div class="now-playing" data-role="avr-now-playing" hidden>
<img class="cover" data-role="avr-cover" alt="" hidden>
<div class="track">
<span class="song" data-role="avr-song"></span>
<span class="artist" data-role="avr-artist"></span>
</div>
</div>
<div class="joined" data-role="joined" hidden> <div class="joined" data-role="joined" hidden>
<p class="joined-title">Playing together</p> <p class="joined-title">Playing together</p>
<div class="joined-rooms" data-role="joined-rooms"></div> <div class="joined-rooms" data-role="joined-rooms"></div>
+12 -6
View File
@@ -1,8 +1,9 @@
"""Stand-in HEOS hardware: just enough of the CLI protocol to test against. """Stand-in HEOS hardware: just enough of the CLI protocol to test against.
The grouping rules are the part worth pinning down -- what a set_group The grouping rules are the part worth pinning down -- what a set_group
call does to a stereo pair is the kind of thing you do not want to find call actually does to players already in a group is the kind of thing
out by experimenting on the speakers at eleven at night. you do not want to find out by experimenting on the speakers at eleven
at night.
""" """
import json import json
@@ -11,9 +12,14 @@ import threading
class FakeHeos(threading.Thread): class FakeHeos(threading.Thread):
"""A HEOS CLI server on localhost, with four players and a pair.""" """A HEOS CLI server on localhost, with three players.
NAMES = {1: "Home Cinema", 2: "Lego Room", 3: "Denon Home 200 L", 4: "Denon Home 200 R"} "Denon Home 200 L" stands in for the living room's stereo pair --
HEOS pairs that kind of In-Room Group at the hardware level, so it
is one player, one pid, exactly like any other room.
"""
NAMES = {1: "Home Cinema", 2: "Lego Room", 3: "Denon Home 200 L"}
# What browse/browse?sid=<AVR pid> reports: HEOS's own list of the # What browse/browse?sid=<AVR pid> reports: HEOS's own list of the
# AVR's local inputs, already under whatever names you gave them in # AVR's local inputs, already under whatever names you gave them in
@@ -26,10 +32,10 @@ class FakeHeos(threading.Thread):
def __init__(self): def __init__(self):
super().__init__(daemon=True) super().__init__(daemon=True)
self.groups = {3: [3, 4]} # gid -> pids, leader first self.groups = {} # gid -> pids, leader first
self.volumes = {pid: 20 for pid in self.NAMES} self.volumes = {pid: 20 for pid in self.NAMES}
self.play_states = {pid: "play" for pid in self.NAMES} self.play_states = {pid: "play" for pid in self.NAMES}
self.group_volumes = {3: 25} self.group_volumes = {}
self.now_playing_mid = {1: "inputs/mediaplayer"} # pid -> what get_now_playing_media reports self.now_playing_mid = {1: "inputs/mediaplayer"} # pid -> what get_now_playing_media reports
self.now_playing_sid = {} # pid -> its source id; 4 is Spotify self.now_playing_sid = {} # pid -> its source id; 4 is Spotify
self.now_playing_track = {} # pid -> song/artist/image_url fields self.now_playing_track = {} # pid -> song/artist/image_url fields
+64 -81
View File
@@ -4,7 +4,6 @@
""" """
import sys import sys
import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -14,62 +13,56 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from controller import Controller, stepped_level # noqa: E402 from controller import Controller, stepped_level # noqa: E402
from tests.fakes import FakeHeos # noqa: E402 from tests.fakes import FakeHeos # noqa: E402
PAIR = {3, 4} # the two Home 200s
AVR_PID = 1 AVR_PID = 1
HOME400_PID = 2 HOME400_PID = 2
LIVING_ROOM_PID = 3 # the Home 200 pair, one pid -- HEOS pairs it at the hardware level
def build(tmpdir): def build():
heos = FakeHeos() heos = FakeHeos()
cfg = SimpleNamespace( cfg = SimpleNamespace(
HEOS_HOST="127.0.0.1", HEOS_PORT=heos.port, HEOS_HOST="127.0.0.1", HEOS_PORT=heos.port,
HOST_KEY="avr", HOST_KEY="avr",
ROOM_KEYS=["home400", "living_room_group"], ROOM_KEYS=["lego_room", "living_room"],
TARGETS={ TARGETS={
"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"}, "avr": {"label": "Home Cinema", "heos_name": "Home Cinema"},
"home400": {"label": "Lego Room", "heos_name": "Lego Room"}, "lego_room": {"label": "Lego Room", "heos_name": "Lego Room"},
"living_room_group": {"label": "Living Room", "heos_name": "Denon Home 200 L"}, "living_room": {"label": "Living Room", "heos_name": "Denon Home 200 L"},
}, },
AVR_INPUT_CODES=[],
VOLUME_STEP=5, VOLUME_STEP=5,
MEMBERS_FILE=str(Path(tmpdir) / "members.json"),
) )
return Controller(cfg), heos return Controller(cfg), heos
class PanelTest(unittest.TestCase): class PanelTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = tempfile.TemporaryDirectory() self.panel, self.heos = build()
self.addCleanup(self.tmp.cleanup)
self.panel, self.heos = build(self.tmp.name)
def group_pids(self): def group_pids(self):
return {gid: set(pids) for gid, pids in self.heos.groups.items()} return {gid: set(pids) for gid, pids in self.heos.groups.items()}
# -- resolving ------------------------------------------------------ # -- resolving ------------------------------------------------------
def test_pair_resolves_to_both_speakers(self): def test_living_room_resolves_to_its_one_pid(self):
"""The bug this replaces: grouping used only the pair's leader,
which left the second Home 200 behind."""
self.panel.scan() self.panel.scan()
self.assertEqual(set(self.panel.member_pids("living_room_group")), PAIR) self.assertEqual(self.panel.member_pids("living_room"), [LIVING_ROOM_PID])
self.assertEqual(self.panel.member_pids("home400"), [HOME400_PID]) self.assertEqual(self.panel.member_pids("lego_room"), [HOME400_PID])
def test_avr_resolves_to_a_player_even_while_it_leads_a_group(self): def test_avr_resolves_to_a_player_even_while_it_leads_a_group(self):
self.panel.join("home400") self.panel.join("lego_room")
# HEOS now reports a *group* named "Home Cinema" as well as the player. # HEOS now reports a *group* named "Home Cinema" as well as the player.
self.assertEqual(self.panel.member_pids("avr"), [AVR_PID]) self.assertEqual(self.panel.member_pids("avr"), [AVR_PID])
# -- grouping ------------------------------------------------------- # -- grouping -------------------------------------------------------
def test_joining_takes_the_whole_pair(self): def test_joining_adds_the_room(self):
self.panel.join("living_room_group") self.panel.join("living_room")
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID} | PAIR}) self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, LIVING_ROOM_PID}})
self.assertEqual(self.panel.joined_keys(), ["living_room_group"]) self.assertEqual(self.panel.joined_keys(), ["living_room"])
def test_joining_keeps_whoever_is_already_grouped(self): def test_joining_keeps_whoever_is_already_grouped(self):
self.panel.join("home400") self.panel.join("lego_room")
self.panel.join("living_room_group") self.panel.join("living_room")
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, HOME400_PID} | PAIR}) self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, HOME400_PID, LIVING_ROOM_PID}})
self.assertEqual(self.panel.joined_keys(), ["home400", "living_room_group"]) self.assertEqual(self.panel.joined_keys(), ["lego_room", "living_room"])
def test_joining_replays_the_avr_input_over_heos(self): def test_joining_replays_the_avr_input_over_heos(self):
"""A room that has just joined sometimes stays silent until the """A room that has just joined sometimes stays silent until the
@@ -77,102 +70,92 @@ class PanelTest(unittest.TestCase):
the way the HEOS app does it, not the AVR's Telnet port -- so the way the HEOS app does it, not the AVR's Telnet port -- so
join() pokes it with whatever is already playing.""" join() pokes it with whatever is already playing."""
before = len(self.heos.commands) before = len(self.heos.commands)
self.panel.join("home400") self.panel.join("lego_room")
replays = [c for c in self.heos.commands[before:] if "browse/play_input" in c] replays = [c for c in self.heos.commands[before:] if "browse/play_input" in c]
self.assertEqual(len(replays), 1) self.assertEqual(len(replays), 1)
self.assertIn(f"pid={AVR_PID}", replays[0]) self.assertIn(f"pid={AVR_PID}", replays[0])
self.assertIn("input=inputs/mediaplayer", replays[0]) self.assertIn("input=inputs/mediaplayer", replays[0])
def test_leaving_rebuilds_the_stereo_pair(self): def test_leaving_ungroups_the_room(self):
self.panel.join("living_room_group") self.panel.join("living_room")
self.panel.leave("living_room_group") self.panel.leave("living_room")
self.assertEqual(self.group_pids(), {3: PAIR}) # pair back, AVR alone self.assertEqual(self.group_pids(), {}) # AVR alone, no group left
self.assertEqual(self.panel.joined_keys(), []) self.assertEqual(self.panel.joined_keys(), [])
def test_leaving_one_room_does_not_disturb_the_other(self): def test_leaving_one_room_does_not_disturb_the_other(self):
self.panel.join("home400") self.panel.join("lego_room")
self.panel.join("living_room_group") self.panel.join("living_room")
before = [c for c in self.heos.commands if "set_group" in c] before = [c for c in self.heos.commands if "set_group" in c]
self.panel.leave("home400") self.panel.leave("lego_room")
after = [c for c in self.heos.commands if "set_group" in c] after = [c for c in self.heos.commands if "set_group" in c]
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID} | PAIR}) self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, LIVING_ROOM_PID}})
# Exactly one new set_group: the remaining room is never regrouped, # Exactly one new set_group: the remaining room is never regrouped,
# which is what stops its music restarting. # which is what stops its music restarting.
self.assertEqual(len(after) - len(before), 1) self.assertEqual(len(after) - len(before), 1)
def test_separate_everything(self): def test_separate_everything(self):
self.panel.set_membership(["home400", "living_room_group"]) self.panel.set_membership(["lego_room", "living_room"])
self.panel.set_membership([]) self.panel.set_membership([])
self.assertEqual(self.group_pids(), {3: PAIR}) self.assertEqual(self.group_pids(), {})
self.assertEqual(self.panel.joined_keys(), []) self.assertEqual(self.panel.joined_keys(), [])
def test_membership_survives_a_restart_while_merged(self): def test_resolving_survives_a_restart_while_merged(self):
"""Once merged, the pair's own group is gone from HEOS, so a fresh """member_pids reads the live player list, so a fresh process needs
process has to fall back on what it learned earlier.""" no memory of anything to find a room that is currently merged."""
self.panel.join("living_room_group") self.panel.join("living_room")
reborn = Controller(self.panel.cfg) reborn = Controller(self.panel.cfg)
reborn.scan() reborn.scan()
self.assertEqual(set(reborn.member_pids("living_room_group")), PAIR) self.assertEqual(reborn.member_pids("living_room"), [LIVING_ROOM_PID])
reborn.leave("living_room_group") reborn.leave("living_room")
self.assertEqual(self.group_pids(), {3: PAIR}) self.assertEqual(self.group_pids(), {})
# -- volume --------------------------------------------------------- # -- volume ---------------------------------------------------------
def test_pair_uses_group_volume_when_it_stands_alone(self): def test_living_room_volume_is_its_own_player_volume(self):
self.panel.scan() self.panel.scan()
self.assertEqual(self.panel.set_volume("living_room_group", 42), 42) self.assertEqual(self.panel.set_volume("living_room", 42), 42)
self.assertEqual(self.heos.group_volumes[3], 42) self.assertEqual(self.heos.volumes[LIVING_ROOM_PID], 42)
self.assertEqual(self.panel.volume("living_room"), 42)
def test_pair_uses_player_volume_once_merged(self):
"""Its gid stops existing the moment it joins the AVR, so the old
bridge's get_volume?gid= call would simply fail here."""
self.panel.join("living_room_group")
self.assertEqual(self.panel.set_volume("living_room_group", 31), 31)
self.assertEqual(self.heos.volumes[3], 31)
self.assertEqual(self.heos.volumes[4], 31)
self.assertEqual(self.panel.volume("living_room_group"), 31)
def test_nudge_clamps_at_the_ends(self): def test_nudge_clamps_at_the_ends(self):
self.panel.set_volume("home400", 98) self.panel.set_volume("lego_room", 98)
self.assertEqual(self.panel.nudge_volume("home400", 5), 100) self.assertEqual(self.panel.nudge_volume("lego_room", 5), 100)
self.panel.set_volume("home400", 1) self.panel.set_volume("lego_room", 1)
self.assertEqual(self.panel.nudge_volume("home400", -9), 0) self.assertEqual(self.panel.nudge_volume("lego_room", -9), 0)
# -- volume in whole steps ------------------------------------------- # -- volume in whole steps -------------------------------------------
def test_a_tap_lands_on_the_next_multiple(self): def test_a_tap_lands_on_the_next_multiple(self):
self.panel.set_volume("home400", 23) self.panel.set_volume("lego_room", 23)
self.assertEqual(self.panel.step_volume("home400", 1), 25) self.assertEqual(self.panel.step_volume("lego_room", 1), 25)
self.panel.set_volume("home400", 23) self.panel.set_volume("lego_room", 23)
self.assertEqual(self.panel.step_volume("home400", -1), 20) self.assertEqual(self.panel.step_volume("lego_room", -1), 20)
def test_a_level_already_on_a_multiple_moves_a_whole_step(self): def test_a_level_already_on_a_multiple_moves_a_whole_step(self):
self.panel.set_volume("home400", 25) self.panel.set_volume("lego_room", 25)
self.assertEqual(self.panel.step_volume("home400", 1), 30) self.assertEqual(self.panel.step_volume("lego_room", 1), 30)
self.panel.set_volume("home400", 25) self.panel.set_volume("lego_room", 25)
self.assertEqual(self.panel.step_volume("home400", -1), 20) self.assertEqual(self.panel.step_volume("lego_room", -1), 20)
def test_a_burst_of_taps_snaps_once_then_moves_whole_steps(self): def test_a_burst_of_taps_snaps_once_then_moves_whole_steps(self):
self.panel.set_volume("living_room_group", 23) self.panel.set_volume("living_room", 23)
self.assertEqual(self.panel.step_volume("living_room_group", 3), 35) self.assertEqual(self.panel.step_volume("living_room", 3), 35)
# -- play / pause ------------------------------------------------------ # -- play / pause ------------------------------------------------------
def test_toggle_play_flips_what_the_speakers_report(self): def test_toggle_play_flips_what_the_speakers_report(self):
self.panel.scan() self.panel.scan()
self.assertEqual(self.panel.get_play_state("home400"), "play") self.assertEqual(self.panel.get_play_state("lego_room"), "play")
self.assertEqual(self.panel.toggle_play("home400"), "pause") self.assertEqual(self.panel.toggle_play("lego_room"), "pause")
self.assertEqual(self.panel.get_play_state("home400"), "pause") self.assertEqual(self.panel.get_play_state("lego_room"), "pause")
self.assertEqual(self.panel.toggle_play("home400"), "play") self.assertEqual(self.panel.toggle_play("lego_room"), "play")
def test_toggle_play_takes_an_explicit_state(self): def test_toggle_play_takes_an_explicit_state(self):
self.panel.scan() self.panel.scan()
self.assertEqual(self.panel.toggle_play("home400", "stop"), "stop") self.assertEqual(self.panel.toggle_play("lego_room", "stop"), "stop")
self.assertEqual(self.heos.play_states[HOME400_PID], "stop") self.assertEqual(self.heos.play_states[HOME400_PID], "stop")
def test_pair_is_controlled_through_one_of_its_speakers(self): def test_living_room_playback_goes_to_its_player(self):
"""Playback is a player command -- a group has none of its own -- so
it goes to the pair's leader."""
self.panel.scan() self.panel.scan()
self.panel.toggle_play("living_room_group", "pause") self.panel.toggle_play("living_room", "pause")
self.assertEqual(self.heos.play_states[3], "pause") self.assertEqual(self.heos.play_states[LIVING_ROOM_PID], "pause")
def test_state_says_which_rooms_are_playing_spotify(self): def test_state_says_which_rooms_are_playing_spotify(self):
"""The cards only offer play/pause for a Spotify stream, so the """The cards only offer play/pause for a Spotify stream, so the
@@ -249,10 +232,10 @@ class PanelTest(unittest.TestCase):
# -- the whole snapshot the UI renders ------------------------------- # -- the whole snapshot the UI renders -------------------------------
def test_state_snapshot(self): def test_state_snapshot(self):
self.panel.join("home400") self.panel.join("lego_room")
state = self.panel.state() state = self.panel.state()
self.assertTrue(state["heos_ok"]) self.assertTrue(state["heos_ok"])
self.assertEqual([r["key"] for r in state["rooms"]], ["home400", "living_room_group"]) self.assertEqual([r["key"] for r in state["rooms"]], ["lego_room", "living_room"])
self.assertEqual([r["grouped"] for r in state["rooms"]], [True, False]) self.assertEqual([r["grouped"] for r in state["rooms"]], [True, False])
self.assertTrue(all(isinstance(r["volume"], int) for r in state["rooms"])) self.assertTrue(all(isinstance(r["volume"], int) for r in state["rooms"]))
self.assertEqual([r["play_state"] for r in state["rooms"]], ["play", "play"]) self.assertEqual([r["play_state"] for r in state["rooms"]], ["play", "play"])
+3 -3
View File
@@ -2,7 +2,7 @@
"""One-time Spotify login per account, to get the refresh token config.py """One-time Spotify login per account, to get the refresh token config.py
needs for that account's Spotify button on the panel. needs for that account's Spotify button on the panel.
python3 tools/spotify_auth.py --client-id ... --client-secret ... --account fifou python3 tools/spotify_auth.py --client-id ... --client-secret ... --account 1
Run this somewhere you can actually reach a browser to approve the Run this somewhere you can actually reach a browser to approve the
login -- your laptop, or a WSL shell if Windows can reach it (WSL2 login -- your laptop, or a WSL shell if Windows can reach it (WSL2
@@ -126,7 +126,7 @@ def main():
parser.add_argument("--client-id", required=True) parser.add_argument("--client-id", required=True)
parser.add_argument("--client-secret", required=True) parser.add_argument("--client-secret", required=True)
parser.add_argument("--account", required=True, parser.add_argument("--account", required=True,
help="the SPOTIFY_ACCOUNTS key this login is for, e.g. fifou or clarita") help="which SPOTIFY_ACCOUNTS slot this login is for, e.g. 1 or 2")
args = parser.parse_args() args = parser.parse_args()
state = secrets.token_urlsafe(16) state = secrets.token_urlsafe(16)
@@ -136,7 +136,7 @@ def main():
print("\nPut these in .env -- the client id and secret are the same for every account:\n") print("\nPut these in .env -- the client id and secret are the same for every account:\n")
print(f"SPOTIFY_CLIENT_ID={args.client_id}") print(f"SPOTIFY_CLIENT_ID={args.client_id}")
print(f"SPOTIFY_CLIENT_SECRET={args.client_secret}") print(f"SPOTIFY_CLIENT_SECRET={args.client_secret}")
print(f"SPOTIFY_{args.account.upper()}_REFRESH_TOKEN={tokens['refresh_token']}") print(f"SPOTIFY_ACCOUNT{args.account}_REFRESH_TOKEN={tokens['refresh_token']}")
if __name__ == "__main__": if __name__ == "__main__":
+39
View File
@@ -0,0 +1,39 @@
"""Minimal client for the Zidoo media player's own HTTP API
The AVR only tells HEOS which of its inputs is selected, never what a
device plugged into one is actually showing, so a Zidoo's "now playing"
has to be asked for directly, over its own control API on port 9529.
Its video player only answers getPlayStatus while a video is actually
loaded -- with nothing playing, that route does not exist yet, which
looks the same here as the box being off or unreachable. Both are simply
"nothing to show" rather than an error: this is a nice-to-have on top of
an AVR input, not something the rest of the panel depends on.
"""
import json
import urllib.error
import urllib.request
class ZidooClient:
def __init__(self, host, port=9529, timeout=1.5):
self.base_url = f"http://{host}:{port}"
self.timeout = timeout
def now_playing(self):
"""{"song", "artist", "image"} for whatever video is loaded, in the
same shape a room's now-playing card already expects -- or None."""
try:
with urllib.request.urlopen(
f"{self.base_url}/ZidooVideoPlay/getPlayStatus", timeout=self.timeout
) as response:
payload = json.loads(response.read())
except (urllib.error.URLError, ValueError):
return None
if payload.get("status") != 200:
return None
title = (payload.get("video") or {}).get("title")
if not title:
return None
return {"song": title, "artist": None, "image": None}