Fix account swaping
Deploy HEOS panel / deploy (push) Successful in 26s

This commit is contained in:
2026-09-18 02:05:48 +02:00
parent 053be20b9c
commit 5ca504c680
10 changed files with 885 additions and 21 deletions
+5 -2
View File
@@ -12,6 +12,8 @@ Everything fits on one screen:
- **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 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
- **Disconnect Spotify** again by tapping the bordered button — the account playing there: it pauses that account and signs the speaker out of it, so the music stops and the room belongs to nobody until someone claims it
- **Hand a room over** by tapping the other account: a speaker belongs to one Spotify account at a time, so the panel signs it in to the other one over the LAN — the same thing picking the room in the Spotify app does — and resumes there
## The kit it assumes
@@ -108,7 +110,7 @@ Spotify dropped native, browsable HEOS integration years ago — it is Connect-o
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**.
2. While the app is in development mode, Spotify only serves accounts you have listed: 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
@@ -146,7 +148,8 @@ Used by the interface:
| `GET /api/avr/inputs` | your renamed sources, over HEOS |
| `POST /api/avr/input` | `{"code": "inputs/aux_in_1"}` |
| `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 /api/spotify/resume` | `{"target": "lego_room", "account": "account1"}` — transfers that account's current playback there and resumes it, signing the speaker in to that account first if another one has it |
| `POST /api/spotify/disconnect` | `{"target": "lego_room", "account": "account1"}` — pauses that account and signs the speaker out of it over the LAN, which stops the music too |
## Demo/Tests
+199 -3
View File
@@ -15,15 +15,18 @@ friends) are still here so existing Shortcuts and scripts keep working.
import argparse
import os
import time
from functools import wraps
from flask import Flask, jsonify, render_template, request, url_for
from werkzeug.middleware.proxy_fix import ProxyFix
import config
import spotify_zc
from controller import Controller, TargetError
from heos import HeosError
from spotify import SpotifyClient, SpotifyError
from spotify import SpotifyClient, SpotifyDeviceUnavailable, SpotifyError
from spotify_zc import ZeroconfError
from zidoo import ZidooError
app = Flask(__name__)
@@ -81,7 +84,7 @@ def handle_errors(view):
return view(*args, **kwargs)
except (TargetError, ValueError) as exc:
return jsonify({"error": str(exc)}), 400
except (HeosError, SpotifyError, ZidooError) as exc:
except (HeosError, SpotifyError, ZeroconfError, ZidooError) as exc:
return jsonify({"error": str(exc)}), 502
return wrapped
@@ -140,6 +143,165 @@ def _spotify_playing_on() -> dict:
return playing_on
def _spotify_account_seeing(device_name: str, besides: str) -> str:
"""Which OTHER configured account can currently see that Connect
receiver, if any. A speaker is logged into one Spotify account at a
time and Spotify shows it to that account alone, so when a room goes
missing this -- not the room's power or its spotify_name -- is very
often the whole answer."""
for key, client in spotify.items():
if key == besides:
continue
try:
if any(d.get("name") == device_name for d in client.devices()):
return key
except (SpotifyError, OSError):
continue # an account we cannot ask is not an account holding it
return None
def _spotify_pause_on(key: str, account: str) -> bool:
"""Pause an account, but only if this room is where it is playing -- an
account that has already moved on is playing somewhere else now, and
stopping it there is nobody's intention. Best effort: an account
Spotify will not answer for is no reason to leave the room alone."""
client = spotify.get(account)
if client is None:
return False
try:
player = client.playback()
if (player.get("device") or {}).get("name") == _spotify_name(key) and player.get("is_playing"):
client.pause()
return True
except (SpotifyError, OSError):
pass
return False
# How long to let a speaker finish changing source before stopping it.
RELEASE_TRIES = 6
RELEASE_WAIT = 0.5
def _is_demo() -> bool:
"""Demo mode's rooms are invented, but the real speakers are very
likely on the same LAN -- and signing one in or out would physically
change it. So the zeroconf side sits out a demo."""
return bool(getattr(controller, "demo", False))
def _find_speaker(device_name: str):
"""A room's own LAN endpoint, or None when it cannot be reached."""
try:
return spotify_zc.find(device_name)
except (ZeroconfError, OSError):
return None
def _spotify_release(key: str, account: str = None) -> dict:
"""Take a room off Spotify: pause the account playing there, then sign
the speaker out over the LAN. Reports what it managed: {"paused",
"signed_out"}.
Signing out is the whole disconnect -- the speaker stops within a
second and drops out of the account's device list -- and it leaves the
room belonging to nobody, which costs nothing now that either button
can claim it back with a sign-in (see _spotify_take_over). Pausing
first is manners: it leaves that account where it was rather than cut
off mid-song.
The HEOS fallback below is for a speaker that does not answer on the
LAN. It can end the stream but not the sign-in, and it cannot do it the
obvious way either: player/set_play_state with stop is accepted for a
Connect stream and then quietly leaves the player paused, session
intact. Putting the speaker on one of its own inputs does end the
stream, and stopping *that* is honoured, so the room is left idle
rather than sitting on a live input.
"""
released = {"paused": _spotify_pause_on(key, account), "signed_out": False}
speaker = None if _is_demo() else _find_speaker(_spotify_name(key))
if speaker is not None:
speaker.reset_users()
released["signed_out"] = True
return released
_release_over_heos(key)
return released
def _release_over_heos(key: str):
"""Stop a room's Spotify stream without the speaker's help -- see
_spotify_release for why it goes the long way round."""
inputs = controller.heos_inputs(key)
if not inputs:
raise ValueError(
f"'{config.TARGETS[key]['label']}' did not answer on the network, and HEOS lists "
"no input on it to fall back to -- so there is no way from here to take this room "
"off Spotify.")
# An AUX jack with nothing in it is the quietest thing a speaker can be
# switched to, and the stop below cuts even that short.
quiet = next((i for i in inputs if "aux" in i["input_id"]), inputs[0])
controller.play_heos_input(key, quiet["input_id"])
# A speaker takes a moment to change source, and a stop that arrives
# while it is still on the Spotify stream is exactly the one HEOS turns
# into a pause -- so wait for the input to be what is playing.
for _ in range(RELEASE_TRIES):
time.sleep(RELEASE_WAIT)
if not controller.on_spotify(key):
break
controller.toggle_play(key, "stop")
# Signing a speaker in is a LAN round trip, but Spotify's cloud hearing
# about it is not: the room only turns up in the account's device list a
# moment later, and a resume before then looks exactly like a failure.
HANDOVER_TRIES = 8
HANDOVER_WAIT = 1.2
def _spotify_take_over(device_name: str, client: SpotifyClient) -> str:
"""Sign a room's speaker in to this account over the LAN, the way the
Spotify app does -- see spotify_zc. Returns None when it worked, or
what went wrong, since that is what the caller has to tell the user.
This is the only thing that moves a speaker between accounts: Spotify's
cloud will not, and until the speaker itself is signed in, the Web API
does not admit the room exists."""
try:
# Checked before knocking, not after: a speaker takes the sign-in,
# drops whoever was on it, and only then finds out Spotify will not
# have the token -- which leaves the room signed in to nobody. A
# login too old to carry the scope must not cost you the room.
if not client.has_scope("streaming"):
return ("that account's Spotify login has no 'streaming' scope, which is the one a "
"speaker asks for -- re-run tools/spotify_auth.py for it (see the README's "
"Spotify section), put the new refresh token in .env and restart the panel")
if _is_demo():
return "the panel is in demo mode, which leaves real speakers alone"
speaker = _find_speaker(device_name)
if speaker is None:
return f"no speaker calling itself '{device_name}' answered on the network"
speaker.add_user(client.me()["id"], client.access_token())
except (ZeroconfError, SpotifyError, OSError) as exc:
return str(exc)
return None
def _resume_once_signed_in(client: SpotifyClient, device_name: str) -> dict:
"""Resume as soon as the room turns up in this account's device list,
rather than once, at the one moment it is least likely to be there."""
for attempt in range(HANDOVER_TRIES):
if attempt:
time.sleep(HANDOVER_WAIT)
try:
return client.resume(device_name)
except SpotifyDeviceUnavailable:
continue
raise SpotifyError(
f"'{device_name}' took the sign-in, but Spotify never offered the room to this account "
"-- which means the speaker could not log in with the token it was given, and is now "
"signed in to nobody. Pick the room once in that account's Spotify app to put it back.")
def _mark_spotify_accounts(rooms: list):
"""Tag each room HEOS says is on Spotify with the account playing it, so
its card can pick out that account's button. Spotify is only asked when
@@ -357,10 +519,44 @@ def api_spotify_resume():
data = _payload()
client = _spotify_from(data)
key = _target_from(data)
device = client.resume(_spotify_name(key))
try:
device = client.resume(_spotify_name(key))
except SpotifyDeviceUnavailable as exc:
# A room this account cannot see is nearly always one another
# account is signed in to -- which is worth naming if the hand-over
# below cannot go through.
holder = _spotify_account_seeing(exc.device_name, besides=data["account"])
# Leave whoever is there paused where they were, rather than cut
# off: signing the speaker in to someone else ends their stream.
if holder:
_spotify_pause_on(key, holder)
trouble = _spotify_take_over(exc.device_name, client)
if trouble:
whose = (f" It is signed in to {config.SPOTIFY_ACCOUNTS[holder]['label']}'s Spotify,"
" and Spotify's own API will not move a speaker between accounts." if holder else "")
raise SpotifyError(
f"Could not sign '{exc.device_name}' in to "
f"{config.SPOTIFY_ACCOUNTS[data['account']]['label']}'s Spotify: {trouble}.{whose} "
"Picking the room once in that account's Spotify app does the same thing by "
"hand.") from exc
device = _resume_once_signed_in(client, exc.device_name)
return jsonify({"target": key, "account": data["account"], "device": device.get("name")})
@app.post("/api/spotify/disconnect")
@handle_errors
def api_spotify_disconnect():
"""Let go of a room: pause what the account is playing there, then sign
the speaker out of it, which stops the music and leaves the room free
for either account's button to claim -- see _spotify_release."""
data = _payload()
_spotify_from(data) # the account has to be one of ours to let go of
key = _target_from(data)
released = _spotify_release(key, data["account"])
return jsonify({"target": key, "account": data["account"],
"device": _spotify_name(key), **released})
# --- The original bridge's API, unchanged -----------------------------
@app.get("/targets")
@handle_errors
+12
View File
@@ -42,6 +42,11 @@ class _FakeAvr:
class DemoController:
# app.py checks this before anything that would touch real hardware
# over the network -- the speakers this stands in for may be switched
# on in the next room.
demo = True
def __init__(self, cfg):
self.cfg = cfg
self.avr = _FakeAvr()
@@ -150,8 +155,15 @@ class DemoController:
return [{"name": s["name"], "input_id": s["code"]} for s in self.avr.inputs()]
def play_heos_input(self, key, input_id, source_key=None):
# A room put on one of its own inputs is off Spotify, which is how
# the panel lets go of a room -- see app.py's _spotify_release.
if key in self._spotify:
self._spotify[key] = False
return None
def on_spotify(self, key):
return self._spotify.get(key, False)
# -- the AVR: app.py calls these directly, same as the real Controller
def avr_inputs(self):
return self.avr.inputs()
+66 -8
View File
@@ -25,6 +25,18 @@ class SpotifyError(RuntimeError):
"""Spotify answered, but said no (or never answered at all)."""
class SpotifyDeviceUnavailable(SpotifyError):
"""The room is not in this account's device list. Usually that is not a
fault at all: a Connect receiver is signed in to ONE Spotify account at
a time, and Spotify hides it from every other account until that one
lets go -- which only a caller that knows the other accounts can say,
hence the device name travelling with the error."""
def __init__(self, message, device_name):
super().__init__(message)
self.device_name = device_name
class SpotifyClient:
"""One access token, refreshed on demand, guarded by a lock the same
way HeosClient guards its socket."""
@@ -42,6 +54,8 @@ class SpotifyClient:
self.api_url = api_url or self.API_URL
self._access_token = None
self._expires_at = 0
self._scopes = set()
self._me = None
self._lock = threading.Lock()
# -- auth ------------------------------------------------------------
@@ -70,6 +84,10 @@ class SpotifyClient:
raise SpotifyError(f"Could not reach Spotify: {exc.reason}") from exc
self._access_token = payload["access_token"]
self._expires_at = time.time() + payload.get("expires_in", 3600)
# The only place Spotify ever says what this login is allowed to do:
# scopes are fixed when the refresh token is minted, so a feature
# needing one can ask instead of finding out the hard way.
self._scopes = set((payload.get("scope") or "").split())
return self._access_token
def _token(self) -> str:
@@ -119,6 +137,26 @@ class SpotifyClient:
whether or not anything is currently playing."""
return self._call("GET", "/v1/me/player/devices").get("devices", [])
def me(self) -> dict:
"""Who this refresh token belongs to. Its "id" is the canonical
username a speaker wants when signing the account in over the LAN
-- see spotify_zc.Speaker.add_user. Asked once: a refresh token
belongs to the one account for as long as it is a refresh token."""
if self._me is None:
self._me = self._call("GET", "/v1/me")
return self._me
def has_scope(self, scope: str) -> bool:
"""Whether this login carries a scope. Getting a token is what
makes Spotify tell us, so make sure we have one."""
self._token()
return scope in self._scopes
def access_token(self) -> str:
"""The current access token, for the one caller that has to hand it
somewhere other than our own requests: a speaker's zeroconf login."""
return self._token()
def playback(self) -> dict:
"""The account's current playback -- which device, and whether it is
playing -- or {} when the account has no playback session at all."""
@@ -131,15 +169,23 @@ class SpotifyClient:
devices = self.devices()
matches = [d for d in devices if d.get("name") == device_name]
if not matches:
visible = ", ".join(d.get("name", "?") for d in devices) or "none"
raise SpotifyError(
f"No Spotify Connect device named '{device_name}' is visible right now "
f"(Spotify sees: {visible}) -- is the room powered on, or does its "
"spotify_name in config.py need setting?")
names = [d.get("name", "?") for d in devices]
raise SpotifyDeviceUnavailable(
f"No Spotify Connect device named '{device_name}' is visible to this "
f"account right now (Spotify sees: {', '.join(names) or 'none'}) -- is "
"the room powered on, is it already connected to another Spotify "
"account, or does its spotify_name in config.json need setting?",
device_name)
device = matches[0]
self._call("PUT", "/v1/me/player", body={"device_ids": [device["id"]], "play": True})
return device
def pause(self):
"""Pause whatever this account is playing, on whichever device is
playing it. Spotify refuses this with a 403 when nothing is, so ask
playback() first rather than pausing on spec."""
self._call("PUT", "/v1/me/player/pause")
def seek(self, position_ms: int):
"""Jump to position_ms in whatever the account is playing, on
whichever device is playing it."""
@@ -147,9 +193,21 @@ class SpotifyClient:
def _error_detail(exc: urllib.error.HTTPError) -> str:
"""Whatever Spotify actually said, since a bare "Forbidden" sends you
nowhere. Most refusals come back as JSON, but some answer with a plain
sentence instead -- notably the 403 for an account the developer app
has not listed under User Management -- so fall through to the raw body
before giving up and quoting the status line."""
try:
with exc:
payload = json.loads(exc.read())
return payload.get("error_description") or payload.get("error", {}).get("message") or exc.reason
raw = exc.read()
except (ValueError, OSError):
raw = b""
try:
payload = json.loads(raw)
detail = payload.get("error_description") or payload.get("error", {}).get("message")
except (ValueError, AttributeError, KeyError):
return exc.reason
detail = None
# A body that is neither JSON nor a short sentence is some proxy's error
# page, not Spotify's -- keep enough of it to recognise, not all of it.
return detail or raw.decode(errors="replace").strip()[:200] or exc.reason
+344
View File
@@ -0,0 +1,344 @@
"""Spotify Connect over the LAN -- the channel the phone app uses, and the
only one that can move a speaker from one Spotify account to another.
The Web API (spotify.py) only ever sees a speaker already signed in to the
account doing the asking: to every other account Spotify answers "Device not
found", even handed the right device id, and nothing in that API signs a
speaker in or out. The speakers themselves advertise `_spotify-connect._tcp`
on the LAN, with a small HTTP endpoint -- getInfo, addUser, resetUsers -- and
that endpoint is what your phone talks to when you pick a room in the
Spotify app. Same speaker, same device id; a different door.
These Denons report `tokenType: accesstoken`, and what that means in
practice was worth measuring rather than assuming, because the older
`authBlob` protocol these devices descend from wraps its payload in a
Diffie-Hellman exchange and AES. This firmware does not: it takes the blob
literally. A correctly sealed blob is accepted with a cheerful 101 and then
never signs in; the token sent as plain text signs in immediately. The
clientKey parameter is required -- without it the POST is a 400 -- and is
then completely ignored: 96 random bytes work as well as a real public key.
So the token crosses the LAN in the clear, which is the protocol's doing
rather than a shortcut here, and there is no crypto in this file at all.
None of this is Spotify-documented -- it is the eSDK zeroconf protocol as
these speakers actually implement it.
"""
import base64
import json
import os
import socket
import struct
import time
import urllib.error
import urllib.parse
import urllib.request
SERVICE = b"_spotify-connect._tcp.local"
MDNS_GROUP = "224.0.0.251"
MDNS_PORT = 5353
# The length of the client key the device insists on -- see _client_key.
KEY_BYTES = 96
# How long to listen for announcements. Every speaker here answers within a
# quarter of a second, and find() stops at the first match anyway, so this
# is only the patience for a room that does not answer -- and mDNS
# responders ignore a question they just answered, which is the usual
# reason for that, so the second look matters more than a long first one.
DISCOVERY_WINDOW = 1.5
RETRY_AFTER = 1.5
class ZeroconfError(RuntimeError):
"""The speaker answered, but not with what we asked for."""
# --- finding the speakers ----------------------------------------------
def _local_address() -> str:
"""The address mDNS should go out of. Connecting a UDP socket sends
nothing; it just makes the kernel pick the route and tell us which
interface it chose."""
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
probe.connect((MDNS_GROUP, MDNS_PORT))
return probe.getsockname()[0]
finally:
probe.close()
def _encode_name(name: bytes) -> bytes:
return b"".join(bytes([len(part)]) + part for part in name.split(b".")) + b"\x00"
def _read_name(data: bytes, offset: int):
"""A DNS name, following the compression pointers that make one
impossible to read with a plain slice."""
parts = []
while True:
length = data[offset]
if length & 0xC0 == 0xC0:
pointer = struct.unpack("!H", data[offset:offset + 2])[0] & 0x3FFF
parts.append(_read_name(data, pointer)[0])
return b".".join(parts), offset + 2
offset += 1
if not length:
return b".".join(parts), offset
parts.append(data[offset:offset + length])
offset += length
def _mdns_socket(interface: str):
"""Joined to the group on 5353 where answers are broadcast. Something
else (avahi) may already hold that port, so fall back to an ephemeral
one and ask for unicast answers instead."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except (AttributeError, OSError):
pass
unicast = False
try:
sock.bind(("", MDNS_PORT))
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(MDNS_GROUP) + socket.inet_aton(interface))
except OSError:
sock.bind((interface, 0))
unicast = True
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(interface))
sock.settimeout(0.4)
return sock, unicast
def discover(timeout: float = DISCOVERY_WINDOW, interface: str = None):
"""Every Spotify Connect receiver announcing itself on the LAN, as
{"host", "port", "path"} -- yielded as each one answers rather than
collected up and handed back at the end, because they all reply within
a quarter of a second and a caller looking for one room should not have
to sit out the rest of the window.
A speaker that re-announces on a different port -- which is exactly
what it does after signing in or out -- comes round again as a second
entry rather than being swallowed as a duplicate of itself.
The name in the announcement is the HEOS one and the path comes from
its TXT record, so what a room is actually called to Spotify is
getInfo's business, not this function's."""
interface = interface or _local_address()
sock, unicast = _mdns_socket(interface)
try:
query = struct.pack("!6H", 0, 0, 1, 0, 0, 0) + _encode_name(SERVICE)
query += struct.pack("!2H", 12, 0x8001 if unicast else 1)
sock.sendto(query, (MDNS_GROUP, MDNS_PORT))
speakers, announced = {}, set()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
data, sender = sock.recvfrom(9000)
except socket.timeout:
continue
except OSError:
break
entry = _parse_answer(data)
if not entry:
continue
# SRV (the port) and TXT (the path) arrive in the same packet,
# so one is worth acting on as soon as it has a port at all.
speaker = speakers.setdefault(sender[0], {"host": sender[0], "path": "/zc"})
speaker.update(entry)
if "port" in speaker and (sender[0], speaker["port"]) not in announced:
announced.add((sender[0], speaker["port"]))
yield dict(speaker)
finally:
sock.close()
def _parse_answer(data: bytes) -> dict:
"""The port and TXT path out of one mDNS answer, if it is about us."""
try:
counts = struct.unpack("!6H", data[:12])
offset = 12
for _ in range(counts[2]):
_, offset = _read_name(data, offset)
offset += 4
entry = {}
for _ in range(sum(counts[3:])):
name, offset = _read_name(data, offset)
rtype, _cls, _ttl, length = struct.unpack("!HHIH", data[offset:offset + 10])
offset += 10
body, offset = data[offset:offset + length], offset + length
if SERVICE not in name.lower():
continue
if rtype == 33: # SRV: where to knock
entry["port"] = struct.unpack("!H", body[4:6])[0]
elif rtype == 16: # TXT: CPath=/zc
for field in _txt_fields(body):
if field.startswith(b"CPath="):
entry["path"] = field[6:].decode(errors="replace")
return entry
except (struct.error, IndexError):
return {} # not ours to read
def _txt_fields(body: bytes) -> list:
fields, offset = [], 0
while offset < len(body):
length = body[offset]
fields.append(body[offset + 1:offset + 1 + length])
offset += 1 + length
return fields
# --- talking to one speaker ---------------------------------------------
class Speaker:
"""One Connect receiver's zeroconf endpoint."""
def __init__(self, host: str, port: int, path: str = "/zc", timeout: float = 6.0):
self.host = host
self.port = port
self.path = path
self.timeout = timeout
def __repr__(self):
return f"Speaker({self.host}:{self.port}{self.path})"
@property
def url(self) -> str:
return f"http://{self.host}:{self.port}{self.path}"
def _call(self, params: dict, post: bool = False) -> dict:
body = urllib.parse.urlencode(params).encode()
try:
if post:
request = urllib.request.Request(
self.url, data=body, method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"})
else:
request = urllib.request.Request(f"{self.url}?{body.decode()}")
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read()
except (urllib.error.URLError, OSError) as exc:
raise ZeroconfError(f"{self.host} did not answer: {exc}") from exc
try:
return json.loads(raw)
except ValueError as exc:
raise ZeroconfError(
f"{self.host} answered something that is not JSON: "
f"{raw.decode(errors='replace')[:120]}") from exc
def info(self) -> dict:
"""What the speaker says it is: remoteName (the name Spotify shows,
which is what config.json's spotify_name has to match), deviceID
(the same id the Web API uses), publicKey, and the tokenType that
says how add_user has to talk to it."""
return self._call({"action": "getInfo"})
def add_user(self, username: str, access_token: str, device_name: str = "Heos panel") -> dict:
"""Sign this speaker in to an account, the way the phone does.
One POST: who, and a token to be them with. See the module
docstring for why the token is not encrypted -- this firmware would
not decrypt it if it were."""
details = self.info()
if details.get("tokenType") != "accesstoken":
raise ZeroconfError(
f"'{details.get('remoteName', self.host)}' wants a {details.get('tokenType')} "
"login, not an access token -- that is the older blob protocol, which needs "
"credentials a Web API token cannot produce.")
answer = self._call({
"action": "addUser",
"userName": username,
"blob": access_token,
"clientKey": self._client_key(),
"tokenType": "accesstoken",
"deviceName": device_name,
"version": details.get("version", "2.10.0"),
"loginId": "",
}, post=True)
# 101 is the speaker saying it took the request, not that the
# sign-in worked: it answers before it has talked to Spotify at
# all. Whether it worked is the caller's device list, a moment later.
if answer.get("status") != 101:
raise ZeroconfError(
f"'{details.get('remoteName', self.host)}' refused the login: "
f"{answer.get('statusString') or answer}")
return answer
def reset_users(self) -> dict:
"""Sign the speaker out of whatever account it is on. Leaves it
signed in to nobody -- so nobody's Web API can see it either, which
is why add_user, not this, is what hands a room over.
The receiver restarts afterwards and comes back on a *different*
port, so a Speaker held across this call is pointing at nothing:
find() it again rather than reusing this one."""
answer = self._call({"action": "resetUsers"}, post=True)
forget(self)
return answer
@staticmethod
def _client_key() -> str:
"""The device refuses a POST without one (HTTP 400) and signs in
perfectly well with random bytes, so that is what this is: the
shape of a public key, for a key exchange this firmware never
performs."""
return base64.b64encode(os.urandom(KEY_BYTES)).decode()
# Speakers found before, by the name Spotify knows them by. Worth keeping:
# an mDNS responder ignores a question it has just answered, so asking the
# network on every button press is slower *and* less reliable than knocking
# on the door that worked last time.
_known = {}
def _answers_to(speaker: Speaker, name: str, timeout: float = 1.0) -> bool:
"""Whether that endpoint is still this room. Its own short timeout: a
speaker that has been switched off does not refuse the connection, it
says nothing at all, and the point of this check is to be quick."""
probe = Speaker(speaker.host, speaker.port, speaker.path, timeout=timeout)
try:
return probe.info().get("remoteName") == name
except ZeroconfError:
return False
def forget(speaker: Speaker):
"""Drop any remembered endpoint pointing where this one does -- for
after something that restarts the receiver."""
for name, known in list(_known.items()):
if (known.host, known.port) == (speaker.host, speaker.port):
del _known[name]
def find(name: str, timeout: float = DISCOVERY_WINDOW, interface: str = None,
tries: int = 2) -> Speaker:
"""The speaker Spotify calls `name`, or None.
Tried in the cheap order: the endpoint that worked last time, confirmed
with one getInfo (some 20ms), and only then the network. Discovery
answers with HEOS names, which are not always the Spotify ones -- the
Living Room pair announces itself as "Denon Home 200 L" and calls
itself "Living Room" to Spotify -- so every candidate gets asked who it
is. And it goes round twice, because a responder that has just answered
somebody ignores the next question for about a second, which looks
exactly like a room that is not there."""
remembered = _known.get(name)
if remembered is not None and _answers_to(remembered, name):
return remembered
for attempt in range(tries):
if attempt:
time.sleep(RETRY_AFTER)
for candidate in discover(timeout=timeout, interface=interface):
speaker = Speaker(candidate["host"], candidate["port"], candidate.get("path", "/zc"))
try:
if speaker.info().get("remoteName") == name:
_known[name] = speaker
return speaker
except ZeroconfError:
continue # an endpoint that has moved on since it announced
_known.pop(name, None)
return None
+44 -2
View File
@@ -204,8 +204,14 @@ function paintRoom(room) {
// them, because the row also holds the host toggle, which stays put and
// merely disables.
els('.spotify', room.spotify).forEach((button) => {
const holding = button.dataset.account === room.spotifyAccount;
button.hidden = !room.available || room.grouped;
button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount));
button.setAttribute('aria-pressed', String(holding));
// Bordered, the button is the way back out of the room rather than into
// it, so its label has to say that and not "Resume".
button.setAttribute(
'aria-label',
`${holding ? 'Disconnect' : 'Resume'} ${button.querySelector('span').textContent}'s Spotify`);
});
room.toggle.disabled = !room.available;
@@ -592,7 +598,14 @@ function applyJoined(joined) {
receiver to resume that account, instead of always connecting to it
from the Spotify app -------------------------------------------------- */
els('.spotify').forEach((button) => {
button.addEventListener('click', () => resumeSpotify(button));
button.addEventListener('click', () => {
// The bordered button is the account already playing here, and resuming
// it again would do nothing, so that tap is the opposite request: let
// the room go, so another account can have it.
const room = rooms[button.dataset.target];
if (room && room.spotifyAccount === button.dataset.account) disconnectSpotify(button);
else resumeSpotify(button);
});
});
async function resumeSpotify(button) {
@@ -615,6 +628,35 @@ async function resumeSpotify(button) {
}
}
async function disconnectSpotify(button) {
const { target, account } = button.dataset;
const who = button.querySelector('span').textContent;
const room = rooms[target];
button.disabled = true;
try {
const data = await api('/api/spotify/disconnect', { target, account });
// The speaker not answering on the LAN is not a failure -- the room
// still stops -- but it does leave the account signed in to it, and
// that is the difference between "let go of" and "stopped".
toast(data.signed_out
? `${data.device} let go of ${who}'s Spotify`
: `Stopped ${who}'s Spotify on ${data.device} — the speaker did not answer, so it stays signed in`,
'ok');
// The room stops with it, so drop the border, the transport row and the
// song now rather than leaving a card that still claims to be playing;
// the refresh below confirms it.
room.spotifyAccount = null;
room.playState = 'stop';
room.track = null;
paintRoom(room);
setTimeout(refresh, 1500);
} catch (error) {
toast(error.message);
} finally {
button.disabled = false;
}
}
/* --- the input picker -------------------------------------------------- */
ui.inputButton.addEventListener('click', openSheet);
el('[data-role="scrim"]').addEventListener('click', closeSheet);
+34 -3
View File
@@ -1,6 +1,6 @@
"""Stand-in Spotify Web API: just enough of /api/token, .../player/devices,
.../player and .../player/seek to test spotify.py against, the same way fakes.py stands in
for real HEOS hardware."""
.../player, .../player/pause and .../player/seek to test spotify.py against, the same way
fakes.py stands in for real HEOS hardware."""
import json
import threading
@@ -23,8 +23,11 @@ class FakeSpotify(threading.Thread):
self.tokens_issued = 0
self.transfers = [] # every PUT /v1/me/player body
self.seeks = [] # every PUT /v1/me/player/seek's position_ms
self.pauses = 0 # how many PUT /v1/me/player/pause it took
self.reject_refresh = False # simulate a revoked refresh token
self.scope = "user-read-playback-state user-modify-playback-state"
self.player = None # GET /v1/me/player's body; None is no session (204)
self.forbid = None # a plain-text 403 body every API call answers with
fake = self
@@ -39,6 +42,19 @@ class FakeSpotify(threading.Thread):
if payload is not None:
self.wfile.write(json.dumps(payload).encode())
def _forbidden(self):
"""The real one refuses an account the developer app has not
listed with a bare sentence, not the usual JSON error object."""
if fake.forbid is None:
return False
body = fake.forbid.encode()
self.send_response(403)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return True
def _authorized(self):
header = self.headers.get("Authorization", "")
return header == f"Bearer {fake.valid_token}" and fake.valid_token is not None
@@ -51,11 +67,14 @@ class FakeSpotify(threading.Thread):
return
fake.tokens_issued += 1
fake.valid_token = f"token-{fake.tokens_issued}"
self._send(200, {"access_token": fake.valid_token, "expires_in": 3600})
self._send(200, {"access_token": fake.valid_token, "expires_in": 3600,
"scope": fake.scope})
return
self._send(404, {"error": {"message": "not found"}})
def do_PUT(self):
if self._forbidden():
return
if self.path == "/v1/me/player":
if not self._authorized():
self._send(401, {"error": {"message": "The access token expired"}})
@@ -65,6 +84,16 @@ class FakeSpotify(threading.Thread):
self.send_response(204)
self.end_headers()
return
if self.path == "/v1/me/player/pause":
if not self._authorized():
self._send(401, {"error": {"message": "The access token expired"}})
return
fake.pauses += 1
if fake.player is not None:
fake.player["is_playing"] = False
self.send_response(204)
self.end_headers()
return
if self.path.startswith("/v1/me/player/seek?"):
if not self._authorized():
self._send(401, {"error": {"message": "The access token expired"}})
@@ -83,6 +112,8 @@ class FakeSpotify(threading.Thread):
self._send(404, {"error": {"message": "not found"}})
def do_GET(self):
if self._forbidden():
return
if self.path == "/v1/me/player/devices":
if not self._authorized():
self._send(401, {"error": {"message": "The access token expired"}})
+34 -2
View File
@@ -9,7 +9,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from spotify import SpotifyClient, SpotifyError # noqa: E402
from spotify import SpotifyClient, SpotifyDeviceUnavailable, SpotifyError # noqa: E402
from tests.fake_spotify import FakeSpotify # noqa: E402
@@ -33,10 +33,33 @@ class SpotifyTest(unittest.TestCase):
self.assertEqual(self.fake.transfers, [{"device_ids": ["dev-1"], "play": True}])
def test_resume_raises_when_no_device_has_that_name(self):
with self.assertRaises(SpotifyError):
with self.assertRaises(SpotifyDeviceUnavailable):
self.client.resume("Kitchen")
self.assertEqual(self.fake.transfers, [])
def test_an_invisible_device_reports_what_it_looked_for(self):
# A room another Spotify account is signed in to is simply absent
# here, and app.py needs the name back to go and ask who has it.
with self.assertRaises(SpotifyDeviceUnavailable) as caught:
self.client.resume("Living Room")
self.assertEqual(caught.exception.device_name, "Living Room")
def test_it_knows_what_its_login_is_allowed_to_do(self):
# Signing a speaker in needs the "streaming" scope, and finding that
# out afterwards costs the room -- see app.py's _spotify_take_over.
self.fake.scope = "user-read-playback-state streaming"
self.assertTrue(self.client.has_scope("streaming"))
self.assertFalse(self.client.has_scope("user-modify-playback-state"))
def test_pause_stops_what_the_account_is_playing(self):
# What handing a room back starts with: the account keeps its place
# in the queue, so its own app -- or this panel's button -- can pick
# the stream up again somewhere else.
self.fake.player = {"device": {"id": "dev-1", "name": "Lego Room"}, "is_playing": True}
self.client.pause()
self.assertEqual(self.fake.pauses, 1)
self.assertFalse(self.client.playback()["is_playing"])
def test_seek_asks_for_the_position(self):
self.client.seek(90000)
self.assertEqual(self.fake.seeks, [90000])
@@ -60,6 +83,15 @@ class SpotifyTest(unittest.TestCase):
self.assertEqual(names, ["Lego Room", "Home Cinema"])
self.assertEqual(self.fake.tokens_issued, 2)
def test_a_plain_text_refusal_is_quoted_rather_than_flattened(self):
# The 403 for an account missing from the developer app's User
# Management is Spotify's only hint that that is what is wrong,
# and it arrives as prose rather than as a JSON error object.
self.fake.forbid = "The user is not registered for this application."
with self.assertRaises(SpotifyError) as caught:
self.client.devices()
self.assertIn("not registered for this application", str(caught.exception))
def test_a_revoked_refresh_token_raises_a_clear_error(self):
self.fake.reject_refresh = True
with self.assertRaises(SpotifyError):
+141
View File
@@ -0,0 +1,141 @@
"""The LAN half of Spotify Connect, against a stand-in for one speaker's
/zc endpoint. What the real ones do with these calls was measured rather
than assumed -- spotify_zc's module docstring says what and why.
python3 -m unittest discover -s tests -t .
"""
import base64
import json
import sys
import threading
import unittest
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from spotify_zc import KEY_BYTES, Speaker, ZeroconfError # noqa: E402
class FakeSpeaker(threading.Thread):
"""A speaker's /zc endpoint, as much of one as this needs -- including
its habit of answering 101 to a sign-in it has not tried yet."""
def __init__(self, token_type="accesstoken"):
super().__init__(daemon=True)
self.token_type = token_type
self.refuse = False
self.logins = []
self.resets = 0
fake = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _send(self, payload):
body = json.dumps(payload).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if parse_qs(urlparse(self.path).query).get("action") == ["getInfo"]:
self._send(fake.getinfo())
else:
self._send({"status": 301, "statusString": "ERROR-UNKNOWN"})
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
form = {k: v[0] for k, v in parse_qs(self.rfile.read(length).decode()).items()}
if form.get("action") == "addUser":
fake.logins.append(form)
if fake.refuse:
self._send({"status": 203, "statusString": "ERROR-INVALID-ARGUMENTS"})
else:
self._send({"status": 101, "statusString": "OK", "spotifyError": 0})
elif form.get("action") == "resetUsers":
fake.resets += 1
self._send({"status": 101, "statusString": "OK"})
else:
self._send({"status": 301, "statusString": "ERROR-UNKNOWN"})
self.server = HTTPServer(("127.0.0.1", 0), Handler)
self.port = self.server.server_port
self.start()
def getinfo(self):
return {
"status": 101, "statusString": "OK", "remoteName": "Living Room",
"deviceID": "8f190c21", "version": "2.10.0", "tokenType": self.token_type,
}
def run(self):
self.server.serve_forever(poll_interval=0.05)
def stop(self):
self.server.shutdown()
self.server.server_close()
class SpeakerTest(unittest.TestCase):
def setUp(self):
self.fake = FakeSpeaker()
self.addCleanup(self.fake.stop)
self.speaker = Speaker("127.0.0.1", self.fake.port, "/zc")
def test_info_reads_the_speaker_back(self):
self.assertEqual(self.speaker.info()["remoteName"], "Living Room")
def test_add_user_hands_over_the_token(self):
self.speaker.add_user("fifou", "BQD-token")
(login,) = self.fake.logins
self.assertEqual(login["userName"], "fifou")
self.assertEqual(login["tokenType"], "accesstoken")
# Plain, because that is the only shape these speakers act on: a
# sealed blob is taken and then never signed in with.
self.assertEqual(login["blob"], "BQD-token")
def test_add_user_sends_the_client_key_the_device_demands(self):
# Required -- a POST without one is a 400 -- and then never read,
# so all that matters is that something of the size is there.
self.speaker.add_user("fifou", "BQD-token")
self.assertEqual(len(base64.b64decode(self.fake.logins[0]["clientKey"])), KEY_BYTES)
def test_every_sign_in_brings_its_own_client_key(self):
self.speaker.add_user("fifou", "BQD-token")
self.speaker.add_user("fifou", "BQD-token")
first, second = (login["clientKey"] for login in self.fake.logins)
self.assertNotEqual(first, second)
def test_a_refused_sign_in_is_reported(self):
self.fake.refuse = True
with self.assertRaises(ZeroconfError) as caught:
self.speaker.add_user("fifou", "BQD-token")
self.assertIn("refused the login", str(caught.exception))
def test_reset_users_signs_it_out(self):
self.speaker.reset_users()
self.assertEqual(self.fake.resets, 1)
def test_an_older_speaker_says_so_rather_than_failing_obscurely(self):
# A device on the original blob protocol wants credentials a Web API
# token cannot produce, so there is no point sending it one.
self.fake.token_type = "authBlob"
with self.assertRaises(ZeroconfError) as caught:
self.speaker.add_user("fifou", "BQD-token")
self.assertIn("older blob protocol", str(caught.exception))
self.assertEqual(self.fake.logins, [])
def test_a_speaker_that_is_not_there_is_an_error_not_a_hang(self):
self.fake.stop()
with self.assertRaises(ZeroconfError):
self.speaker.info()
if __name__ == "__main__":
unittest.main()
+6 -1
View File
@@ -46,7 +46,12 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
REDIRECT_PORT = 8899
REDIRECT_URI = f"http://127.0.0.1:{REDIRECT_PORT}/callback"
SCOPES = "user-read-playback-state user-modify-playback-state"
# "streaming" is not for streaming anything here: it is the scope the
# speakers themselves ask for (getInfo says so) when this panel signs one
# in to an account over the LAN -- see spotify_zc.py. Without it a token
# can still see and steer a room the account already holds, but cannot
# take a room that another account is signed in to.
SCOPES = "user-read-playback-state user-modify-playback-state streaming"
def get_code(client_id: str, state: str) -> str: