This commit is contained in:
+344
@@ -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
|
||||
Reference in New Issue
Block a user