345 lines
14 KiB
Python
345 lines
14 KiB
Python
"""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
|