mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
Spotify: Advertise across different networks better, Swap Devices More Seemlessly
This commit is contained in:
@@ -15,6 +15,7 @@ openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js -text
|
||||
*.apk -text
|
||||
openpilot/starpilot/galaxy/bin/hev-socks5-tunnel_linux_arm64 -text
|
||||
openpilot/starpilot/soloist/pulseaudio/bin/pulseaudio -text
|
||||
openpilot/starpilot/soloist/pulseaudio/bin/pactl -text
|
||||
|
||||
openpilot/selfdrive/car/tests/test_models_segs.txt -text
|
||||
openpilot/common/hardware/comma/updater -text
|
||||
|
||||
@@ -38,6 +38,8 @@ assert.equal(serviceLabel({ enabled: false }), "Off")
|
||||
assert.equal(serviceLabel({ enabled: true, service: { state: "waiting_output", message: "Waiting for Car to connect over Bluetooth." } }),
|
||||
"Waiting for Car to connect over Bluetooth.")
|
||||
assert.match(serviceLabel({ enabled: true, service: { state: "waiting", message: "" } }), /pick this device/)
|
||||
assert.match(serviceLabel({ enabled: true, service: { state: "waiting", message: "", outputWaiting: "Car" } }),
|
||||
/pick this device.*Audio waits for Car\./)
|
||||
assert.equal(serviceLabel({ enabled: true, service: { state: "ready", player: { status: "paused" } } }), "Paused")
|
||||
|
||||
assert.equal(positionNow({ ms: 1000, at: 10_000, speed: 1 }, 5000, 12_000), 3000)
|
||||
|
||||
@@ -99,7 +99,8 @@ export function serviceLabel(setup) {
|
||||
const service = setup.service
|
||||
if (!service) return "Starting…"
|
||||
if (service.state === "ready" && service.player?.status === "paused") return "Paused"
|
||||
return service.message && !["ready", "playing", "waiting"].includes(service.state) ? service.message : STATE_TEXT[service.state] || "Starting…"
|
||||
const text = service.message && !["ready", "playing", "waiting"].includes(service.state) ? service.message : STATE_TEXT[service.state] || "Starting…"
|
||||
return service.outputWaiting ? `${text} Audio waits for ${service.outputWaiting}.` : text
|
||||
}
|
||||
|
||||
// Where the track is now, from Soloist's anchor and the browser time elapsed since it.
|
||||
@@ -479,8 +480,11 @@ export const SpotifyPage = {
|
||||
<input type="range" class="gx-slider" min="0" max="100" step="1" :value="volumeDraft ?? player.volume" aria-label="Volume"
|
||||
@input="volumeDraft = Number($event.target.value)" @change="commitVolume" /></label>
|
||||
</div>
|
||||
<button v-if="setup.enabled && player?.loggedIn && !player.active" type="button" class="gx-btn gx-btn--tonal" @click="control('activate')">
|
||||
<i class="bi bi-broadcast" aria-hidden="true"></i> Play Here</button>
|
||||
<div v-if="setup.enabled && player?.loggedIn && !player.active" class="gx-stack">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="control('activate')">
|
||||
<i class="bi bi-broadcast" aria-hidden="true"></i> Move Spotify to {{ setup.preferences.deviceName || 'StarPilot' }}</button>
|
||||
<small class="gx-note">Takes over from the phone or speaker Spotify is playing on now. Music plays through {{ outputName }}. You might need to confirm the switch in the Spotify app.</small>
|
||||
</div>
|
||||
<GalaxySettingRow :row="switchRow('Spotify', setup.enabled, 'Turn off to stop playing and free up the comma. Your setup stays saved.')"
|
||||
:index="0" :busy="busy" :save-value="(index, value) => setEnabled(value === 'On')" />
|
||||
<div class="gx-actions">
|
||||
@@ -571,7 +575,7 @@ export const SpotifyPage = {
|
||||
<GalaxySettingRow :row="switchRow('Spotify', setup.enabled, 'Runs Spotify on your comma. Turn off to stop playing; your setup stays saved.')"
|
||||
:index="0" :busy="busy" :save-value="(index, value) => setEnabled(value === 'On')" />
|
||||
<p v-if="setup.enabled" class="gx-note" role="status">{{ statusText }}</p>
|
||||
<p class="gx-note">The first time, your phone has to be on the same Wi‑Fi or hotspot as your comma. After that, Spotify remembers it.</p>
|
||||
<p class="gx-note">Pair once on the same Wi‑Fi or hotspot. After pairing, keep Spotify enabled and both devices online; your comma can use cellular or a phone hotspot. Use the same Spotify account on your phone.</p>
|
||||
<form class="gx-stack" @submit.prevent="saveName">
|
||||
<label class="gx-stack"><span>Name in Spotify</span>
|
||||
<input class="gx-field" type="text" v-model="nameValue" maxlength="40" autocomplete="off" /></label>
|
||||
@@ -601,11 +605,17 @@ export const SpotifyPage = {
|
||||
</details>
|
||||
<details class="gx-aa-wiki"><summary>Spotify doesn’t show my comma</summary>
|
||||
<ol>
|
||||
<li>Make sure step 4 says <strong>Ready to pair</strong>.</li>
|
||||
<li>Keep Spotify enabled in step 4. A disconnected Bluetooth speaker can wait while you pair; music will be silent until it connects.</li>
|
||||
<li>Put your phone on the same Wi‑Fi or hotspot as your comma. Some hotspots and guest networks block devices from seeing each other.</li>
|
||||
<li>For cellular or different networks after pairing, turn off <strong>Local device visibility</strong> in Spotify → Settings → Apps and devices. Both devices need internet access.</li>
|
||||
<li>On iPhone, allow Spotify’s <strong>Local Network</strong> permission in iPhone Settings. If your phone’s hotspot blocks initial pairing, pair once on another shared Wi‑Fi network.</li>
|
||||
<li>If it still does not appear, turn Spotify off and back on here. This reconnects using your saved account; you do not need to clear the account or cache.</li>
|
||||
<li>In Spotify, tap the device icon and look for <strong>{{ setup?.preferences.deviceName || 'StarPilot' }}</strong>.</li>
|
||||
</ol>
|
||||
</details>
|
||||
<details class="gx-aa-wiki"><summary>Can I use the comma hotspot or wireless Android Auto?</summary>
|
||||
<p>The comma hotspot can provide the local connection for pairing, but the comma also needs internet access to stream. A wireless Android Auto connection alone does not guarantee Spotify discovery or internet access. After pairing, Spotify can use the saved account over the comma’s cellular connection.</p>
|
||||
</details>
|
||||
<details class="gx-aa-wiki"><summary>Does it use my data plan?</summary>
|
||||
<p>Yes, when your comma streams over cellular. Music usually uses about 40–150 MB an hour depending on quality. Songs you’ve played recently come from the cache instead.</p>
|
||||
</details>
|
||||
|
||||
@@ -4,7 +4,8 @@ Galaxy → Spotify and Phone Audio supports two mutually exclusive music sources
|
||||
Spotify Connect (Soloist) and a paired phone's Bluetooth A2DP audio. Both use the
|
||||
private PulseAudio output, either Android Auto's PCM media channel or an existing
|
||||
BlueALSA Bluetooth output. Phone mode needs neither Soloist nor a Spotify API key.
|
||||
The feature's existing enable flag starts `spotifyd` for either source.
|
||||
The feature's existing enable flag starts `spotifyd` for either source; with the phone source it
|
||||
never launches Soloist, and switching to it stops a running Soloist.
|
||||
|
||||
## Phone setup
|
||||
|
||||
@@ -82,3 +83,66 @@ artist and progress of Spotify or of the phone's Bluetooth player, with a backgr
|
||||
supervisor publishes the snapshot in its status file; the car screen reads it once a second only while the
|
||||
card is enabled, and the card stays hidden when nothing is playing or an alert is up. Phone metadata comes
|
||||
from the phone's Bluetooth media player (BlueZ `MediaPlayer1`), so it depends on the phone sharing it.
|
||||
|
||||
## Spotify Connect on hotspots and cellular
|
||||
|
||||
Enable Spotify and pair once from Spotify's device picker on a shared local network
|
||||
(home Wi-Fi, a phone hotspot, or the comma's Galaxy hotspot). Both local discovery
|
||||
and device-to-device traffic must be allowed. A hotspot-hosting phone may restrict
|
||||
this traffic; if initial pairing fails, use another shared Wi-Fi network. Wireless
|
||||
Android Auto by itself is not a promise of local discovery or an internet uplink.
|
||||
|
||||
Soloist keeps its device identity and Connect session in `state/` across restarts,
|
||||
output changes, network handoffs and cache clearing. After pairing, keep both devices
|
||||
online and use the same Spotify account. On the phone, disable Spotify's **Local
|
||||
device visibility** under Settings → Apps and devices to allow devices on other
|
||||
networks. On iPhone also grant Spotify **Local Network** access for local pairing.
|
||||
The comma needs its own usable internet connection (cellular or tethered internet
|
||||
is fine); the local Galaxy hotspot alone does not supply one.
|
||||
|
||||
The supervisor has no Wi-Fi, unmetered-network or ignition requirement, and it never
|
||||
restarts Soloist because the network changed. Soloist follows network changes itself:
|
||||
it binds one mDNS socket on `0.0.0.0:5353` and answers on interfaces that appear after
|
||||
it starts, because the device's avahi-daemon joins the mDNS group on every interface.
|
||||
Checked on the device 2026-10-20 with network namespaces: a running Soloist answered
|
||||
`_spotify-connect._tcp` queries on a link created after launch within ten seconds.
|
||||
|
||||
A disconnected selected Bluetooth speaker no longer prevents Soloist from starting.
|
||||
It uses a private silent sink so the device can be paired and remain online; Galaxy
|
||||
still shows the pairing state and adds that audio is waiting for the speaker. The
|
||||
supervisor checks the selected speaker every twenty seconds in the background, so that
|
||||
periodic check never blocks Galaxy commands. After two checks in a row agree that availability
|
||||
changed, it uses the vendored `pactl` to load the speaker (or silent) sink into the running audio
|
||||
server and move Soloist's stream onto it, so Soloist keeps its session, any pairing in progress,
|
||||
and playback. Soloist plays to the server's default sink rather than a named one, so later streams
|
||||
follow too. Only if `pactl` is missing or the switch fails does it restart the audio/player with
|
||||
the saved session.
|
||||
It never takes over playback from another Spotify device automatically.
|
||||
|
||||
The local API is polled for authentication state every fifteen seconds. A response
|
||||
missing for forty-five seconds reconnects that socket; two minutes without a working
|
||||
API restarts Soloist without clearing the account. A build that never answers at all
|
||||
counts as a failed start, so repeated silence still rolls back to the previous build.
|
||||
Loss of a previously observed login gets the same two-minute grace period, or ten
|
||||
minutes if it was playing just before (cached music can outlast a coverage gap); an
|
||||
initial pairing can wait indefinitely. A live API/login is not proof of internet reachability. If discovery still
|
||||
fails, switch Spotify off/on in Galaxy to refresh it without resetting the account.
|
||||
|
||||
These behaviors cannot override phone hotspot isolation, Spotify app permissions,
|
||||
cloud availability, expired Soloist builds, or a sleeping/powered-off comma.
|
||||
The optional control WebSocket stays on loopback; exposing it or the Galaxy tunnel
|
||||
is not how Spotify Connect discovers a device.
|
||||
|
||||
References checked 2026-10-20:
|
||||
- https://developer.spotify.com/documentation/soloist/concepts/authentication
|
||||
- https://developer.spotify.com/documentation/soloist/tutorials/getting-started
|
||||
- https://developer.spotify.com/documentation/soloist/reference/websocket-api
|
||||
- https://support.spotify.com/us/article/spotify-connect/
|
||||
|
||||
Hardware acceptance (not covered by unit tests): pair from Android and iPhone on
|
||||
home Wi-Fi and each phone's hotspot; pair through the Galaxy hotspot with a separate
|
||||
comma internet uplink; move a paired comma from Wi-Fi to cellular and IPv6 tethering;
|
||||
control it from a phone on a different network with Local device visibility off;
|
||||
restart the comma and confirm the saved account returns; connect/disconnect the
|
||||
selected Bluetooth speaker; verify availability after a tunnel/coverage outage.
|
||||
Confirm another Spotify player is never activated or interrupted by background recovery.
|
||||
|
||||
@@ -17,6 +17,8 @@ soundd and the warning speaker are untouched. Outputs:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -26,6 +28,7 @@ from openpilot.starpilot.soloist.preferences import ADDRESS, Preferences
|
||||
SERVER_ROOT = Path(__file__).resolve().parent / 'pulseaudio'
|
||||
PCM_NAME = 'starpilot_spotify'
|
||||
SINK_NAME = 'spotify'
|
||||
SILENT_SINK = 'spotify_silent'
|
||||
SYSTEM_ALSA_CONFIG = Path('/usr/share/alsa/alsa.conf')
|
||||
# The Android Auto media channel format every head unit must offer; the pipe runs on the system clock,
|
||||
# so it produces exactly real-time audio (silence when paused) whether or not anyone reads it.
|
||||
@@ -40,6 +43,10 @@ def bundle_ready(root: Path = SERVER_ROOT) -> bool:
|
||||
return os.access(root / 'bin' / 'pulseaudio', os.X_OK) and (root / 'modules').is_dir()
|
||||
|
||||
|
||||
def can_switch(root: Path = SERVER_ROOT) -> bool:
|
||||
return os.access(root / 'bin' / 'pactl', os.X_OK)
|
||||
|
||||
|
||||
def alsa_config(address: str, base: Path = SYSTEM_ALSA_CONFIG) -> str:
|
||||
"""Process-local ALSA config: the system configuration plus one named BlueALSA A2DP PCM."""
|
||||
if not ADDRESS.fullmatch(address):
|
||||
@@ -66,7 +73,17 @@ def android_auto_pipe(layout: Layout | None = None) -> Path:
|
||||
return runtime_paths(layout or Layout.default())['android_auto']
|
||||
|
||||
|
||||
def server_launch(layout: Layout, prefs: Preferences, *, alsa_base: Path = SYSTEM_ALSA_CONFIG, root: Path = SERVER_ROOT) -> Launch:
|
||||
def _sink(output: str, paths: dict[str, Path]) -> tuple[str, str]:
|
||||
if output == 'bluetooth':
|
||||
return 'module-alsa-sink', f'device={PCM_NAME} sink_name={SINK_NAME} channels=2 tsched=0'
|
||||
if output == 'android_auto':
|
||||
return 'module-pipe-sink', (f'file={paths["android_auto"]} sink_name={SINK_NAME} format=s16le rate={PIPE_RATE} ' +
|
||||
f'channels={PIPE_CHANNELS} use_system_clock_for_timing=yes')
|
||||
return 'module-null-sink', f'sink_name={SILENT_SINK if output == "silent" else SINK_NAME}'
|
||||
|
||||
|
||||
def server_launch(layout: Layout, prefs: Preferences, *, silent: bool = False, alsa_base: Path = SYSTEM_ALSA_CONFIG,
|
||||
root: Path = SERVER_ROOT) -> Launch:
|
||||
paths = runtime_paths(layout)
|
||||
for key in ('run', 'home', 'pulse'):
|
||||
private_dir(paths[key])
|
||||
@@ -77,16 +94,13 @@ def server_launch(layout: Layout, prefs: Preferences, *, alsa_base: Path = SYSTE
|
||||
'LD_LIBRARY_PATH': str(root / 'lib'), 'PATH': '/usr/bin:/bin',
|
||||
}
|
||||
if prefs.output == 'bluetooth':
|
||||
# Written even when starting silent, so the speaker can be added to the running server later.
|
||||
paths['alsa'].write_text(alsa_config(prefs.output_address, alsa_base))
|
||||
os.chmod(paths['alsa'], 0o600)
|
||||
env['ALSA_CONFIG_PATH'] = str(paths['alsa'])
|
||||
sink = f'module-alsa-sink device={PCM_NAME} sink_name={SINK_NAME} channels=2 tsched=0'
|
||||
elif prefs.output == 'android_auto':
|
||||
paths['android_auto'].unlink(missing_ok=True)
|
||||
sink = (f'module-pipe-sink file={paths["android_auto"]} sink_name={SINK_NAME} format=s16le rate={PIPE_RATE} ' +
|
||||
f'channels={PIPE_CHANNELS} use_system_clock_for_timing=yes')
|
||||
else:
|
||||
sink = f'module-null-sink sink_name={SINK_NAME}'
|
||||
sink = ' '.join(_sink('silent' if silent else prefs.output, paths))
|
||||
argv = [str(root / 'bin' / 'pulseaudio'), '--daemonize=no', '--system=no', '--use-pid-file=no', '-n',
|
||||
'--exit-idle-time=-1', '--realtime=no', '--high-priority=no', '--disallow-exit', '--log-target=stderr',
|
||||
'--log-level=notice', f'--dl-search-path={root / "modules"}',
|
||||
@@ -103,5 +117,30 @@ def player_launch(layout: Layout, prefs: Preferences, api_key: str) -> Launch:
|
||||
'--data-dir', str(layout.state), '--cache-dir', str(layout.cache), '--cache-size', str(prefs.cache_mb),
|
||||
'--initial-volume', str(prefs.initial_volume), '--ws', '127.0.0.1:0']
|
||||
env = {'HOME': str(paths['home']), 'XDG_RUNTIME_DIR': str(paths['run']), 'PATH': '/usr/bin:/bin',
|
||||
'PULSE_SERVER': f'unix:{paths["socket"]}', 'PULSE_SINK': SINK_NAME}
|
||||
'PULSE_SERVER': f'unix:{paths["socket"]}'}
|
||||
return Launch(argv, env)
|
||||
|
||||
|
||||
def _pactl(layout: Layout, root: Path, run: Callable[..., subprocess.CompletedProcess], *args: str) -> str:
|
||||
paths = runtime_paths(layout)
|
||||
env = {'HOME': str(paths['home']), 'XDG_RUNTIME_DIR': str(paths['run']), 'PATH': '/usr/bin:/bin',
|
||||
'PULSE_SERVER': f'unix:{paths["socket"]}'}
|
||||
return run([str(root / 'bin' / 'pactl'), *args], env=env, capture_output=True, text=True, timeout=5, check=True).stdout
|
||||
|
||||
|
||||
def switch_output(layout: Layout, speaker: bool, *, root: Path = SERVER_ROOT,
|
||||
run: Callable[..., subprocess.CompletedProcess] = subprocess.run) -> None:
|
||||
"""Move Soloist between the speaker and the silent sink inside the running server, without restarting it."""
|
||||
new, old = (SINK_NAME, SILENT_SINK) if speaker else (SILENT_SINK, SINK_NAME)
|
||||
module, args = _sink('bluetooth' if speaker else 'silent', runtime_paths(layout))
|
||||
|
||||
def pactl(*args: str) -> str:
|
||||
return _pactl(layout, root, run, *args)
|
||||
pactl('load-module', module, *args.split())
|
||||
pactl('set-default-sink', new)
|
||||
for line in pactl('list', 'short', 'sink-inputs').splitlines():
|
||||
if line.strip():
|
||||
pactl('move-sink-input', line.split()[0], new)
|
||||
for line in pactl('list', 'short', 'modules').splitlines():
|
||||
if f'sink_name={old}' in line.split():
|
||||
pactl('unload-module', line.split()[0])
|
||||
|
||||
@@ -20,6 +20,8 @@ ARTWORK_HOSTS = ('i.scdn.co', 'mosaic.scdn.co', 'image-cdn-ak.spotifycdn.com', '
|
||||
STATUSES = ('idle', 'playing', 'paused', 'buffering')
|
||||
REPEATS = ('off', 'context', 'track')
|
||||
ACTIONS = ('play', 'pause', 'skip_next', 'skip_prev', 'seek', 'shuffle', 'set_repeat')
|
||||
AUTH_POLL_SECONDS = 15.0
|
||||
AUTH_TIMEOUT_SECONDS = 45.0
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
@@ -161,8 +163,10 @@ def _connect(url: str):
|
||||
|
||||
|
||||
class PlayerClient:
|
||||
def __init__(self, data_dir: Path, *, connect: Callable[[str], Any] = _connect, on_change: Callable[[], None] = lambda: None):
|
||||
def __init__(self, data_dir: Path, *, connect: Callable[[str], Any] = _connect, on_change: Callable[[], None] = lambda: None,
|
||||
clock: Callable[[], float] = time.monotonic):
|
||||
self.data_dir, self.connect, self.on_change = data_dir, connect, on_change
|
||||
self.clock = clock
|
||||
self.state = PlayerState()
|
||||
self.lock = threading.Lock()
|
||||
self.outbox: queue.Queue[dict] = queue.Queue(maxsize=16)
|
||||
@@ -227,12 +231,20 @@ class PlayerClient:
|
||||
connection.settimeout(0.25)
|
||||
with self.lock:
|
||||
self.state.reset()
|
||||
self.state.value['connected'] = True
|
||||
while not self.outbox.empty(): # never replay commands from before a reconnect
|
||||
self.outbox.get_nowait()
|
||||
connection.send(json.dumps({'type': 'command', 'command': 'get_auth_state'}))
|
||||
pending_auth = self.clock()
|
||||
next_auth = pending_auth + AUTH_POLL_SECONDS
|
||||
asked_state = False
|
||||
while not self.stop_event.is_set():
|
||||
now = self.clock()
|
||||
if pending_auth is not None and now - pending_auth >= AUTH_TIMEOUT_SECONDS:
|
||||
raise TimeoutError('Soloist did not answer its local health check')
|
||||
if pending_auth is None and now >= next_auth:
|
||||
connection.send(json.dumps({'type': 'command', 'command': 'get_auth_state'}))
|
||||
pending_auth = now
|
||||
next_auth = now + AUTH_POLL_SECONDS
|
||||
try:
|
||||
while True:
|
||||
connection.send(json.dumps(self.outbox.get_nowait()))
|
||||
@@ -254,10 +266,16 @@ class PlayerClient:
|
||||
continue
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
if message.get('type') == 'auth_state':
|
||||
pending_auth = None
|
||||
with self.lock:
|
||||
self.state.apply(message)
|
||||
if message.get('type') == 'auth_state':
|
||||
self.state.value['connected'] = True
|
||||
logged_in = self.state.value['loggedIn']
|
||||
if logged_in and not asked_state:
|
||||
asked_state = True
|
||||
connection.send(json.dumps({'type': 'command', 'command': 'get_state'}))
|
||||
elif not logged_in:
|
||||
asked_state = False
|
||||
self.on_change()
|
||||
|
||||
@@ -3,4 +3,5 @@ cdb454b2f42c494dd19e9d097f0f6c3d57befe8e75c1a40c436959c219c6cf2885841f621dfe4050
|
||||
95e377f3c58f78d65a2e3bf151d6d47d9b6c66dc9a0810babbcd0a47d3a02f16d4522110f8ea92946ef35d004029dc19859a1c61192683377c233d9c5c608740 libsoxr0_0.1.3-4build3_arm64.deb
|
||||
422d97994b50973e5c4a2b07f7dede00ce15cb9a58fddb6d3e2d530686bb9cc01ee971de5a7b66a4c3ada15f735e6b4405fe22738e230b099bfdf1deb2ab0f24 libspeexdsp1_1.2.1-1ubuntu3_arm64.deb
|
||||
68345e041b86ca08a6b3be7a8f55abcda1a18537bc15b1f83be781638e53117380c93e52af506ba5aa2b5fc425f4d3dde623b86c3419d9ee3686512e81b8334c libtdb1_1.4.10-1build1_arm64.deb
|
||||
b871ab5448683f6bb57f898b857fa17a0a1f13b9011b7a0e73dc6f6fc22e47f90880eef99f2f9f94bccc24c8aa2b2f9c9d4d90658e0a4eb446e6964276b98f01 pulseaudio-utils_1%3a16.1+dfsg1-2ubuntu10.1_arm64.deb
|
||||
7fb6688c36b2d1de13d2f6ac0e45487ee61850fab983a0ee2112630d8e98c9f185ff246ae6e7d753a511bcea36936d48b7954ecdfdf040c91f8cf1661256d72b pulseaudio_1%3a16.1+dfsg1-2ubuntu10.1_arm64.deb
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
Spotify Soloist plays only through PulseAudio or PipeWire, and AGNOS ships PulseAudio's client library
|
||||
without a server. This directory is the minimal server needed to carry Soloist's audio to one output:
|
||||
the `pulseaudio` daemon, its private libraries, and four modules (native protocol, ALSA sink for
|
||||
the `pulseaudio` daemon, `pactl` (to switch between the speaker and the silent sink without
|
||||
restarting Soloist), their private libraries, and four modules (native protocol, ALSA sink for
|
||||
BlueALSA, null sink, and the real-time pipe sink used for Android Auto).
|
||||
|
||||
- Source: Ubuntu 24.04 (noble) arm64 binary packages, `pulseaudio 1:16.1+dfsg1-2ubuntu10.1` and the
|
||||
- Source: Ubuntu 24.04 (noble) arm64 binary packages, `pulseaudio` and `pulseaudio-utils`
|
||||
`1:16.1+dfsg1-2ubuntu10.1`, and the
|
||||
libraries listed in `PACKAGES.sha512` (SHA-512 of each `.deb` as published by Ubuntu).
|
||||
- The comma's own `libpulse0`/`libpulsecommon-16.1.so` is byte-identical to that Ubuntu build, which is
|
||||
why only these files are needed. Regenerate with `./update.sh`; `SHA256SUMS` lists the extracted files.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
b3b6955f8437125be03061b575757b255da00f0ac21d5079ee83257bac54326e bin/pactl
|
||||
c08fc5b6e751bb83af5fc65c052a317d88ed8d506c2ca53a813bfc0a22703c78 bin/pulseaudio
|
||||
b51278275776288a97699c06f0d377adba603b5e83edac1c7e9bf1891bc41e3e lib/libalsa-util.so
|
||||
0ad17dfc2ef3f6e322934da1cec6193bfd44ba89c75d77d1e409973cf7f11401 lib/libltdl.so.7
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,605 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: Pulseeaudio
|
||||
Upstream-Contact: General PulseAudio Discussion <pulseaudio-discuss@lists.freedesktop.org>
|
||||
Files-Excluded: src/modules/rtp/rfc*
|
||||
|
||||
Files: *
|
||||
Copyright:
|
||||
Copyright (C) 2004-2009 Lennart Poettering
|
||||
Copyright (C) 2006-2007 Pierre Ossman <ossman@cendio.se> for Cendio AB
|
||||
License: LGPL-2.1+
|
||||
The upstream license clarifies pretty well that the sources of pulseaudio are
|
||||
LGPL (please see LGPL license grant below), but that some parts will be
|
||||
effectively GPL since they rely on GPL libraries, quoting the upstream
|
||||
LICENSE:
|
||||
.
|
||||
"""All PulseAudio source files are licensed under the GNU Lesser General
|
||||
Public License. (see file LGPL for details)
|
||||
.
|
||||
However, the server side links to the GPL-only library 'libsamplerate'
|
||||
which practically downgrades the license of the server part to GPL (see
|
||||
file GPL for details), exercising section 3 of the LGPL.
|
||||
.
|
||||
Hence you should treat the client library ('libpulse') of PulseAudio as
|
||||
being LGPL licensed and the server part ('libpulsecore') as being GPL
|
||||
licensed. Since the PulseAudio daemon and the modules link to
|
||||
'libpulsecore' they are of course also GPL licensed.
|
||||
.
|
||||
-- Lennart Poettering, April 20th, 2006."""
|
||||
.
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/pulsecore/g711.c
|
||||
Copyright:
|
||||
Copyright (C) Sun Microsystems, Inc
|
||||
License: other
|
||||
This source code is a product of Sun Microsystems, Inc. and is provided
|
||||
for unrestricted use. Users may copy or modify this source code without
|
||||
charge.
|
||||
.
|
||||
SUN SOURCE CODE IS PROVIDED AS IS WITH NO WARRANTIES OF ANY KIND INCLUDING
|
||||
THE WARRANTIES OF DESIGN, MERCHANTIBILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE, OR ARISING FROM A COURSE OF DEALING, USAGE OR TRADE PRACTICE.
|
||||
.
|
||||
Sun source code is provided with no support and without any obligation on
|
||||
the part of Sun Microsystems, Inc. to assist in its use, correction,
|
||||
modification or enhancement.
|
||||
.
|
||||
SUN MICROSYSTEMS, INC. SHALL HAVE NO LIABILITY WITH RESPECT TO THE
|
||||
INFRINGEMENT OF COPYRIGHTS, TRADE SECRETS OR ANY PATENTS BY THIS SOFTWARE
|
||||
OR ANY PART THEREOF.
|
||||
.
|
||||
In no event will Sun Microsystems, Inc. be liable for any lost revenue
|
||||
or profits or other special, indirect and consequential damages, even if
|
||||
Sun has been advised of the possibility of such damages.
|
||||
|
||||
Files: src/pulsecore/g711.h
|
||||
Copyright:
|
||||
Copyright (C) 2001 Chris Bagwell
|
||||
License: other
|
||||
Permission to use, copy, modify, and distribute this software and its
|
||||
documentation for any purpose and without fee is hereby granted, provided
|
||||
that the above copyright notice appear in all copies and that both that
|
||||
copyright notice and this permission notice appear in supporting
|
||||
documentation. This software is provided "as is" without express or
|
||||
implied warranty.
|
||||
|
||||
Files: src/pulsecore/poll.*
|
||||
Copyright:
|
||||
Copyright 2006 Pierre Ossman <ossman@cendio.se> for Cendio AB
|
||||
Copyright (C)1994,96,97,98,99,2000,2001,2004 Free Software Foundation, Inc.
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/pulse/utf8.c
|
||||
Copyright:
|
||||
Copyright (C) 1999 Tom Tromey
|
||||
Copyright (C) 2000 Red Hat, Inc.
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/bluetooth/ipc.* src/modules/bluetooth/rtp.*
|
||||
Copyright:
|
||||
Copyright (C) 2004-2009 Marcel Holtmann <marcel@holtmann.org>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/bluetooth/module-bluetooth-device.c
|
||||
src/modules/bluetooth/module-bluetooth-discover.c
|
||||
src/modules/bluetooth/bluetooth-util.*
|
||||
Copyright:
|
||||
Copyright (C) 2008-2009 Joao Paulo Rechi Vita
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.
|
||||
|
||||
Files: src/modules/bluetooth/proximity-helper.c
|
||||
Copyright:
|
||||
Copyright (C) 2000-2001 Qualcomm Incorporated
|
||||
Copyright (C) 2002-2003 Maxim Krasnyansky <maxk@qualcomm.com>
|
||||
Copyright (C) 2002-2007 Marcel Holtmann <marcel@holtmann.org>
|
||||
License: GPL-2+
|
||||
On Debian systems, the complete text of the GPL-2 can be found in
|
||||
/usr/share/common-licenses/GPL-2.
|
||||
|
||||
Files: src/modules/bluetooth/sbc*
|
||||
Copyright:
|
||||
Copyright (C) 2004-2009 Marcel Holtmann <marcel@holtmann.org>
|
||||
Copyright (C) 2004-2005 Henryk Ploetz <henryk@ploetzli.ch>
|
||||
Copyright (C) 2005-2006 Brad Midgley <bmidgley@xmission.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/dbus/iface-client.c
|
||||
src/modules/dbus/iface-stream.c:
|
||||
Copyright:
|
||||
Copyright 2009 Tanu Kaskinen
|
||||
Copyright 2009 Vincent Filali-Ansary <filali.v@azurdigitalnetworks.net>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/dbus/module-dbus-protocol.c
|
||||
Copyright:
|
||||
Copyright 2009 Tanu Kaskinen
|
||||
Copyright 2006 Lennart Poettering
|
||||
Copyright 2006 Shams E. King
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/dbus/*
|
||||
Copyright:
|
||||
Copyright 2009 Tanu Kaskinen
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/echo-cancel/adrian*.*
|
||||
Copyright:
|
||||
Copyright (C) DFS Deutsche Flugsicherung (2004). All Rights Reserved.
|
||||
License: other
|
||||
You are allowed to use this source code in any open source or closed
|
||||
source software you want. You are allowed to use the algorithms for a
|
||||
hardware solution. You are allowed to modify the source code.
|
||||
You are not allowed to remove the name of the author from this memo or
|
||||
from the source code files. You are not allowed to monopolize the
|
||||
source code or the algorithms behind the source code as your
|
||||
intellectual property. This source code is free of royalty and comes
|
||||
with no warranty.
|
||||
|
||||
Files: src/modules/echo-cancel/echo-cancel.h
|
||||
Copyright:
|
||||
Copyright 2010 Arun Raghavan <arun.raghavan@collabora.co.uk>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/echo-cancel/module-echo-cancel.c
|
||||
Copyright:
|
||||
Copyright 2010 Wim Taymans <wim.taymans@gmail.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/echo-cancel/speex.c
|
||||
Copyright:
|
||||
Copyright 2010 Wim Taymans <wim.taymans@gmail.com>
|
||||
Contributor: Arun Raghavan <arun.raghavan@collabora.co.uk>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/jack/module-jackdbus-detect.c
|
||||
Copyright:
|
||||
Written by David Henningsson <david.henningsson@canonical.com>
|
||||
Copyright 2010 Canonical Ltd.
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/module-device-manager.*
|
||||
Copyright:
|
||||
Copyright 2006-2008 Lennart Poettering
|
||||
Copyright (C) 2009 Colin Guthrie
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/module-equalizer-sink.c
|
||||
Copyright:
|
||||
Copyright 2004-2008 Lennart Poettering
|
||||
Copyright 2009 Jason Newton <nevion@gmail.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/pulsecore/dbus-*.* src/modules/module-hal-detect.c
|
||||
Copyright:
|
||||
Copyright 2006 Lennart Poettering
|
||||
Copyright 2006 Shams E. King
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/ladspa.h
|
||||
Copyright:
|
||||
Copyright (C) 2000-2002 Richard W.E. Furse, Paul Barton-Davis, Stefan
|
||||
Westerfeld.
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/module-always-sink.c src/modules/rtp/rtsp_client.*
|
||||
Copyright:
|
||||
Copyright (C) 2008 Colin Guthrie
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/module-detect.c
|
||||
Copyright:
|
||||
Copyright 2006 Lennart Poettering
|
||||
Copyright 2006 Pierre Ossman <ossman@cendio.se> for Cendio AB
|
||||
Copyright 2006 Diego Petteno
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/roap/*roap* src/modules/rtp/headerlist.*
|
||||
Copyright:
|
||||
Copyright 2005-2007 Lennart Poettering
|
||||
Copyright (C) 2008 Colin Guthrie
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/roap/base64.*
|
||||
Copyright:
|
||||
Copyright (C) 2008 Colin Guthrie
|
||||
Copyright (C) Kungliga Tekniska Hogskolan
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/module-device-manager.c
|
||||
Copyright:
|
||||
Copyright 2006-2008 Lennart Poettering
|
||||
Copyright 2009 Colin Guthrie
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/module-filter-apply*.c
|
||||
src/modules/module-filter-heuristics*.c
|
||||
Copyright:
|
||||
Copyright 2011 Colin Guthrie
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/module-solaris.c
|
||||
Copyright:
|
||||
Copyright 2006 Lennart Poettering
|
||||
Copyright 2006-2007 Pierre Ossman <ossman@cendio.se> for Cendio AB
|
||||
Copyright 2009 Finn Thain
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/module-switch-on-connect.c
|
||||
Copyright:
|
||||
Copyright 2006 Lennart Poettering
|
||||
Copyright 2009 Canonical Ltd
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/module-virtual-sink.c
|
||||
src/modules/module-virtual-source.c
|
||||
Copyright:
|
||||
Copyright 2010 Intel Corporation
|
||||
Contributor: Pierre-Louis Bossart <pierre-louis.bossart@intel.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/modules/reserve.*
|
||||
Copyright:
|
||||
Copyright 2009 (C) Lennart Poettering
|
||||
License: other
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation files
|
||||
(the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
.
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
||||
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
||||
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Files: src/pulsecore/atomic.h
|
||||
Copyright:
|
||||
Copyright (C) 2006-2008 Lennart Poettering
|
||||
Copyright (C) 2008 Nokia Corporation
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/pulsecore/core-util.c
|
||||
Copyright:
|
||||
Copyright (C) 2004-2006 Lennart Poettering
|
||||
Copyright (C) 2004 Joe Marcus Clarke
|
||||
Copyright (C) 2006-2007 Pierre Ossman <ossman@cendio.se> for Cendio AB
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/pulse/ext-device-manager.*
|
||||
Copyright:
|
||||
Copyright (C) 2008 Lennart Poettering
|
||||
Copyright (C) 2009 Colin Guthrie
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/pulsecore/ffmpeg/avcodec.h
|
||||
Copyright:
|
||||
Copyright (c) 2001 Fabrice Bellard
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/pulsecore/ffmpeg/resample2.c
|
||||
Copyright:
|
||||
Copyright (c) 2004 Michael Niedermayer <michaelni@gmx.at>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/pulsecore/socket-util.c
|
||||
Copyright:
|
||||
Copyright (C) 2004-2006 Lennart Poettering
|
||||
Copyright (C) 2004 Joe Marcus Clarke
|
||||
Copyright (C) 2006-2007 Pierre Ossman <ossman@cendio.se> for Cendio AB
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: man/xmltoman
|
||||
Copyright:
|
||||
Copyright (C) 2000-2002 Oliver Kurth <oku@masqmail.cx>
|
||||
Copyright (C) 2003 Lennart Poettering <mzkzygbzna@0pointer.de>
|
||||
License: LGPL-2+
|
||||
On Debian systems, the complete text of the LGPL-2 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.
|
||||
|
||||
Files: po/ca.po
|
||||
Copyright:
|
||||
Copyright (C) 2008 Xavier Conde Rueda <xavi.conde@gmail.com>
|
||||
Copyright (C) 2009 Agustí Grau <fletxa@gmail.com>, 2009.
|
||||
Copyright (C) Judith Pintó Subirada <judithp@gmail.com>
|
||||
Copyright (C) 2009 Josep Torné Llavall <josep.torne@gmail.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/cs.po
|
||||
Copyright:
|
||||
Copyright (C) 2008,2009 Petr Kovar <pknbe@volny.cz>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/de.po
|
||||
Copyright:
|
||||
Copyright (C) 2008,2009 Fabian Affolter <fab@fedoraproject.org>
|
||||
Copyright (C) 2008,2009 Micha Pietsch <barney@fedoraproject.org>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/de_CH.po
|
||||
Copyright:
|
||||
Copyright (C) 2008, 2009 Fabian Affolter <fab@fedoraproject.org>
|
||||
Copyright (C) 2008, 2009 Micha Pietsch <barney@fedoraproject.org>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/el.po
|
||||
Copyright:
|
||||
Copyright (C) 2008 Dimitris Glezos <dimitris@glezos.com>
|
||||
Copyright (C) 2009 Thalia Papoutsaki <saliyath@gmail.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/es.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Domingo Becker <domingobecker@gmail.com>
|
||||
Copyright (C) 2008 Hector Daniel Cabrera <h.daniel.cabrera@gmail.com>
|
||||
Copyright (C) 2009 Fernando Gonzalez Blanco <fgonz@fedoraproject.org>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/fi.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Timo Jyrinki <timo.jyrinki@iki.fi>
|
||||
Copyright (C) 2009 Ville-Pekka Vainio <vpivaini@cs.helsinki.fi>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/fr.po
|
||||
Copyright:
|
||||
Copyright (C) 2008 Robert-Andre Mauchin <zebob.m@pengzone.org>
|
||||
Copyright (C) 2008 Michael Ughetto <telimektar esraonline com>
|
||||
Copyright (C) 2008 Pablo Martin-Gomez <pablo.martin-gomez@laposte.net>
|
||||
Copyright (C) 2009 Corentin Perard <corentin.perard@gmail.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/gu.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Sweta Kothari <swkothar@redhat.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/hi.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Rajesh Ranjan <rajesh672@gmail.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/it.po
|
||||
Copyright:
|
||||
Copyright (C) 2008,2009 Luca Ferretti <elle.uca@libero.it>
|
||||
Copyright (C) 2009 Milo Casagrande <milo@ubuntu.com>
|
||||
Copyright (C) 2009 ario_santagiuliana <mario at marionline.it>
|
||||
Copyright (C) 2009 Milo Casagrande <milo@ubuntu.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/ja.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Hyu_gabaru Ryu_ichi <hyu_gabaru@yahoo.co.jp>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/kn.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Shankar Prasad <svenkate@redhat.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/mr.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Sandeep Shedmake <sandeep.shedmake@gmail.com>
|
||||
Copyright (C) 2009 Sandeep Shedmake <sshedmak@redhat.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/nl.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Geert Warrink <geert.warrink@onsnet.nu>
|
||||
Copyright (C) 2009 Reinout van Schouwen <reinout@gmail.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/or.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Manoj Kumar Giri <mgiri@redhat.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/pa.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Amanpreet Singh Alam <aalam@users.sf.net>
|
||||
Copyright (C) 2009 Jaswinder Singh <jsingh@redhat.com>
|
||||
Copyright (C) 2009 A S Alam <aalam@users.sf.net>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/pl.po
|
||||
Copyright:
|
||||
Copyright (C) 2008 Piotr Drag <piotrdrag@gmail.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/pt_BR.po
|
||||
Copyright:
|
||||
Copyright (C) 2008 Fabian Affolter <fab@fedoraproject.org>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/sr.po po/sr@latin.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Igor Miletic (Игор Милетић) <grejigl-gnomeprevod@yahoo.ca>, 2009.
|
||||
Copyright (C) 2009 Miloš Komarčević <kmilos@gmail.com>, 2009.
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/sv.po
|
||||
Copyright:
|
||||
Copyright (C) 2008 Daniel Nylander <po@danielnylander.se>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/ta.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 I. Felix <ifelix@redhat.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/te.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Krishna Babu K <kkrothap@redhat.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/uk.po
|
||||
Copyright:
|
||||
Copyright (C) 2009 Yuri Chornoivan <yurchor@ukr.net>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: po/zh_CN.po
|
||||
Copyright:
|
||||
Copyright (C) 2008 闫丰刚 (sainry)<sainry@gmail.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: debian/*
|
||||
Copyright:
|
||||
Copyright 2006-2009 Sjoerd Simons <sjoerd@debian.org>
|
||||
Copyright 2006-2008 CJ van den Berg <cj@vdbonline.com>
|
||||
License: GPL-2+
|
||||
On Debian systems, the complete text of the GPL-2 can be found in
|
||||
/usr/share/common-licenses/GPL-2.
|
||||
|
||||
Files: src/pulsecore/cpu-* src/pulsecore/remap*
|
||||
src/pulsecore/svolume_mmx.c
|
||||
src/pulsecore/svolume_arm.c
|
||||
src/pulsecore/svolume_sse.c
|
||||
Copyright:
|
||||
Copyright 2004-2006 Lennart Poettering
|
||||
Copyright 2009 Wim Taymans <wim.taymans@collabora.co.uk>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/pulsecore/sconv_sse.c
|
||||
Copyright:
|
||||
Copyright 2004-2006 Lennart Poettering
|
||||
Copyright 2006 Pierre Ossman <ossman@cendio.se> for Cendio AB
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/pulsecore/usergroup.*
|
||||
Copyright:
|
||||
Copyright 2009 Ted Percival
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
|
||||
Files: src/utils/qpaeq
|
||||
Copyright: 2009 Jason Newton <nevion@gmail.com>
|
||||
License: LGPL-2.1+
|
||||
On Debian systems, the complete text of the LGPL-2.1 can be found in
|
||||
/usr/share/common-licenses/LGPL-2.1.
|
||||
@@ -8,16 +8,16 @@ docker run --rm --platform linux/arm64 -v "$PWD:/out" ubuntu:24.04 bash -euc '
|
||||
apt-get -qq update >/dev/null
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -qq install -y --no-install-recommends libpulse0 libasound2t64 libsndfile1 libwrap0 \
|
||||
libx11-xcb1 libgomp1 libasyncns0 libsystemd0 libudev1 libdbus-1-3 >/dev/null
|
||||
cd /tmp && apt-get download pulseaudio libltdl7 liborc-0.4-0t64 libsoxr0 libspeexdsp1 libtdb1 >/dev/null
|
||||
cd /tmp && apt-get download pulseaudio pulseaudio-utils libltdl7 liborc-0.4-0t64 libsoxr0 libspeexdsp1 libtdb1 >/dev/null
|
||||
sha512sum *.deb > /out/PACKAGES.sha512
|
||||
for deb in *.deb; do dpkg-deb -x "$deb" root; done
|
||||
R=/tmp/root; M=$R/usr/lib/pulse-16.1+dfsg1/modules
|
||||
rm -rf /out/bin /out/lib /out/modules /out/licenses && mkdir -p /out/bin /out/lib /out/modules /out/licenses
|
||||
cp $R/usr/bin/pulseaudio /out/bin/
|
||||
cp $R/usr/bin/pulseaudio $R/usr/bin/pactl /out/bin/
|
||||
for m in module-native-protocol-unix module-alsa-sink module-null-sink module-pipe-sink; do cp $M/$m.so /out/modules/; done
|
||||
export LD_LIBRARY_PATH=$R/usr/lib/aarch64-linux-gnu:$R/usr/lib/aarch64-linux-gnu/pulseaudio:$M
|
||||
for f in /out/bin/pulseaudio /out/modules/*.so; do ldd "$f"; done | awk "/=>/{print \$3}" | sort -u | grep "^$R" |
|
||||
for f in /out/bin/* /out/modules/*.so; do ldd "$f"; done | awk "/=>/{print \$3}" | sort -u | grep "^$R" |
|
||||
while read -r lib; do cp -L "$lib" /out/lib/; done
|
||||
for p in pulseaudio libltdl7 liborc-0.4-0t64 libsoxr0 libspeexdsp1 libtdb1; do cp $R/usr/share/doc/$p/copyright /out/licenses/$p.copyright; done
|
||||
chmod 755 /out/bin/pulseaudio && chmod 644 /out/lib/* /out/modules/* /out/licenses/*
|
||||
for p in pulseaudio pulseaudio-utils libltdl7 liborc-0.4-0t64 libsoxr0 libspeexdsp1 libtdb1; do cp -L $R/usr/share/doc/$p/copyright /out/licenses/$p.copyright; done
|
||||
chmod 755 /out/bin/* && chmod 644 /out/lib/* /out/modules/* /out/licenses/*
|
||||
cd /out && sha256sum bin/* lib/* modules/* > SHA256SUMS'
|
||||
|
||||
@@ -31,6 +31,8 @@ STOP_SECONDS = 5.0
|
||||
OUTPUT_RETRY_SECONDS = 20.0
|
||||
PAIRING_PROFILE_SECONDS = 180.0 # the pairing window plus time for the phone to save the comma's services
|
||||
MAX_FAST_FAILURES = 5
|
||||
UNRESPONSIVE_SECONDS = 120.0
|
||||
PLAYING_GRACE_SECONDS = 600.0
|
||||
LOG_LINES = 200
|
||||
EXPIRES_IN = re.compile(r'client expires in (\d+) days?')
|
||||
|
||||
@@ -128,6 +130,20 @@ class Supervisor:
|
||||
self.phone_track = None
|
||||
self.phone_profile = False
|
||||
self.pairing_profile_until = 0.0
|
||||
self.output_available = True
|
||||
self.output_check_at = 0.0
|
||||
self.output_lock = threading.Lock()
|
||||
self.output_seen: tuple[int, bool] | None = None
|
||||
self.output_generation = 0
|
||||
self.output_checking = False
|
||||
self.output_flips = 0
|
||||
self.waiting_for = ''
|
||||
self.run_async: Callable[[Callable[[], None]], None] = lambda fn: threading.Thread(
|
||||
target=fn, name='spotify_output_check', daemon=True).start()
|
||||
self.unresponsive_since: float | None = None
|
||||
self.authenticated_once = False
|
||||
self.answered = False
|
||||
self.last_playing = False
|
||||
|
||||
# ---- status ---------------------------------------------------------------------------------------
|
||||
def _set(self, state: str, message: str = '') -> None:
|
||||
@@ -146,7 +162,7 @@ class Supervisor:
|
||||
with self.log_lock:
|
||||
log = list(self.log)[-20:]
|
||||
return {'state': state, 'message': self.message, 'daysLeft': self.days_left, 'player': player,
|
||||
'nowPlaying': now_playing, 'log': log}
|
||||
'nowPlaying': now_playing, 'outputWaiting': self.waiting_for if self.player is not None else '', 'log': log}
|
||||
|
||||
def publish(self) -> None:
|
||||
try:
|
||||
@@ -177,6 +193,9 @@ class Supervisor:
|
||||
return process
|
||||
|
||||
def stop_children(self, *, release_phone: bool = True) -> None:
|
||||
self.unresponsive_since = None
|
||||
self.output_generation += 1
|
||||
self.output_seen, self.output_flips, self.waiting_for = None, 0, ''
|
||||
if self.client is not None:
|
||||
self.client.stop()
|
||||
self.client = None
|
||||
@@ -190,6 +209,45 @@ class Supervisor:
|
||||
if self.clock() >= self.pairing_profile_until:
|
||||
self._release_profile()
|
||||
|
||||
def _output_ready(self, address: str) -> bool:
|
||||
with self.output_lock:
|
||||
return self.output.ready(address)
|
||||
|
||||
def _output_changed(self, now: float, address: str) -> bool:
|
||||
seen, self.output_seen = self.output_seen, None
|
||||
if seen is not None and seen[0] == self.output_generation:
|
||||
self.output_flips = self.output_flips + 1 if seen[1] != self.output_available else 0
|
||||
if self.output_flips >= 2:
|
||||
return True
|
||||
if now >= self.output_check_at and not self.output_checking:
|
||||
self.output_check_at = now + OUTPUT_RETRY_SECONDS
|
||||
self.output_checking = True
|
||||
generation = self.output_generation
|
||||
|
||||
def check() -> None:
|
||||
try:
|
||||
self.output_seen = (generation, self._output_ready(address))
|
||||
finally:
|
||||
self.output_checking = False
|
||||
self.run_async(check)
|
||||
return False
|
||||
|
||||
def _switch_output(self, prefs: preferences.Preferences) -> bool:
|
||||
"""Move audio to or from the speaker inside the running server, so Soloist keeps its session."""
|
||||
speaker = not self.output_available
|
||||
if not backend.can_switch():
|
||||
return False
|
||||
try:
|
||||
backend.switch_output(self.layout, speaker)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
with self.log_lock:
|
||||
self.log.append(f'audio: could not switch output: {error}')
|
||||
return False
|
||||
self.output_available, self.output_flips = speaker, 0
|
||||
self.waiting_for = '' if speaker else prefs.output_name or 'your Bluetooth speaker'
|
||||
self.publish()
|
||||
return True
|
||||
|
||||
def _release_profile(self) -> None:
|
||||
if self.phone_profile:
|
||||
phone_audio.profile('release')
|
||||
@@ -225,7 +283,7 @@ class Supervisor:
|
||||
self._set('waiting_phone', f'Waiting for {prefs.phone_name or "your phone"}. Select the comma as its Bluetooth audio output.')
|
||||
self.retry_at = now + 5
|
||||
return
|
||||
if prefs.output == 'bluetooth' and not self.output.ready(prefs.output_address):
|
||||
if prefs.output == 'bluetooth' and not self._output_ready(prefs.output_address):
|
||||
self.stop_children(release_phone=False)
|
||||
self._set('waiting_output', f'Waiting for {prefs.output_name or "your speaker"} to connect.')
|
||||
self.retry_at = now + OUTPUT_RETRY_SECONDS
|
||||
@@ -295,19 +353,23 @@ class Supervisor:
|
||||
if self.release_target != target:
|
||||
self.release_target, self.fast_failures, self.retry_at = target, 0, 0.0
|
||||
if self.player is not None:
|
||||
if prefs.output == 'bluetooth' and self._output_changed(now, prefs.output_address):
|
||||
if not self._switch_output(prefs):
|
||||
self.stop_children()
|
||||
self.retry_at = 0.0
|
||||
return
|
||||
self._watch(now, target)
|
||||
return
|
||||
if now < self.retry_at:
|
||||
return
|
||||
if prefs.output == 'bluetooth' and not self.output.ready(prefs.output_address):
|
||||
name = prefs.output_name or 'your Bluetooth speaker'
|
||||
self._set('waiting_output', f'Waiting for {name} to connect over Bluetooth.')
|
||||
self.retry_at = now + OUTPUT_RETRY_SECONDS
|
||||
return
|
||||
self.output_available = prefs.output != 'bluetooth' or self._output_ready(prefs.output_address)
|
||||
self.output_check_at = now + OUTPUT_RETRY_SECONDS
|
||||
self.waiting_for = '' if self.output_available else prefs.output_name or 'your Bluetooth speaker'
|
||||
# A private null sink keeps Connect available until the speaker returns; never the warning speaker.
|
||||
self.secret = key
|
||||
self._set('starting', 'Starting Spotify…')
|
||||
try:
|
||||
self.server = self._start(backend.server_launch(self.layout, prefs), 'audio')
|
||||
self.server = self._start(backend.server_launch(self.layout, prefs, silent=not self.output_available), 'audio')
|
||||
deadline = now + 5.0
|
||||
socket_path = backend.runtime_paths(self.layout)['socket']
|
||||
while not socket_path.exists() and self.server.poll() is None and self.clock() < deadline:
|
||||
@@ -319,7 +381,7 @@ class Supervisor:
|
||||
self.stop_children()
|
||||
self._fail(now, f'Could not start Spotify: {error}')
|
||||
return
|
||||
self.launched, self.started_at = launch_key, now
|
||||
self.launched, self.started_at, self.answered, self.last_playing = launch_key, now, False, False
|
||||
self.client = self.make_client(self.layout.state)
|
||||
self.client.on_change = self.publish
|
||||
self.client.start()
|
||||
@@ -329,7 +391,29 @@ class Supervisor:
|
||||
code = self.player.poll() if self.player is not None else None
|
||||
server_dead = self.server is not None and self.server.poll() is not None
|
||||
if code is None and not server_dead:
|
||||
if now - self.started_at > 60:
|
||||
snapshot = self.client.snapshot() if self.client is not None else {}
|
||||
connected = bool(snapshot.get('connected'))
|
||||
self.answered = self.answered or connected
|
||||
if snapshot.get('loggedIn'):
|
||||
self.authenticated_once = True
|
||||
self.last_playing = bool(snapshot.get('active') and snapshot.get('status') in ('playing', 'buffering'))
|
||||
lost_login = connected and self.authenticated_once and not snapshot.get('loggedIn')
|
||||
# Logout clears the playback state, so cached music from before a coverage gap gets longer.
|
||||
limit = PLAYING_GRACE_SECONDS if lost_login and self.last_playing else UNRESPONSIVE_SECONDS
|
||||
if self.client is not None and (not connected or lost_login):
|
||||
if self.unresponsive_since is None:
|
||||
self.unresponsive_since = now
|
||||
elif now - self.unresponsive_since >= limit:
|
||||
self.stop_children()
|
||||
if not self.answered:
|
||||
self._fail(now, 'Spotify didn’t respond. Retrying…')
|
||||
return
|
||||
self.retry_at = now + 10
|
||||
self._set('retrying', 'Reconnecting Spotify with your saved account…')
|
||||
return
|
||||
else:
|
||||
self.unresponsive_since = None
|
||||
if self.answered and now - self.started_at > 60:
|
||||
self.fast_failures = 0
|
||||
return
|
||||
self.stop_children()
|
||||
@@ -393,6 +477,7 @@ class Supervisor:
|
||||
if command == 'reset_session':
|
||||
with self.lock:
|
||||
self.stop_children()
|
||||
self.authenticated_once = False
|
||||
shutil.rmtree(self.layout.state, ignore_errors=True)
|
||||
shutil.rmtree(self.layout.cache, ignore_errors=True)
|
||||
return {'ok': True}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.soloist import client, supervisor
|
||||
from openpilot.starpilot.soloist.tests.test_runtime import make_supervisor, ready_layout
|
||||
from openpilot.starpilot.soloist.paths import Layout
|
||||
|
||||
|
||||
def test_local_api_failure_recovers_without_rollback_or_erasing_login(tmp_path, monkeypatch):
|
||||
layout = Layout(tmp_path / 'soloist')
|
||||
ready_layout(layout, monkeypatch)
|
||||
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
||||
spawned = []
|
||||
sup, now = make_supervisor(layout, spawned)
|
||||
sup.tick()
|
||||
sup.tick() # the default fake answered once
|
||||
(layout.state / 'account').write_text('saved')
|
||||
sup.client.snapshot = lambda: {'connected': False}
|
||||
sup.tick()
|
||||
now[0] += 121
|
||||
sup.tick()
|
||||
assert sup.player is None and sup.fast_failures == 0
|
||||
now[0] += 11
|
||||
sup.tick()
|
||||
assert len(spawned) == 4
|
||||
assert (layout.state / 'account').read_text() == 'saved'
|
||||
|
||||
|
||||
def test_auth_heartbeat_refreshes_state_after_relogin(tmp_path):
|
||||
now = [0.0]
|
||||
player = client.PlayerClient(tmp_path, clock=lambda: now[0])
|
||||
events = [True, False, True]
|
||||
class Connection:
|
||||
sent = []
|
||||
def settimeout(self, _timeout): pass
|
||||
def send(self, message): self.sent.append(json.loads(message)['command'])
|
||||
def recv(self):
|
||||
if not events:
|
||||
player.stop_event.set()
|
||||
return ''
|
||||
now[0] += 16
|
||||
return json.dumps({'type': 'auth_state', 'logged_in': events.pop(0)})
|
||||
connection = Connection()
|
||||
player._session(connection)
|
||||
assert connection.sent.count('get_auth_state') >= 3
|
||||
assert connection.sent.count('get_state') == 2
|
||||
|
||||
|
||||
def test_unresponsive_socket_is_not_a_healthy_player(tmp_path):
|
||||
now = [0.0]
|
||||
player = client.PlayerClient(tmp_path, clock=lambda: now[0])
|
||||
class Connection:
|
||||
def settimeout(self, _timeout): pass
|
||||
def send(self, _message): pass
|
||||
def recv(self):
|
||||
now[0] += 16
|
||||
raise TimeoutError()
|
||||
with pytest.raises(TimeoutError, match='health check'):
|
||||
player._session(Connection())
|
||||
assert not player.snapshot()['connected']
|
||||
|
||||
|
||||
def test_lost_login_recovers_but_first_pairing_waits_without_restart(tmp_path, monkeypatch):
|
||||
layout = Layout(tmp_path / 'soloist')
|
||||
ready_layout(layout, monkeypatch)
|
||||
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
||||
spawned = []
|
||||
sup, now = make_supervisor(layout, spawned)
|
||||
sup.tick()
|
||||
for _ in range(5):
|
||||
now[0] += 200
|
||||
sup.tick()
|
||||
assert len(spawned) == 2 # unlimited time for first pairing
|
||||
sup.client.snapshot = lambda: {'connected': True, 'loggedIn': True}
|
||||
sup.tick()
|
||||
sup.client.snapshot = lambda: {'connected': True, 'loggedIn': False}
|
||||
sup.tick()
|
||||
now[0] += 121
|
||||
sup.tick()
|
||||
assert sup.player is None
|
||||
now[0] += 11
|
||||
sup.tick()
|
||||
assert len(spawned) == 4
|
||||
sup.handle({'command': 'reset_session'})
|
||||
assert not sup.authenticated_once
|
||||
|
||||
|
||||
def test_lost_login_while_playing_gets_a_longer_grace(tmp_path, monkeypatch):
|
||||
layout = Layout(tmp_path / 'soloist')
|
||||
ready_layout(layout, monkeypatch)
|
||||
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
||||
spawned = []
|
||||
sup, now = make_supervisor(layout, spawned)
|
||||
sup.tick()
|
||||
state = client.PlayerState()
|
||||
state.apply({'type': 'auth_state', 'logged_in': True, 'is_active': True})
|
||||
state.apply({'type': 'playback_changed', 'status': 'playing'})
|
||||
state.value['connected'] = True
|
||||
sup.client.snapshot = lambda: dict(state.value)
|
||||
sup.tick()
|
||||
state.apply({'type': 'auth_state', 'logged_in': False}) # the real client clears playback on logout
|
||||
for _ in range(5):
|
||||
now[0] += 100
|
||||
sup.tick()
|
||||
assert sup.player is not None and len(spawned) == 2
|
||||
now[0] += supervisor.PLAYING_GRACE_SECONDS
|
||||
sup.tick()
|
||||
assert sup.player is None
|
||||
|
||||
|
||||
def test_silent_build_counts_toward_rollback(tmp_path, monkeypatch):
|
||||
layout = Layout(tmp_path / 'soloist')
|
||||
ready_layout(layout, monkeypatch)
|
||||
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
||||
rolled = []
|
||||
monkeypatch.setattr(supervisor.package, 'rollback', lambda _l: rolled.append(True) or True)
|
||||
spawned = []
|
||||
sup, now = make_supervisor(layout, spawned)
|
||||
def silent(): return {'connected': False}
|
||||
for _ in range(supervisor.MAX_FAST_FAILURES):
|
||||
sup.tick()
|
||||
sup.client.snapshot = silent
|
||||
sup.tick()
|
||||
now[0] += supervisor.UNRESPONSIVE_SECONDS + 1
|
||||
sup.tick()
|
||||
assert sup.player is None
|
||||
now[0] += 120
|
||||
assert rolled == [True]
|
||||
|
||||
|
||||
def test_speaker_check_runs_off_the_tick(tmp_path, monkeypatch):
|
||||
layout = Layout(tmp_path / 'soloist')
|
||||
ready_layout(layout, monkeypatch)
|
||||
supervisor.preferences.save(supervisor.preferences.Preferences(output_address='AA:BB:CC:DD:EE:FF', output_name='Car'), layout)
|
||||
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
||||
class Output:
|
||||
calls = 0
|
||||
def ready(self, _address):
|
||||
self.calls += 1
|
||||
return self.calls % 2 == 0 # flaps on every check
|
||||
spawned, queued = [], []
|
||||
sup, now = make_supervisor(layout, spawned)
|
||||
sup.output = Output()
|
||||
sup.tick()
|
||||
sup.run_async = queued.append
|
||||
for _ in range(6):
|
||||
now[0] += supervisor.OUTPUT_RETRY_SECONDS + 1
|
||||
sup.tick()
|
||||
assert len(queued) <= 1 # the tick only queued the blocking check
|
||||
if queued:
|
||||
queued.pop()()
|
||||
assert len(spawned) == 2
|
||||
|
||||
|
||||
def test_phone_source_never_runs_soloist(tmp_path, monkeypatch):
|
||||
layout = Layout(tmp_path / 'soloist')
|
||||
ready_layout(layout, monkeypatch)
|
||||
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
||||
spawned = []
|
||||
sup, now = make_supervisor(layout, spawned)
|
||||
sup.tick()
|
||||
client = sup.client
|
||||
assert any('soloist' in process.argv[0] for process in spawned)
|
||||
supervisor.preferences.save(supervisor.preferences.Preferences(source='phone', output='test'), layout)
|
||||
spawned.clear()
|
||||
for _ in range(5):
|
||||
now[0] += 60
|
||||
sup.tick()
|
||||
assert client.stopped and sup.client is None
|
||||
assert not any('soloist' in process.argv[0] for process in spawned)
|
||||
assert sup.status()['state'] == 'no_phone'
|
||||
|
||||
|
||||
def test_phone_page_never_shows_the_spotify_speaker(tmp_path, monkeypatch):
|
||||
layout = Layout(tmp_path / 'soloist')
|
||||
ready_layout(layout, monkeypatch)
|
||||
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
||||
monkeypatch.setattr(supervisor.phone_audio, 'profile', lambda _action: None)
|
||||
supervisor.preferences.save(supervisor.preferences.Preferences(output_address='AA:BB:CC:DD:EE:FF', output_name='Car'), layout)
|
||||
class Output:
|
||||
connected = False
|
||||
def ready(self, _address): return self.connected
|
||||
class Phone:
|
||||
def powered(self): return True
|
||||
def ready(self, _address, _now): return True
|
||||
def track(self, _address): return None
|
||||
def close(self): pass
|
||||
spawned = []
|
||||
sup, now = make_supervisor(layout, spawned)
|
||||
sup.output, sup.phone = Output(), Phone()
|
||||
sup.tick()
|
||||
assert sup.status()['outputWaiting'] == 'Car'
|
||||
sup.output.connected = True
|
||||
supervisor.preferences.save(supervisor.preferences.Preferences(source='phone', phone_address='11:22:33:44:55:66',
|
||||
output_address='AA:BB:CC:DD:EE:FF', output_name='Car'), layout)
|
||||
now[0] += 1
|
||||
sup.tick()
|
||||
assert sup.player is not None and 'soloist' not in sup.player.argv[0]
|
||||
assert sup.status()['outputWaiting'] == ''
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -65,7 +66,10 @@ def test_launch_commands_isolate_the_server_and_player(layout):
|
||||
assert (layout.runtime / 'alsa.conf').stat().st_mode & 0o777 == 0o600
|
||||
test = backend.server_launch(layout, preferences.Preferences(output='test'))
|
||||
assert any(arg.startswith('module-null-sink') for arg in test.argv) and 'ALSA_CONFIG_PATH' not in test.env
|
||||
silent = backend.server_launch(layout, prefs, silent=True, alsa_base=Path('/usr/share/alsa/alsa.conf'))
|
||||
assert 'module-null-sink sink_name=spotify_silent' in silent.argv and 'ALSA_CONFIG_PATH' in silent.env
|
||||
player = backend.player_launch(layout, prefs, 'sk_live_0123456789abcdef')
|
||||
assert 'PULSE_SINK' not in player.env # follows the server's default sink, so outputs can switch live
|
||||
assert player.argv[player.argv.index('--ws') + 1] == '127.0.0.1:0'
|
||||
assert player.argv[player.argv.index('--cache-size') + 1] == '256'
|
||||
assert player.env['PULSE_SERVER'] == f'unix:{layout.runtime}/pulse/native'
|
||||
@@ -179,6 +183,7 @@ def make_supervisor(layout, spawned):
|
||||
return process
|
||||
now = [100.0]
|
||||
sup = supervisor.Supervisor(layout, spawn=spawn, clock=lambda: now[0], make_client=FakeClient) # ty: ignore[invalid-argument-type] # test double
|
||||
sup.run_async = lambda fn: fn()
|
||||
return sup, now
|
||||
|
||||
|
||||
@@ -247,25 +252,74 @@ def test_supervisor_backs_off_and_rolls_back_a_failing_build(layout, monkeypatch
|
||||
assert rolled == [True]
|
||||
|
||||
|
||||
def test_supervisor_waits_for_the_selected_bluetooth_output(layout, monkeypatch):
|
||||
def _speaker_supervisor(layout, monkeypatch, *, can_switch):
|
||||
ready_layout(layout, monkeypatch)
|
||||
preferences.save(preferences.Preferences(output_address='AA:BB:CC:DD:EE:FF', output_name='Car'), layout)
|
||||
class Output:
|
||||
connected = False
|
||||
def ready(self, address):
|
||||
return self.connected
|
||||
output = Output()
|
||||
switched = []
|
||||
monkeypatch.setattr(supervisor.backend, 'can_switch', lambda: can_switch)
|
||||
monkeypatch.setattr(supervisor.backend, 'switch_output', lambda _layout, speaker: switched.append(speaker))
|
||||
monkeypatch.setattr(supervisor, '_kill_group', lambda _p: None)
|
||||
spawned = []
|
||||
sup, now = make_supervisor(layout, spawned)
|
||||
sup.output = output
|
||||
sup.output = Output()
|
||||
sup.tick()
|
||||
assert sup.status()['state'] == 'waiting_output' and 'Car' in sup.status()['message'] and spawned == []
|
||||
output.connected = True
|
||||
now[0] += supervisor.OUTPUT_RETRY_SECONDS + 1
|
||||
assert sup.status()['state'] == 'waiting' and sup.status()['outputWaiting'] == 'Car'
|
||||
assert len(spawned) == 2 and sup.client.started
|
||||
assert 'module-null-sink sink_name=spotify_silent' in spawned[0].argv
|
||||
sup.output.connected = True
|
||||
for _ in range(2): # one check is not enough; the speaker has to stay connected
|
||||
now[0] += supervisor.OUTPUT_RETRY_SECONDS + 1
|
||||
sup.tick()
|
||||
assert len(spawned) == 2 and switched == []
|
||||
sup.tick()
|
||||
assert len(spawned) == 2
|
||||
return sup, now, spawned, switched
|
||||
|
||||
|
||||
def test_speaker_returning_switches_audio_without_restarting_soloist(layout, monkeypatch):
|
||||
sup, now, spawned, switched = _speaker_supervisor(layout, monkeypatch, can_switch=True)
|
||||
client = sup.client
|
||||
assert switched == [True] and len(spawned) == 2 and sup.client is client and not client.stopped
|
||||
assert sup.status()['outputWaiting'] == ''
|
||||
sup.output.connected = False
|
||||
for _ in range(3):
|
||||
now[0] += supervisor.OUTPUT_RETRY_SECONDS + 1
|
||||
sup.tick()
|
||||
assert switched == [True, False] and len(spawned) == 2
|
||||
assert sup.status()['outputWaiting'] == 'Car'
|
||||
|
||||
|
||||
def test_speaker_switch_falls_back_to_a_restart(layout, monkeypatch):
|
||||
sup, _now, spawned, switched = _speaker_supervisor(layout, monkeypatch, can_switch=False)
|
||||
(layout.state / 'saved-session').write_text('retained')
|
||||
sup.tick()
|
||||
assert switched == [] and len(spawned) == 4
|
||||
assert any(arg.startswith('module-alsa-sink') for arg in spawned[2].argv)
|
||||
assert (layout.state / 'saved-session').read_text() == 'retained'
|
||||
assert sup.status()['outputWaiting'] == ''
|
||||
|
||||
|
||||
def test_switch_output_moves_streams_and_drops_the_old_sink(layout):
|
||||
calls = []
|
||||
outputs = {('list', 'short', 'sink-inputs'): '7\t1\t3\tprotocol-native.c\ts16le 2ch 44100Hz\n',
|
||||
('list', 'short', 'modules'): ('0\tmodule-native-protocol-unix\tsocket=/x\n' +
|
||||
'1\tmodule-null-sink\tsink_name=spotify_silent\n' +
|
||||
'2\tmodule-alsa-sink\tdevice=starpilot_spotify sink_name=spotify channels=2 tsched=0\n')}
|
||||
def run(argv, **kwargs):
|
||||
calls.append(tuple(argv[1:]))
|
||||
assert kwargs['env']['PULSE_SERVER'].startswith('unix:') and kwargs['timeout'] and kwargs['check']
|
||||
return subprocess.CompletedProcess(argv, 0, outputs.get(tuple(argv[1:]), ''))
|
||||
backend.switch_output(layout, True, run=run)
|
||||
assert calls == [('load-module', 'module-alsa-sink', 'device=starpilot_spotify', 'sink_name=spotify', 'channels=2', 'tsched=0'),
|
||||
('set-default-sink', 'spotify'), ('list', 'short', 'sink-inputs'), ('move-sink-input', '7', 'spotify'),
|
||||
('list', 'short', 'modules'), ('unload-module', '1')]
|
||||
calls.clear()
|
||||
backend.switch_output(layout, False, run=run)
|
||||
assert calls[0] == ('load-module', 'module-null-sink', 'sink_name=spotify_silent') and calls[-1] == ('unload-module', '2')
|
||||
|
||||
def test_control_socket_round_trip(layout, tmp_path):
|
||||
import threading
|
||||
from openpilot.starpilot.galaxy.spotify_setup import SpotifyClient
|
||||
|
||||
Reference in New Issue
Block a user