diff --git a/openpilot/starpilot/bluetooth/owner.py b/openpilot/starpilot/bluetooth/owner.py index 22616ee532..f69b39ecc4 100644 --- a/openpilot/starpilot/bluetooth/owner.py +++ b/openpilot/starpilot/bluetooth/owner.py @@ -253,7 +253,8 @@ class BlueZ: f"Bluetooth Device · {address[-5:]}" if relevant else address) devices.append({'path': path, 'address': address, 'name': str(name)[:80], 'paired': props.get('Paired') is True, 'connected': props.get('Connected') is True, - 'trusted': props.get('Trusted') is True}) + 'trusted': props.get('Trusted') is True, + 'audioSink': '0000110b-0000-1000-8000-00805f9b34fb' in profiles}) return devices def snapshot(self) -> dict: diff --git a/openpilot/starpilot/bluetooth/tests/test_owner.py b/openpilot/starpilot/bluetooth/tests/test_owner.py index 89a9771601..21529218fa 100644 --- a/openpilot/starpilot/bluetooth/tests/test_owner.py +++ b/openpilot/starpilot/bluetooth/tests/test_owner.py @@ -855,3 +855,13 @@ class BluetoothHeadUnitDiscoveryTest(unittest.TestCase): self.assertEqual({d['address'] for d in found}, {f'11:22:33:44:55:{i:02X}' for i in range(5)}) self.assertTrue(all(d['name'] for d in found)) self.assertTrue(all(not d['paired'] for d in found)) + + +class BluetoothAudioOutputTest(unittest.TestCase): + def test_only_audio_sink_profile_can_receive_music(self): + profiles = ['0000110B-0000-1000-8000-00805F9B34FB', '0000110a-0000-1000-8000-00805f9b34fb', + '4de17a00-52cb-11e6-bdf4-0800200c9a66', None] + objects = {f'/org/bluez/hci0/dev_{i}': {'org.bluez.Device1': { + 'Address': f'11:22:33:44:55:0{i}', 'Paired': True, 'UUIDs': [profile] if profile else [], + }} for i, profile in enumerate(profiles)} + self.assertEqual([device['audioSink'] for device in BlueZ._devices(objects)], [True, False, False, False]) diff --git a/openpilot/starpilot/galaxy/spotify_setup.py b/openpilot/starpilot/galaxy/spotify_setup.py index aee132a8cd..168288e2c8 100644 --- a/openpilot/starpilot/galaxy/spotify_setup.py +++ b/openpilot/starpilot/galaxy/spotify_setup.py @@ -90,7 +90,8 @@ class SpotifySetup: self._require_session(session) prefs = preferences.load(self.layout) try: - devices = [{'address': d['address'], 'name': d['name'], 'connected': d.get('connected') is True} + devices = [{'address': d['address'], 'name': d['name'], 'connected': d.get('connected') is True, + 'audioSink': d.get('audioSink') is True} for d in self.paired_devices(session) if d.get('paired')] except Exception: devices = [] diff --git a/openpilot/starpilot/galaxy/tests/test_spotify.mjs b/openpilot/starpilot/galaxy/tests/test_spotify.mjs index 5d48c2e04c..af12c1f505 100644 --- a/openpilot/starpilot/galaxy/tests/test_spotify.mjs +++ b/openpilot/starpilot/galaxy/tests/test_spotify.mjs @@ -144,3 +144,17 @@ assert.doesNotMatch(SpotifyPage.template, /Pause smoothing[^>]*parked/) } assert.equal(serviceLabel({ enabled: true, service: { state: "ready", player: { status: "paused" }, outputWaiting: "Car" } }), "Paused Audio waits for Car.") + +// Audio outputs require the receiving-music profile; the phone source list stays separate. +{ + const devices = [ + { address: "AA:BB:CC:DD:EE:01", name: "Car", connected: true, audioSink: true }, + { address: "AA:BB:CC:DD:EE:02", name: "Speaker", connected: false, audioSink: true }, + { address: "AA:BB:CC:DD:EE:03", name: "Phone", connected: true, audioSink: false }, + { address: "AA:BB:CC:DD:EE:04", name: "Radar detector", connected: true }, + ] + const deviceChoices = SpotifyPage.computed.deviceChoices.call({ setup: { devices } }) + const outputs = SpotifyPage.computed.outputChoices.call({ deviceChoices }) + assert.deepEqual(outputs.map(choice => choice.label), ["Car", "Speaker (Not Connected)"]) + assert.equal(deviceChoices.length, 4) +} diff --git a/openpilot/starpilot/galaxy/tests/test_spotify_setup.py b/openpilot/starpilot/galaxy/tests/test_spotify_setup.py index 6d257ac9f2..aeda176465 100644 --- a/openpilot/starpilot/galaxy/tests/test_spotify_setup.py +++ b/openpilot/starpilot/galaxy/tests/test_spotify_setup.py @@ -13,7 +13,7 @@ from openpilot.starpilot.soloist import credentials, preferences from openpilot.starpilot.soloist.paths import Layout SESSION = ('token', 1) -DEVICES = [{'address': 'AA:BB:CC:DD:EE:FF', 'name': 'My Car', 'paired': True, 'connected': True}, +DEVICES = [{'address': 'AA:BB:CC:DD:EE:FF', 'name': 'My Car', 'paired': True, 'connected': True, 'audioSink': True}, {'address': '11:22:33:44:55:66', 'name': 'Nearby', 'paired': False, 'connected': False}] @@ -58,7 +58,7 @@ def test_status_never_contains_the_key(owner): setup.set_key(SESSION, 'sk_live_0123456789abcdef') status = setup.status(SESSION) assert status['keyPresent'] is True and 'sk_live' not in json.dumps(status) - assert status['devices'] == [{'address': 'AA:BB:CC:DD:EE:FF', 'name': 'My Car', 'connected': True}] + assert status['devices'] == [{'address': 'AA:BB:CC:DD:EE:FF', 'name': 'My Car', 'connected': True, 'audioSink': True}] assert status['links']['download'].startswith('https://developer.spotify.com/') with pytest.raises(SetupRejected): setup.set_key(SESSION, 'bad key') diff --git a/openpilot/starpilot/galaxy/web/js/spotify.js b/openpilot/starpilot/galaxy/web/js/spotify.js index 063519870f..6f4ed70aac 100644 --- a/openpilot/starpilot/galaxy/web/js/spotify.js +++ b/openpilot/starpilot/galaxy/web/js/spotify.js @@ -346,8 +346,11 @@ export const SpotifyPage = { keyValid() { return /^[A-Za-z0-9._~+/=-]{16,256}$/.test(this.keyValue.trim()) }, nameValid() { return /^[^\x00-\x1f\x7f]{1,40}$/.test(this.nameValue.trim()) }, outputChoices() { + return this.deviceChoices.filter((choice) => choice.audioSink) + }, + deviceChoices() { const devices = this.setup?.devices || [] - return devices.map((device) => ({ value: device.address, label: device.name + (device.connected ? "" : " (Not Connected)") })) + return devices.map((device) => ({ value: device.address, audioSink: device.audioSink === true, label: device.name + (device.connected ? "" : " (Not Connected)") })) }, }, methods: { @@ -441,12 +444,13 @@ export const SpotifyPage = { +
Spotify streams on the comma. Your phone controls playback in Spotify; the comma sends the music to the audio output you choose below.
Pair your phone in the companion app, or tap Pair phone on the Bluetooth page. Then choose it below and turn on phone audio. On your phone, select the comma as the media audio output. No Spotify install or API key is needed.
Through Android Auto, or your car or a speaker over Bluetooth. Alerts keep playing from the comma itself.
Send music from the comma to your car speakers through StarPort (Android Auto), or to a Bluetooth car stereo or speaker. Alerts keep playing from the comma itself.
Only Bluetooth devices that support receiving music appear here. Pairing a phone or radar detector does not make it an audio output.
Put the car in Park or use offroad mode to change this.Music plays through the car while StarPort is connected, and the car shows it as Android Auto audio. If you switch the car to the radio, playback is paused when the source supports it.
-No paired devices yet. Pair your car or speaker on the Bluetooth page first.
+No paired Bluetooth audio outputs found. Pair your car stereo or speaker on the Bluetooth page first.
Don’t see it? Pair it on the Bluetooth page. In your car, choose Bluetooth Audio as the source.
Music plays through your car or speaker; alerts keep playing from the comma’s own speaker. Keep the music at a level where you can still hear the comma.
Yes. In step 3 choose Android Auto, and music plays through the car as Android Auto audio alongside the StarPilot screen. It needs Android Auto to be connected, so if it isn’t, music has nowhere to play.
+Yes. Under Send audio to, choose Car speakers via StarPort (Android Auto), and music plays through the car as Android Auto audio alongside the StarPilot screen. It needs Android Auto to be connected, so if it isn’t, music has nowhere to play.