Files
heos/spotify_zc.py
T
franzz 5ca504c680
Deploy HEOS panel / deploy (push) Successful in 26s
Fix account swaping
2026-09-18 02:05:48 +02:00

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