mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""Galaxy setup owner for Spotify Soloist, modeled on the Android Auto package flow.
|
||
|
||
The HTTP server must authenticate the caller before invoking this owner. The API key is write-only:
|
||
no method here returns it. Configuration that changes the running player (install, output, account)
|
||
uses the shared connectivity authority (Park or offroad); playback controls work in any road state.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import shutil
|
||
import socket
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
from collections.abc import Callable
|
||
from pathlib import Path
|
||
from typing import BinaryIO
|
||
|
||
from openpilot.starpilot.soloist import backend, credentials, package, preferences
|
||
from openpilot.starpilot.soloist.phone_audio import A2DP_SOURCE
|
||
from openpilot.starpilot.soloist.paths import CONTROL_SOCKET, Layout, private_dir
|
||
|
||
LINKS = {
|
||
'download': package.DOWNLOAD_PAGE,
|
||
'key': 'https://developer.spotify.com/dashboard/soloist',
|
||
'help': 'https://developer.spotify.com/documentation/soloist/tutorials/getting-started',
|
||
}
|
||
|
||
|
||
class SetupRejected(RuntimeError):
|
||
pass
|
||
|
||
|
||
class SpotifyClient:
|
||
def __init__(self, path: str = CONTROL_SOCKET, timeout: float = 3.0):
|
||
self.path, self.timeout = path, timeout
|
||
|
||
@property
|
||
def available(self) -> bool:
|
||
return os.path.exists(self.path)
|
||
|
||
def call(self, command: str, **payload) -> dict:
|
||
if not self.available:
|
||
raise RuntimeError('Spotify isn’t running')
|
||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||
sock.settimeout(self.timeout)
|
||
sock.connect(self.path)
|
||
sock.sendall(json.dumps({'command': command, **payload}).encode() + b'\n')
|
||
response = bytearray()
|
||
while not response.endswith(b'\n') and len(response) < 1024 * 1024:
|
||
chunk = sock.recv(65536)
|
||
if not chunk:
|
||
break
|
||
response.extend(chunk)
|
||
result = json.loads(response) if response else {}
|
||
if not result.get('ok'):
|
||
raise RuntimeError(str(result.get('error') or 'Spotify did not respond'))
|
||
return result
|
||
|
||
|
||
class SpotifySetup:
|
||
def __init__(self, *, parked: Callable[[], bool], session_valid: Callable[[tuple], bool],
|
||
layout: Layout | None = None, client: SpotifyClient | None = None,
|
||
import_job: package.ImportJob | None = None,
|
||
paired_devices: Callable[[tuple], list[dict]] = lambda session: [],
|
||
clock: Callable[[], float] = time.monotonic):
|
||
self.parked, self.session_valid = parked, session_valid
|
||
self.layout = layout or Layout.default()
|
||
self.client = client or SpotifyClient()
|
||
self.job = import_job or package.ImportJob(self.layout)
|
||
self.paired_devices, self.clock = paired_devices, clock
|
||
self._upload_lock = threading.Lock()
|
||
|
||
def _require_session(self, session: tuple) -> None:
|
||
if not session or not self.session_valid(session):
|
||
raise SetupRejected('Galaxy session expired; sign in again')
|
||
|
||
def _require_parked(self, what: str) -> None:
|
||
if not self.parked():
|
||
raise SetupRejected(f'Put the car in Park or use offroad mode to {what}')
|
||
|
||
def _service(self) -> dict | None:
|
||
try:
|
||
return self.client.call('status')['status'] if self.client.available else None
|
||
except (OSError, RuntimeError, ValueError, KeyError):
|
||
return None
|
||
|
||
def status(self, session: tuple) -> dict:
|
||
self._require_session(session)
|
||
prefs = preferences.load(self.layout)
|
||
try:
|
||
devices = [{'address': d['address'], 'name': d['name'], 'connected': d.get('connected') is True}
|
||
for d in self.paired_devices(session) if d.get('paired')]
|
||
except Exception:
|
||
devices = []
|
||
return {
|
||
'enabled': self.layout.enabled_flag.exists(), 'parked': self.parked(),
|
||
'release': package.release_status(self.layout), 'keyPresent': credentials.present(self.layout),
|
||
'audioReady': backend.bundle_ready(),
|
||
'preferences': prefs.public(), 'devices': devices, 'service': self._service(),
|
||
'import': self.job.status(), 'maxUploadBytes': package.MAX_UPLOAD_BYTES, 'archiveName': package.ARCHIVE_NAME,
|
||
'cacheChoices': list(preferences.CACHE_CHOICES), 'links': LINKS,
|
||
'clock': int(time.time() * 1000), # noqa: TID251 lets the browser rebase epoch-ms play positions
|
||
}
|
||
|
||
def enable(self, session: tuple, value: bool) -> dict:
|
||
self._require_session(session)
|
||
if type(value) is not bool:
|
||
raise SetupRejected('Invalid Spotify setting')
|
||
preferences.set_enabled(value, self.layout)
|
||
return self.status(session)
|
||
|
||
def upload(self, session: tuple, source: BinaryIO, length: int) -> dict:
|
||
self._require_session(session)
|
||
self._require_parked('install Spotify')
|
||
if type(length) is not int or not 0 < length <= package.MAX_UPLOAD_BYTES:
|
||
raise SetupRejected('Invalid Soloist download size')
|
||
if not self._upload_lock.acquire(blocking=False):
|
||
raise SetupRejected('Another Soloist upload is running')
|
||
path: Path | None = None
|
||
started = False
|
||
try:
|
||
if self.job.busy():
|
||
raise SetupRejected('A Soloist install is running')
|
||
work_dir = private_dir(self.job.work_dir)
|
||
for stale in work_dir.glob('galaxy-upload-*'):
|
||
stale.unlink(missing_ok=True)
|
||
fd, name = tempfile.mkstemp(prefix='galaxy-upload-', suffix='.tar.gz', dir=work_dir)
|
||
path = Path(name)
|
||
with os.fdopen(fd, 'wb') as out:
|
||
remaining = length
|
||
while remaining:
|
||
self._require_session(session)
|
||
chunk = source.read(min(1024 * 1024, remaining))
|
||
if not chunk:
|
||
raise SetupRejected('Soloist upload ended early')
|
||
out.write(chunk)
|
||
remaining -= len(chunk)
|
||
out.flush()
|
||
os.fsync(out.fileno())
|
||
self._require_session(session)
|
||
self.job.start(path=path, enabled=lambda: self.session_valid(session), may_activate=self.parked)
|
||
started = True
|
||
return self.status(session)
|
||
finally:
|
||
if path is not None and not started:
|
||
path.unlink(missing_ok=True)
|
||
self._upload_lock.release()
|
||
|
||
def set_key(self, session: tuple, key: object) -> dict:
|
||
self._require_session(session)
|
||
try:
|
||
credentials.store(key, self.layout)
|
||
except credentials.InvalidKey as error:
|
||
raise SetupRejected(str(error)) from error
|
||
return self.status(session)
|
||
|
||
def save_preferences(self, session: tuple, payload: dict) -> dict:
|
||
self._require_session(session)
|
||
current = preferences.load(self.layout)
|
||
fields = {'deviceName': 'device_name', 'cacheMb': 'cache_mb', 'initialVolume': 'initial_volume',
|
||
'output': 'output', 'outputAddress': 'output_address', 'source': 'source', 'phoneAddress': 'phone_address',
|
||
'pauseMute': 'pause_mute', 'skipMute': 'skip_mute'}
|
||
if not isinstance(payload, dict) or not payload or set(payload) - set(fields):
|
||
raise SetupRejected('Invalid Spotify settings')
|
||
values = {**current.__dict__, **{fields[key]: value for key, value in payload.items()}}
|
||
changes_output = values['output'] != current.output or values['output_address'] != current.output_address
|
||
# Flipping between Spotify and the already chosen phone is a playback control; picking a different phone is setup.
|
||
if values['phone_address'] != current.phone_address:
|
||
self._require_parked('change the phone')
|
||
if values['source'] != current.source or values['phone_address'] != current.phone_address:
|
||
if values['phone_address']:
|
||
match = next((d for d in self.status(session)['devices'] if d['address'] == str(values['phone_address']).upper()), None)
|
||
if match is None:
|
||
raise SetupRejected('Pair your phone on the Bluetooth page first')
|
||
values['phone_name'] = match['name']
|
||
else:
|
||
values['phone_name'] = ''
|
||
if changes_output:
|
||
self._require_parked('change where music plays')
|
||
if values['output'] == 'bluetooth' and values['output_address']:
|
||
match = next((d for d in self.status(session)['devices'] if d['address'] == str(values['output_address']).upper()), None)
|
||
if match is None:
|
||
raise SetupRejected('Pair that speaker or car on the Bluetooth page first')
|
||
values['output_name'] = match['name']
|
||
else:
|
||
values['output_name'] = ''
|
||
try:
|
||
preferences.save(preferences.validated(values), self.layout)
|
||
except (ValueError, TypeError) as error:
|
||
raise SetupRejected(str(error)) from error
|
||
return self.status(session)
|
||
|
||
def prepare_phone_pairing(self) -> None:
|
||
"""Advertise the music receiver before a phone pairs, so the phone offers the comma as an audio output."""
|
||
if not (self.layout.enabled_flag.exists() and self.client.available):
|
||
return
|
||
try:
|
||
self.client.call('prepare_phone_pairing')
|
||
except (OSError, RuntimeError, ValueError):
|
||
pass
|
||
|
||
def adopt_phone(self, address: str, name: str, uuids: object) -> bool:
|
||
"""Offer a newly paired phone as the music source without overriding anything the owner chose."""
|
||
if not isinstance(uuids, (list, tuple)) or A2DP_SOURCE not in {str(value).lower() for value in uuids}:
|
||
return False
|
||
current = preferences.load(self.layout)
|
||
if current.phone_address:
|
||
return False
|
||
values = {**current.__dict__, 'phone_address': str(address).upper(), 'phone_name': str(name)}
|
||
if not package.release_status(self.layout).get('installed'):
|
||
values['source'] = 'phone'
|
||
try:
|
||
preferences.save(preferences.validated(values), self.layout)
|
||
except (ValueError, TypeError):
|
||
return False
|
||
return True
|
||
|
||
def control(self, session: tuple, action: object, args: object) -> dict:
|
||
self._require_session(session)
|
||
if not isinstance(action, str) or not isinstance(args, dict):
|
||
raise SetupRejected('Invalid Spotify control')
|
||
try:
|
||
self.client.call('control', action=action, args=args)
|
||
except (OSError, ValueError) as error:
|
||
raise SetupRejected('Spotify isn’t responding') from error
|
||
except RuntimeError as error:
|
||
raise SetupRejected(str(error)) from error
|
||
return {'ok': True}
|
||
|
||
def maintenance(self, session: tuple, action: str) -> dict:
|
||
self._require_session(session)
|
||
if action not in ('reset_session', 'clear_cache', 'remove_key'):
|
||
raise SetupRejected('Invalid Spotify action')
|
||
if action == 'remove_key':
|
||
credentials.remove(self.layout)
|
||
return self.status(session)
|
||
self._require_parked('change your Spotify account' if action == 'reset_session' else 'clear the cache')
|
||
if self.client.available:
|
||
try:
|
||
self.client.call(action)
|
||
except (OSError, RuntimeError, ValueError) as error:
|
||
raise SetupRejected('Spotify isn’t responding') from error
|
||
else:
|
||
shutil.rmtree(self.layout.cache, ignore_errors=True)
|
||
if action == 'reset_session':
|
||
shutil.rmtree(self.layout.state, ignore_errors=True)
|
||
return self.status(session)
|
||
|
||
def remove(self, session: tuple, *, wait: float = 12.0) -> dict:
|
||
"""Disable, wait for the manager to stop the player, then delete only Soloist-owned files."""
|
||
self._require_session(session)
|
||
with self._upload_lock:
|
||
if self.job.busy():
|
||
raise SetupRejected('Wait for the Soloist install to finish')
|
||
preferences.set_enabled(False, self.layout)
|
||
deadline = self.clock() + wait
|
||
while self.client.available and self.clock() < deadline:
|
||
time.sleep(0.25)
|
||
if self.client.available:
|
||
raise SetupRejected('Spotify is still stopping; try again in a moment')
|
||
from openpilot.starpilot.soloist.supervisor import reap_orphans
|
||
reap_orphans(self.layout)
|
||
shutil.rmtree(self.layout.root, ignore_errors=True)
|
||
return self.status(session)
|