This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
SPOTIFY_ACCOUNT1_REFRESH_TOKEN=
|
||||
SPOTIFY_ACCOUNT2_REFRESH_TOKEN=
|
||||
@@ -80,9 +80,9 @@ jobs:
|
||||
- name: Run tests
|
||||
run: .venv-ci/bin/python -m unittest discover -s tests -t . --verbose
|
||||
|
||||
# .venv, members.json and .env are excluded, so the runtime, the
|
||||
# stereo pair's learned membership, and Spotify's credentials survive
|
||||
# --delete untouched.
|
||||
# .venv, .env and config.json are excluded, so the
|
||||
# runtime, the stereo pair's learned membership, Spotify's
|
||||
# credentials, and your rooms/ports survive --delete untouched.
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
rsync -azc --no-times --delete \
|
||||
@@ -90,8 +90,8 @@ jobs:
|
||||
--exclude "/.gitea/" \
|
||||
--exclude "/.venv/" \
|
||||
--exclude "/.venv-ci/" \
|
||||
--exclude "/members.json" \
|
||||
--exclude "/.env" \
|
||||
--exclude "/config.json" \
|
||||
--exclude "__pycache__/" \
|
||||
./ "$DEPLOY_PATH/"
|
||||
|
||||
|
||||
+2
-2
@@ -144,5 +144,5 @@ __pycache__/
|
||||
venv/
|
||||
.venv-ci/
|
||||
|
||||
# Learned HEOS group membership, written at runtime
|
||||
members.json
|
||||
# Your own rooms/speakers/ports -- copy config.json.example to config.json
|
||||
config.json
|
||||
|
||||
@@ -1,163 +1,33 @@
|
||||
# HEOS panel
|
||||
# Heos app
|
||||
|
||||
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.
|
||||
_The default HEOS app is so bad I had to make one myself._
|
||||
|
||||
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
|
||||
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.
|
||||
- **Play or pause** either room, or skip a track, while it is playing
|
||||
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
|
||||
Everything fits on one screen:
|
||||
|
||||
- **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.
|
||||
- **Navigate** either room (play / pause / prev / next), while it is playing Spotify
|
||||
- **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
|
||||
- **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
|
||||
- **Change the AVR's input**, listed under the names you gave them, minus 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
|
||||
|
||||
## The kit it assumes
|
||||
|
||||
| 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`) |
|
||||
| Home Cinema | Denon AVR-X3800H | a **player** |
|
||||
|
||||
Any other mix works — it is all in `config.py`.
|
||||
See `config.json.example`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd /var/www/html/heos
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python3 app.py
|
||||
```
|
||||
### CI/CD
|
||||
|
||||
Then open `http://<pi-ip>:5443/`.
|
||||
|
||||
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:
|
||||
`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
|
||||
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.
|
||||
|
||||
`python3 app.py` (no gunicorn) is still the right way to run it by hand
|
||||
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:
|
||||
Make sure the CI/CD's \<user\> can restart the service:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/html/heos
|
||||
sudo chown "$(id -un)": /var/www/html/heos
|
||||
|
||||
echo "$(id -un) ALL=(ALL) NOPASSWD: $(command -v systemctl) restart heos-panel" \
|
||||
echo "<user> ALL=(ALL) NOPASSWD: $(command -v systemctl) restart heos-panel" \
|
||||
| sudo tee /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
|
||||
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:
|
||||
Run the Gitea CI/CD.
|
||||
|
||||
- the user has to be the runner's, and
|
||||
- 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.
|
||||
### Manually
|
||||
|
||||
Then push. That first run deploys the files and builds the virtualenv, and
|
||||
stops at the restart, because the service does not exist yet. Install it
|
||||
from the copy it just put there:
|
||||
Clone repo to `/var/www/html/heos` and:
|
||||
|
||||
```bash
|
||||
sudo cp /var/www/html/heos/deploy/heos-panel.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable heos-panel
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
Edit `User=` and the paths in the unit first if the panel lives somewhere
|
||||
else or runs as someone else. Re-run the workflow and it goes green.
|
||||
### Add a reverse proxy (optional)
|
||||
|
||||
### 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
|
||||
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.
|
||||
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.
|
||||
|
||||
## Behind a reverse proxy, at /heos
|
||||
#### 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
|
||||
```
|
||||
|
||||
`deploy/heos.nginx.conf` is the same thing for nginx — a whole `server`
|
||||
block for `rpioffice.nest.domain.com`, ready for `sites-available`:
|
||||
#### Nginx
|
||||
|
||||
`deploy/heos.nginx.conf` reverse-proxies `/heos` to the panel with Nginx:
|
||||
|
||||
```bash
|
||||
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 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;
|
||||
the file ends with the two-line change that puts it at the root instead.
|
||||
## Configure
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 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.
|
||||
`web_port` is this app listening port.
|
||||
|
||||
Two things worth knowing:
|
||||
Open `http://<host-ip>:<web_port>/api/targets`.
|
||||
|
||||
- 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>:5443`. Start it with
|
||||
`--host 127.0.0.1` if you want Apache to be the only way in.
|
||||
`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).
|
||||
|
||||
## 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".
|
||||
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.
|
||||
Restart app flask/gunicorn.
|
||||
|
||||
**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:
|
||||
## Configure Spotify (Optional)
|
||||
|
||||
- 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
|
||||
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.
|
||||
|
||||
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`:
|
||||
This needs Spotify Premium and a one-time login, since Spotify has no way to grant that without a human approving it once.
|
||||
|
||||
```python
|
||||
"living_room_group": {
|
||||
"label": "Living Room",
|
||||
"heos_name": "Denon Home 200 L",
|
||||
"players": ["Denon Home 200 L", "Denon Home 200 R"], # leader first
|
||||
},
|
||||
```
|
||||
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):
|
||||
|
||||
Leaving a room deliberately does *not* rewrite the AVR's group, so the other
|
||||
room's music does not restart.
|
||||
```bash
|
||||
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
|
||||
does not push audio to it — HEOS needs telling *again* which input is
|
||||
playing before it streams that input to the new member, the same reselect
|
||||
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.
|
||||
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`.
|
||||
4. Put those four lines in a `.env` file in this directory
|
||||
5. Restart app flask/gunicorn
|
||||
|
||||
## 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
|
||||
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.
|
||||
## Add it to the iOS home screen
|
||||
|
||||
Selecting an input goes through `browse/play_input`, not a raw `SI<code>`
|
||||
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
|
||||
are grouped with the AVR, not just which jack the AVR itself is listening to.
|
||||
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.
|
||||
|
||||
## HTTP API
|
||||
|
||||
Used by the interface:
|
||||
|
||||
| | |
|
||||
| HTTP Call | Description |
|
||||
| --- | --- |
|
||||
| `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/playback` | `{"target": "home400", "state": "pause"}`, or no `state` to toggle |
|
||||
| `POST /api/skip` | `{"target": "home400", "direction": "next"}` — `previous` too |
|
||||
| `POST /api/group` | `{"target": "home400", "joined": true}` |
|
||||
| `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": "lego_room"}` |
|
||||
| `POST /api/playback` | `{"target": "lego_room", "state": "pause"}`, or no `state` to toggle |
|
||||
| `POST /api/skip` | `{"target": "lego_room", "direction": "next"}` — `previous` too |
|
||||
| `POST /api/group` | `{"target": "lego_room", "joined": true}` |
|
||||
| `POST /api/group/none` | every room back on its own |
|
||||
| `GET /api/avr/inputs` | your renamed sources, over HEOS |
|
||||
| `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) |
|
||||
| `POST /api/spotify/resume` | `{"target": "home400", "account": "fifou"}` — transfers that account's current playback there and resumes it |
|
||||
| `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": "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
|
||||
`?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
|
||||
## Demo/Tests
|
||||
|
||||
```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, 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
|
||||
```
|
||||
|
||||
@@ -274,7 +274,7 @@ def api_avr_set_input():
|
||||
@handle_errors
|
||||
def api_spotify_devices():
|
||||
"""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."""
|
||||
return jsonify(_spotify_from(request.args).devices())
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ first time you run this, so the names below match exactly what HEOS
|
||||
reports for your own devices.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
@@ -27,63 +28,62 @@ def _load_dotenv(path: Path):
|
||||
|
||||
_load_dotenv(Path(__file__).resolve().parent / ".env")
|
||||
|
||||
# --- Network ----------------------------------------------------------
|
||||
# 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.
|
||||
HEOS_HOST = "192.168.0.10"
|
||||
HEOS_PORT = 1255
|
||||
|
||||
# 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.
|
||||
# --- Rooms, ports & behaviour -------------------------------------------
|
||||
# None of this is sensitive -- it doesn't give away anything about your
|
||||
# LAN or credentials -- but it's still yours, not the app's: buying a
|
||||
# speaker, renaming a room, or changing a port is a config edit, not a
|
||||
# code change. Lives in config.json rather than here or in .env; copy
|
||||
# config.json.example to get started.
|
||||
#
|
||||
# heos_name : the EXACT name HEOS reports for that player or group.
|
||||
# players : only for a target that is a HEOS *group* (a stereo pair
|
||||
# or an In-Room Group). List its member players, leader
|
||||
# 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.
|
||||
# heos_name : the EXACT name HEOS reports for that player -- including
|
||||
# an In-Room Group like a stereo pair, which HEOS pairs at
|
||||
# the hardware level into one player, one pid, always.
|
||||
# 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.
|
||||
TARGETS = {
|
||||
"avr": {
|
||||
"label": "Home Cinema",
|
||||
"heos_name": "Home Cinema", # AVR-X3800H
|
||||
},
|
||||
"home400": {
|
||||
"label": "Lego Room",
|
||||
"heos_name": "Lego Room", # Denon Home 400
|
||||
},
|
||||
"living_room_group": {
|
||||
"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"],
|
||||
},
|
||||
}
|
||||
_config_path = Path(__file__).resolve().parent / "config.json"
|
||||
try:
|
||||
_cfg = json.loads(_config_path.read_text())
|
||||
except FileNotFoundError:
|
||||
raise SystemExit(
|
||||
f"{_config_path} not found -- copy config.json.example to config.json "
|
||||
"and fill in your own rooms (see the README's Configure section)."
|
||||
)
|
||||
|
||||
TARGETS = _cfg["targets"]
|
||||
|
||||
# The AVR is always the group host: its content takes over every room
|
||||
# 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.
|
||||
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
|
||||
# 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
|
||||
# HEOS input id (see GET /api/avr/inputs for the exact strings, e.g.
|
||||
# "inputs/aux_in_1"), and sets their order in the list. Empty = every
|
||||
# source HEOS reports for it.
|
||||
AVR_INPUT_CODES = []
|
||||
# Port the panel itself listens on.
|
||||
WEB_PORT = _cfg["web_port"]
|
||||
|
||||
# --- Network ----------------------------------------------------------
|
||||
# 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.
|
||||
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")
|
||||
|
||||
# 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 = {
|
||||
"fifou": {"label": "Fifou", "refresh_token": os.environ.get("SPOTIFY_FIFOU_REFRESH_TOKEN")},
|
||||
"clarita": {"label": "Clarita", "refresh_token": os.environ.get("SPOTIFY_CLARITA_REFRESH_TOKEN")},
|
||||
"account1": {"label": _spotify_names.get("account1", "Account 1"), "refresh_token": os.environ.get("SPOTIFY_ACCOUNT1_REFRESH_TOKEN")},
|
||||
"account2": {"label": _spotify_names.get("account2", "Account 2"), "refresh_token": os.environ.get("SPOTIFY_ACCOUNT2_REFRESH_TOKEN")},
|
||||
}
|
||||
|
||||
+31
-126
@@ -1,15 +1,11 @@
|
||||
"""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
|
||||
Home 200s) is addressed by a gid and behaves like one speaker -- until
|
||||
you merge it into the AVR's group, at which point that gid stops
|
||||
existing and its two players are just two members of the AVR's group.
|
||||
Two consequences drive most of the code below:
|
||||
|
||||
* 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.
|
||||
The interesting part is grouping. Every room here -- including the
|
||||
living room's L/R pair, which HEOS pairs at the hardware level into a
|
||||
single pid -- is addressed by one player pid, merged or not. set_group
|
||||
replaces a group wholesale rather than adding to it, so join() and
|
||||
leave() always have to name every room that should remain in the AVR's
|
||||
group, not just the one being added or removed.
|
||||
|
||||
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
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from heos import HeosClient, HeosError, parse_message
|
||||
|
||||
MEMBERS_FILE = Path(__file__).with_name("members.json")
|
||||
from zidoo import ZidooClient
|
||||
|
||||
|
||||
def stepped_level(current: int, steps: int, size: int) -> int:
|
||||
@@ -58,9 +51,9 @@ class Controller:
|
||||
def __init__(self, cfg):
|
||||
self.cfg = cfg
|
||||
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._members_file = Path(getattr(cfg, "MEMBERS_FILE", MEMBERS_FILE))
|
||||
self._learned = _load_learned(self._members_file)
|
||||
self._players = []
|
||||
self._groups = []
|
||||
self._scanned_at = 0.0
|
||||
@@ -78,13 +71,11 @@ class Controller:
|
||||
|
||||
# -- network picture -----------------------------------------------
|
||||
def scan(self) -> dict:
|
||||
"""Re-read every player and group, and remember what the rooms
|
||||
are made of while we can see them."""
|
||||
"""Re-read every player and group."""
|
||||
with self._lock:
|
||||
self._players = self.heos.command("player/get_players").get("payload", [])
|
||||
self._groups = self.heos.command("group/get_groups").get("payload", [])
|
||||
self._scanned_at = time.monotonic()
|
||||
self._learn_members()
|
||||
return {"players": self._players, "groups": self._groups}
|
||||
|
||||
def _fresh(self, max_age: float = 2.0):
|
||||
@@ -97,41 +88,6 @@ class Controller:
|
||||
return player.get("pid")
|
||||
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 ---------------------------------------
|
||||
def _host_pid(self):
|
||||
"""The AVR is always a plain player. Resolving it by player name
|
||||
@@ -144,63 +100,25 @@ class Controller:
|
||||
return pid
|
||||
|
||||
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:
|
||||
raise TargetError(f"Unknown room '{key}'")
|
||||
if key == self.cfg.HOST_KEY:
|
||||
return [self._host_pid()]
|
||||
|
||||
target = self.cfg.TARGETS[key]
|
||||
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
|
||||
|
||||
name = self.cfg.TARGETS[key]["heos_name"]
|
||||
pid = self._player_pid(name)
|
||||
if pid is not None:
|
||||
return [pid]
|
||||
|
||||
if pid is None:
|
||||
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."
|
||||
)
|
||||
return [pid]
|
||||
|
||||
# -- volume ---------------------------------------------------------
|
||||
def _volume_handles(self, key: str) -> list:
|
||||
"""Where volume for this room lives right now, as (scope, id).
|
||||
|
||||
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]
|
||||
"""Where volume for this room lives right now, as (scope, id)."""
|
||||
return [("player", pid) for pid in self.member_pids(key)]
|
||||
|
||||
@staticmethod
|
||||
def _id_param(scope: str) -> str:
|
||||
@@ -302,8 +220,7 @@ class Controller:
|
||||
return pids
|
||||
|
||||
def ungroup(self, key: str) -> list:
|
||||
"""Stand a room back up on its own. For the Home 200 pair this
|
||||
re-forms the pair rather than leaving two lone speakers behind."""
|
||||
"""Stand a room back up on its own."""
|
||||
with self._lock:
|
||||
self._fresh()
|
||||
pids = self.member_pids(key)
|
||||
@@ -466,17 +383,8 @@ class Controller:
|
||||
def avr_inputs(self) -> list:
|
||||
"""{"code", "name"} pairs for the picker -- heos_inputs()'s shape,
|
||||
renamed to match what the UI and /api/avr/* already send and
|
||||
expect. Narrowed and ordered by AVR_INPUT_CODES, same as before,
|
||||
except the codes it matches are now HEOS's own ("inputs/aux_in_1"),
|
||||
not the AVR's Telnet ones ("AUX1")."""
|
||||
expect."""
|
||||
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]
|
||||
|
||||
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)
|
||||
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 ------------------------------------------
|
||||
def state(self) -> dict:
|
||||
snapshot = {
|
||||
@@ -552,26 +469,14 @@ class Controller:
|
||||
]
|
||||
|
||||
try:
|
||||
current_input = self.avr_current_input()
|
||||
snapshot["avr"] = {
|
||||
"connected": self.avr_connected(),
|
||||
"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:
|
||||
snapshot["errors"].append(str(exc))
|
||||
|
||||
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
|
||||
|
||||
@@ -19,17 +19,12 @@ class _FakeAvr:
|
||||
{"code": "PHONO", "name": "Turntable"},
|
||||
]
|
||||
|
||||
def __init__(self, allowed_codes=()):
|
||||
self.allowed_codes = list(allowed_codes or [])
|
||||
def __init__(self):
|
||||
self.connected = True
|
||||
self._code = "MPLAY"
|
||||
|
||||
def inputs(self, refresh=False):
|
||||
sources = 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
|
||||
return list(self.INPUTS)
|
||||
|
||||
def name_for(self, code):
|
||||
return next((s["name"] for s in self.INPUTS if s["code"] == code), code)
|
||||
@@ -46,7 +41,7 @@ class _FakeAvr:
|
||||
class DemoController:
|
||||
def __init__(self, cfg):
|
||||
self.cfg = cfg
|
||||
self.avr = _FakeAvr(cfg.AVR_INPUT_CODES)
|
||||
self.avr = _FakeAvr()
|
||||
self.heos = None
|
||||
self._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.TARGETS)}
|
||||
self._play = {key: "play" for key in cfg.TARGETS}
|
||||
@@ -71,7 +66,13 @@ class DemoController:
|
||||
"error": None}
|
||||
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,
|
||||
"errors": [],
|
||||
"demo": True,
|
||||
|
||||
+33
-12
@@ -23,8 +23,25 @@ const ui = {
|
||||
options: el('[data-role="options"]'),
|
||||
joined: el('[data-role="joined"]'),
|
||||
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 = {};
|
||||
let inputs = [];
|
||||
let currentInput = null;
|
||||
@@ -147,24 +164,27 @@ function paintRoom(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. */
|
||||
function paintTrack(room) {
|
||||
const track = room.available ? room.track : null;
|
||||
room.nowPlaying.hidden = !track;
|
||||
function renderTrack(track, refs) {
|
||||
refs.nowPlaying.hidden = !track;
|
||||
if (!track) return;
|
||||
room.song.textContent = track.song;
|
||||
room.artist.textContent = track.artist || '';
|
||||
room.artist.hidden = !track.artist;
|
||||
refs.song.textContent = track.song;
|
||||
refs.artist.textContent = track.artist || '';
|
||||
refs.artist.hidden = !track.artist;
|
||||
if (!track.image) {
|
||||
room.cover.hidden = true;
|
||||
room.cover.removeAttribute('src');
|
||||
} else if (room.cover.getAttribute('src') !== track.image) {
|
||||
room.cover.hidden = false;
|
||||
room.cover.src = track.image;
|
||||
refs.cover.hidden = true;
|
||||
refs.cover.removeAttribute('src');
|
||||
} else if (refs.cover.getAttribute('src') !== track.image) {
|
||||
refs.cover.hidden = false;
|
||||
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
|
||||
means: one group, playing one thing. Leaving puts the card back in its
|
||||
own slot, which is why the slots exist. */
|
||||
@@ -467,6 +487,7 @@ function render(state) {
|
||||
ui.inputName.textContent = currentInput ? currentInput.name : '—';
|
||||
ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline';
|
||||
ui.avrStatus.classList.toggle('on', Boolean(avr.connected));
|
||||
renderTrack(avr.now_playing || null, avrTrack);
|
||||
|
||||
const problems = state.errors || [];
|
||||
ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : '');
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
<p class="joined-title">Playing together</p>
|
||||
<div class="joined-rooms" data-role="joined-rooms"></div>
|
||||
|
||||
+12
-6
@@ -1,8 +1,9 @@
|
||||
"""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
|
||||
call does to a stereo pair is the kind of thing you do not want to find
|
||||
out by experimenting on the speakers at eleven at night.
|
||||
call actually does to players already in a group is the kind of thing
|
||||
you do not want to find out by experimenting on the speakers at eleven
|
||||
at night.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -11,9 +12,14 @@ import threading
|
||||
|
||||
|
||||
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
|
||||
# AVR's local inputs, already under whatever names you gave them in
|
||||
@@ -26,10 +32,10 @@ class FakeHeos(threading.Thread):
|
||||
|
||||
def __init__(self):
|
||||
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.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_sid = {} # pid -> its source id; 4 is Spotify
|
||||
self.now_playing_track = {} # pid -> song/artist/image_url fields
|
||||
|
||||
+64
-81
@@ -4,7 +4,6 @@
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
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 tests.fakes import FakeHeos # noqa: E402
|
||||
|
||||
PAIR = {3, 4} # the two Home 200s
|
||||
AVR_PID = 1
|
||||
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()
|
||||
cfg = SimpleNamespace(
|
||||
HEOS_HOST="127.0.0.1", HEOS_PORT=heos.port,
|
||||
HOST_KEY="avr",
|
||||
ROOM_KEYS=["home400", "living_room_group"],
|
||||
ROOM_KEYS=["lego_room", "living_room"],
|
||||
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"},
|
||||
"lego_room": {"label": "Lego Room", "heos_name": "Lego Room"},
|
||||
"living_room": {"label": "Living Room", "heos_name": "Denon Home 200 L"},
|
||||
},
|
||||
AVR_INPUT_CODES=[],
|
||||
VOLUME_STEP=5,
|
||||
MEMBERS_FILE=str(Path(tmpdir) / "members.json"),
|
||||
)
|
||||
return Controller(cfg), heos
|
||||
|
||||
|
||||
class PanelTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.panel, self.heos = build(self.tmp.name)
|
||||
self.panel, self.heos = build()
|
||||
|
||||
def group_pids(self):
|
||||
return {gid: set(pids) for gid, pids in self.heos.groups.items()}
|
||||
|
||||
# -- resolving ------------------------------------------------------
|
||||
def test_pair_resolves_to_both_speakers(self):
|
||||
"""The bug this replaces: grouping used only the pair's leader,
|
||||
which left the second Home 200 behind."""
|
||||
def test_living_room_resolves_to_its_one_pid(self):
|
||||
self.panel.scan()
|
||||
self.assertEqual(set(self.panel.member_pids("living_room_group")), PAIR)
|
||||
self.assertEqual(self.panel.member_pids("home400"), [HOME400_PID])
|
||||
self.assertEqual(self.panel.member_pids("living_room"), [LIVING_ROOM_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):
|
||||
self.panel.join("home400")
|
||||
self.panel.join("lego_room")
|
||||
# HEOS now reports a *group* named "Home Cinema" as well as the player.
|
||||
self.assertEqual(self.panel.member_pids("avr"), [AVR_PID])
|
||||
|
||||
# -- grouping -------------------------------------------------------
|
||||
def test_joining_takes_the_whole_pair(self):
|
||||
self.panel.join("living_room_group")
|
||||
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID} | PAIR})
|
||||
self.assertEqual(self.panel.joined_keys(), ["living_room_group"])
|
||||
def test_joining_adds_the_room(self):
|
||||
self.panel.join("living_room")
|
||||
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, LIVING_ROOM_PID}})
|
||||
self.assertEqual(self.panel.joined_keys(), ["living_room"])
|
||||
|
||||
def test_joining_keeps_whoever_is_already_grouped(self):
|
||||
self.panel.join("home400")
|
||||
self.panel.join("living_room_group")
|
||||
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, HOME400_PID} | PAIR})
|
||||
self.assertEqual(self.panel.joined_keys(), ["home400", "living_room_group"])
|
||||
self.panel.join("lego_room")
|
||||
self.panel.join("living_room")
|
||||
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, HOME400_PID, LIVING_ROOM_PID}})
|
||||
self.assertEqual(self.panel.joined_keys(), ["lego_room", "living_room"])
|
||||
|
||||
def test_joining_replays_the_avr_input_over_heos(self):
|
||||
"""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
|
||||
join() pokes it with whatever is already playing."""
|
||||
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]
|
||||
self.assertEqual(len(replays), 1)
|
||||
self.assertIn(f"pid={AVR_PID}", replays[0])
|
||||
self.assertIn("input=inputs/mediaplayer", replays[0])
|
||||
|
||||
def test_leaving_rebuilds_the_stereo_pair(self):
|
||||
self.panel.join("living_room_group")
|
||||
self.panel.leave("living_room_group")
|
||||
self.assertEqual(self.group_pids(), {3: PAIR}) # pair back, AVR alone
|
||||
def test_leaving_ungroups_the_room(self):
|
||||
self.panel.join("living_room")
|
||||
self.panel.leave("living_room")
|
||||
self.assertEqual(self.group_pids(), {}) # AVR alone, no group left
|
||||
self.assertEqual(self.panel.joined_keys(), [])
|
||||
|
||||
def test_leaving_one_room_does_not_disturb_the_other(self):
|
||||
self.panel.join("home400")
|
||||
self.panel.join("living_room_group")
|
||||
self.panel.join("lego_room")
|
||||
self.panel.join("living_room")
|
||||
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]
|
||||
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,
|
||||
# which is what stops its music restarting.
|
||||
self.assertEqual(len(after) - len(before), 1)
|
||||
|
||||
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.assertEqual(self.group_pids(), {3: PAIR})
|
||||
self.assertEqual(self.group_pids(), {})
|
||||
self.assertEqual(self.panel.joined_keys(), [])
|
||||
|
||||
def test_membership_survives_a_restart_while_merged(self):
|
||||
"""Once merged, the pair's own group is gone from HEOS, so a fresh
|
||||
process has to fall back on what it learned earlier."""
|
||||
self.panel.join("living_room_group")
|
||||
def test_resolving_survives_a_restart_while_merged(self):
|
||||
"""member_pids reads the live player list, so a fresh process needs
|
||||
no memory of anything to find a room that is currently merged."""
|
||||
self.panel.join("living_room")
|
||||
reborn = Controller(self.panel.cfg)
|
||||
reborn.scan()
|
||||
self.assertEqual(set(reborn.member_pids("living_room_group")), PAIR)
|
||||
reborn.leave("living_room_group")
|
||||
self.assertEqual(self.group_pids(), {3: PAIR})
|
||||
self.assertEqual(reborn.member_pids("living_room"), [LIVING_ROOM_PID])
|
||||
reborn.leave("living_room")
|
||||
self.assertEqual(self.group_pids(), {})
|
||||
|
||||
# -- 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.assertEqual(self.panel.set_volume("living_room_group", 42), 42)
|
||||
self.assertEqual(self.heos.group_volumes[3], 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)
|
||||
self.assertEqual(self.panel.set_volume("living_room", 42), 42)
|
||||
self.assertEqual(self.heos.volumes[LIVING_ROOM_PID], 42)
|
||||
self.assertEqual(self.panel.volume("living_room"), 42)
|
||||
|
||||
def test_nudge_clamps_at_the_ends(self):
|
||||
self.panel.set_volume("home400", 98)
|
||||
self.assertEqual(self.panel.nudge_volume("home400", 5), 100)
|
||||
self.panel.set_volume("home400", 1)
|
||||
self.assertEqual(self.panel.nudge_volume("home400", -9), 0)
|
||||
self.panel.set_volume("lego_room", 98)
|
||||
self.assertEqual(self.panel.nudge_volume("lego_room", 5), 100)
|
||||
self.panel.set_volume("lego_room", 1)
|
||||
self.assertEqual(self.panel.nudge_volume("lego_room", -9), 0)
|
||||
|
||||
# -- volume in whole steps -------------------------------------------
|
||||
def test_a_tap_lands_on_the_next_multiple(self):
|
||||
self.panel.set_volume("home400", 23)
|
||||
self.assertEqual(self.panel.step_volume("home400", 1), 25)
|
||||
self.panel.set_volume("home400", 23)
|
||||
self.assertEqual(self.panel.step_volume("home400", -1), 20)
|
||||
self.panel.set_volume("lego_room", 23)
|
||||
self.assertEqual(self.panel.step_volume("lego_room", 1), 25)
|
||||
self.panel.set_volume("lego_room", 23)
|
||||
self.assertEqual(self.panel.step_volume("lego_room", -1), 20)
|
||||
|
||||
def test_a_level_already_on_a_multiple_moves_a_whole_step(self):
|
||||
self.panel.set_volume("home400", 25)
|
||||
self.assertEqual(self.panel.step_volume("home400", 1), 30)
|
||||
self.panel.set_volume("home400", 25)
|
||||
self.assertEqual(self.panel.step_volume("home400", -1), 20)
|
||||
self.panel.set_volume("lego_room", 25)
|
||||
self.assertEqual(self.panel.step_volume("lego_room", 1), 30)
|
||||
self.panel.set_volume("lego_room", 25)
|
||||
self.assertEqual(self.panel.step_volume("lego_room", -1), 20)
|
||||
|
||||
def test_a_burst_of_taps_snaps_once_then_moves_whole_steps(self):
|
||||
self.panel.set_volume("living_room_group", 23)
|
||||
self.assertEqual(self.panel.step_volume("living_room_group", 3), 35)
|
||||
self.panel.set_volume("living_room", 23)
|
||||
self.assertEqual(self.panel.step_volume("living_room", 3), 35)
|
||||
|
||||
# -- play / pause ------------------------------------------------------
|
||||
def test_toggle_play_flips_what_the_speakers_report(self):
|
||||
self.panel.scan()
|
||||
self.assertEqual(self.panel.get_play_state("home400"), "play")
|
||||
self.assertEqual(self.panel.toggle_play("home400"), "pause")
|
||||
self.assertEqual(self.panel.get_play_state("home400"), "pause")
|
||||
self.assertEqual(self.panel.toggle_play("home400"), "play")
|
||||
self.assertEqual(self.panel.get_play_state("lego_room"), "play")
|
||||
self.assertEqual(self.panel.toggle_play("lego_room"), "pause")
|
||||
self.assertEqual(self.panel.get_play_state("lego_room"), "pause")
|
||||
self.assertEqual(self.panel.toggle_play("lego_room"), "play")
|
||||
|
||||
def test_toggle_play_takes_an_explicit_state(self):
|
||||
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")
|
||||
|
||||
def test_pair_is_controlled_through_one_of_its_speakers(self):
|
||||
"""Playback is a player command -- a group has none of its own -- so
|
||||
it goes to the pair's leader."""
|
||||
def test_living_room_playback_goes_to_its_player(self):
|
||||
self.panel.scan()
|
||||
self.panel.toggle_play("living_room_group", "pause")
|
||||
self.assertEqual(self.heos.play_states[3], "pause")
|
||||
self.panel.toggle_play("living_room", "pause")
|
||||
self.assertEqual(self.heos.play_states[LIVING_ROOM_PID], "pause")
|
||||
|
||||
def test_state_says_which_rooms_are_playing_spotify(self):
|
||||
"""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 -------------------------------
|
||||
def test_state_snapshot(self):
|
||||
self.panel.join("home400")
|
||||
self.panel.join("lego_room")
|
||||
state = self.panel.state()
|
||||
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.assertTrue(all(isinstance(r["volume"], int) for r in state["rooms"]))
|
||||
self.assertEqual([r["play_state"] for r in state["rooms"]], ["play", "play"])
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""One-time Spotify login per account, to get the refresh token config.py
|
||||
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
|
||||
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-secret", 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()
|
||||
|
||||
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(f"SPOTIFY_CLIENT_ID={args.client_id}")
|
||||
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__":
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user