diff --git a/openpilot/starpilot/bluetooth/native_owner.py b/openpilot/starpilot/bluetooth/native_owner.py new file mode 100644 index 0000000000..ac19a71cd8 --- /dev/null +++ b/openpilot/starpilot/bluetooth/native_owner.py @@ -0,0 +1,71 @@ +"""Native settings use Galaxy when Android Auto already owns the Bluetooth adapter.""" +from openpilot.starpilot.bluetooth.owner import AdapterBusyElsewhere, BluetoothOwner, BluetoothRejected, BluetoothUnavailable + + +class NativeBluetoothOwner(BluetoothOwner): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.galaxy = None + self.delegated_pairing = False + self.delegated_scan = False + + def _galaxy(self, operation, **kwargs): + if self.galaxy is None: + from openpilot.starpilot.galaxy_bluetooth.phone_ui import galaxy_call + self.galaxy = galaxy_call() # Keep the same authenticated session for prompts and polling. + if operation == 'status': + status, result = self.galaxy('GET', '/api/bluetooth/status') + else: + payload = {'operation': operation} + for key, value in kwargs.items(): + if key in ('address', 'enabled', 'prompt_id', 'accepted') and value is not None: + payload['promptId' if key == 'prompt_id' else key] = value + if operation == 'pairing_response': + payload['value'] = kwargs.get('value', '') + status, result = self.galaxy('POST', '/api/bluetooth/action', payload) + if status == 409: + raise BluetoothRejected('Bluetooth change was not accepted', code=result.get('code', 'busy')) + if status != 200 or not isinstance(result.get('devices'), list): + raise BluetoothUnavailable('Galaxy Bluetooth service is unavailable') + if operation in ('pair', 'accept_pair'): + self.delegated_pairing = True + elif operation == 'cancel_pair': + self.delegated_pairing = False + if operation == 'scan': + self.delegated_scan = True + elif operation in ('stop_scan', 'pair', 'accept_pair'): + self.delegated_scan = False + return result + + def request(self, operation, *, delegate=True, **kwargs): + session = kwargs.get('session') + if session is not None and not self.session_valid(session): + raise BluetoothRejected('Pairing session expired', code='session_expired') + if self.galaxy is None: + try: + return super().request(operation, **kwargs) + except AdapterBusyElsewhere: + if not delegate: + return super().snapshot(session=session) + elif not delegate: + return self.snapshot(session=session) + return self._galaxy(operation, **kwargs) + + def snapshot(self, *, session=None): + if self.galaxy is not None: + return self._galaxy('status') + return super().snapshot(session=session) + + def close(self): + try: + if self.galaxy is not None: + for operation, needed in (('cancel_pair', self.delegated_pairing), ('stop_scan', self.delegated_scan)): + if needed: + try: + self._galaxy(operation) + except (BluetoothRejected, BluetoothUnavailable, OSError, RuntimeError): + pass # The daemon also bounds scans and cancels expired pairing sessions. + finally: + self.galaxy = None + self.delegated_pairing = self.delegated_scan = False + super().close() diff --git a/openpilot/starpilot/bluetooth/owner.py b/openpilot/starpilot/bluetooth/owner.py index 41b0b28a19..03d41e266e 100644 --- a/openpilot/starpilot/bluetooth/owner.py +++ b/openpilot/starpilot/bluetooth/owner.py @@ -174,6 +174,11 @@ class BluetoothRejected(Exception): self.code = code +class AdapterBusyElsewhere(BluetoothRejected): + def __init__(self, message: str): + super().__init__(message, code='busy') + + def normalized_address(value: str) -> str: if not isinstance(value, str) or not ADDRESS.fullmatch(value.upper()): raise ValueError('Invalid Bluetooth address') @@ -577,7 +582,7 @@ class BluetoothOwner: fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: stream.close() - raise BluetoothRejected('Bluetooth adapter is busy in another process', code='busy') from None + raise AdapterBusyElsewhere('Bluetooth adapter is busy in another process') from None self.admission_file = stream def _admission_busy(self) -> bool: @@ -870,7 +875,9 @@ class BluetoothOwner: raise BluetoothRejected('Pairing changed') self.pairing.cancel() return self.snapshot(session=session) - if operation not in ('stop_scan', 'accept_pair'): + if operation != 'stop_scan': + # An unclaimed window gives way to everything, including a new window, so reopening Pair phone never + # waits out the old one. A window a phone has already claimed still blocks (below). self._end_idle_window() if self.pairing is not None and self.pairing.state == 'pairing' and operation != 'stop_scan': raise BluetoothRejected('Pairing is in progress', code='busy') diff --git a/openpilot/starpilot/bluetooth/tests/test_accept_pair.py b/openpilot/starpilot/bluetooth/tests/test_accept_pair.py index 1a0a8bb935..4999f77c9b 100644 --- a/openpilot/starpilot/bluetooth/tests/test_accept_pair.py +++ b/openpilot/starpilot/bluetooth/tests/test_accept_pair.py @@ -259,6 +259,20 @@ class AcceptPairOwnerTest(unittest.TestCase): self.assertEqual(self.paired, []) self.request_when_free('stop_scan', session=self.identity) + def test_unclaimed_window_gives_way_to_a_new_window(self): + self.request_when_free('accept_pair', session=self.identity) + self.wait_for_claim() + first = self.owner.pairing + assert first is not None + first.path = '' # no phone has claimed it + self.request_when_free('accept_pair', session=self.identity) + self.assertTrue(first.done.is_set()) + self.assertIsNot(self.owner.pairing, first) + assert self.owner.pairing is not None + self.assertEqual(self.owner.pairing.state, 'pairing') + self.request_when_free('cancel_pair', session=self.identity) + self.assertTrue(self.owner.pairing.done.wait(2)) + def test_declined_or_canceled_window_does_not_report_a_phone(self): self.owner.request('accept_pair', session=self.identity) self.wait_for_claim() diff --git a/openpilot/starpilot/bluetooth/tests/test_native_owner.py b/openpilot/starpilot/bluetooth/tests/test_native_owner.py new file mode 100644 index 0000000000..94c2b2b8d9 --- /dev/null +++ b/openpilot/starpilot/bluetooth/tests/test_native_owner.py @@ -0,0 +1,76 @@ +from unittest.mock import Mock, patch + +import pytest + +from openpilot.starpilot.bluetooth.native_owner import NativeBluetoothOwner +from openpilot.starpilot.bluetooth.owner import AdapterBusyElsewhere, BluetoothOwner, BluetoothRejected + + +@pytest.mark.parametrize('operation, fields', [ + ('scan', {}), ('stop_scan', {}), ('pair', {'address': 'AA:BB:CC:DD:EE:FF', 'session': ('large', 'one')}), + ('connect', {'address': 'AA:BB:CC:DD:EE:FF'}), ('disconnect', {'address': 'AA:BB:CC:DD:EE:FF'}), + ('forget', {'address': 'AA:BB:CC:DD:EE:FF'}), ('power', {'enabled': False}), +]) +def test_busy_native_owner_delegates_all_controls(operation, fields): + owner = NativeBluetoothOwner(lambda: True) + call = Mock(return_value=(200, {'devices': []})) + with patch.object(BluetoothOwner, 'request', side_effect=AdapterBusyElsewhere('AA owns radio')), \ + patch('openpilot.starpilot.galaxy_bluetooth.phone_ui.galaxy_call', return_value=call) as factory: + assert owner.request(operation, **fields) == {'devices': []} + payload = {'operation': operation, **{k: v for k, v in fields.items() if k != 'session'}} + call.assert_called_once_with('POST', '/api/bluetooth/action', payload) + owner.snapshot(session=('large', 'one')) + call.assert_called_with('GET', '/api/bluetooth/status') + factory.assert_called_once() + + +def test_pairing_poll_response_and_close_use_the_same_galaxy_session(): + owner = NativeBluetoothOwner(lambda: True) + call = Mock(return_value=(200, {'devices': [], 'pairing': {'state': 'pairing'}})) + with patch.object(BluetoothOwner, 'request', side_effect=AdapterBusyElsewhere('busy')) as local, \ + patch('openpilot.starpilot.galaxy_bluetooth.phone_ui.galaxy_call', return_value=call) as factory: + owner.request('pair', address='AA:BB:CC:DD:EE:FF', session=('large', 'one')) + owner.snapshot(session=('large', 'one')) + owner.request('pairing_response', prompt_id='a' * 32, accepted=True, value='', session=('large', 'one')) + call.assert_called_with('POST', '/api/bluetooth/action', + {'operation': 'pairing_response', 'promptId': 'a' * 32, 'accepted': True, 'value': ''}) + owner.close() + call.assert_called_with('POST', '/api/bluetooth/action', {'operation': 'cancel_pair'}) + local.assert_called_once() + factory.assert_called_once() + assert owner.galaxy is None + + +@pytest.mark.parametrize('error', [BluetoothRejected('park first', code='park_required'), + BluetoothRejected('Pairing is in progress', code='busy')]) +def test_local_denials_never_switch_to_galaxy(error): + owner = NativeBluetoothOwner(lambda: True) + with patch.object(BluetoothOwner, 'request', side_effect=error), \ + patch('openpilot.starpilot.galaxy_bluetooth.phone_ui.galaxy_call') as factory: + with pytest.raises(BluetoothRejected): + owner.request('scan') + factory.assert_not_called() + assert owner.galaxy is None + + +def test_delegated_page_open_never_scans(): + owner = NativeBluetoothOwner(lambda: True) + owner.galaxy = Mock(return_value=(200, {'devices': []})) + assert owner.request('scan', delegate=False) == {'devices': []} + owner.galaxy.assert_called_once_with('GET', '/api/bluetooth/status') + + +def test_denials_do_not_retry_through_a_different_owner(): + owner = NativeBluetoothOwner(lambda: True) + with patch.object(BluetoothOwner, 'request', side_effect=BluetoothRejected('park first', code='park_required')), \ + patch('openpilot.starpilot.galaxy_bluetooth.phone_ui.galaxy_call') as factory: + with pytest.raises(BluetoothRejected, match='park first'): + owner.request('power', enabled=False) + factory.assert_not_called() + call = Mock(return_value=(409, {'code': 'busy'})) + owner.galaxy = call + with patch.object(BluetoothOwner, 'request') as local: + with pytest.raises(BluetoothRejected): + owner.request('scan') + local.assert_not_called() + call.assert_called_once() diff --git a/openpilot/starpilot/galaxy/bin/LICENSE.hev-socks5-tunnel b/openpilot/starpilot/galaxy/bin/LICENSE.hev-socks5-tunnel new file mode 100644 index 0000000000..60a7d088b6 --- /dev/null +++ b/openpilot/starpilot/galaxy/bin/LICENSE.hev-socks5-tunnel @@ -0,0 +1,19 @@ +Copyright (c) 2022 hev + +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. diff --git a/openpilot/starpilot/galaxy/bin/hev-socks5-tunnel.sha256 b/openpilot/starpilot/galaxy/bin/hev-socks5-tunnel.sha256 new file mode 100644 index 0000000000..d8907e75c9 --- /dev/null +++ b/openpilot/starpilot/galaxy/bin/hev-socks5-tunnel.sha256 @@ -0,0 +1 @@ +e44a6965cd718d98829f344e9f300a5664d12020557127b7469bee7c8f3098b0 hev-socks5-tunnel_linux_arm64 diff --git a/openpilot/starpilot/galaxy/bin/hev-socks5-tunnel_linux_arm64 b/openpilot/starpilot/galaxy/bin/hev-socks5-tunnel_linux_arm64 new file mode 100755 index 0000000000..e3f8974530 Binary files /dev/null and b/openpilot/starpilot/galaxy/bin/hev-socks5-tunnel_linux_arm64 differ diff --git a/openpilot/starpilot/galaxy/http_server.py b/openpilot/starpilot/galaxy/http_server.py index 4051f5f824..d232be250a 100644 --- a/openpilot/starpilot/galaxy/http_server.py +++ b/openpilot/starpilot/galaxy/http_server.py @@ -16,10 +16,14 @@ class _LocalHTTPServer(ThreadingHTTPServer): """Bound idle browser connections without blocking other local requests.""" MAX_CONNECTIONS = 8 + # comma's own services call back over loopback while a browser request waits on them. + LOOPBACK_CONNECTIONS = 4 REQUEST_TIMEOUT = 4.0 def __init__(self, *args, **kwargs): self._slots = threading.BoundedSemaphore(self.MAX_CONNECTIONS) + self._loopback_slots = threading.BoundedSemaphore(self.LOOPBACK_CONNECTIONS) + self._draining = False super().__init__(*args, **kwargs) def get_request(self): @@ -27,35 +31,53 @@ class _LocalHTTPServer(ThreadingHTTPServer): request.settimeout(self.REQUEST_TIMEOUT) return request, address - def process_request(self, request, client_address): - if not self._slots.acquire(timeout=self.REQUEST_TIMEOUT): + def loopback(self, request) -> bool: + if getattr(self, 'remote_transport', False): + return False + try: + return request.getpeername()[0] == request.getsockname()[0] == '127.0.0.1' + except (OSError, IndexError, TypeError): + return False + + def _admit(self, request) -> threading.BoundedSemaphore | None: + """The pool this request got a slot from; loopback overflows into its reserve instead of waiting.""" + if self._draining: + return None + if self.loopback(request): + if self._slots.acquire(blocking=False): + return self._slots + return self._loopback_slots if self._loopback_slots.acquire(timeout=self.REQUEST_TIMEOUT) else None + return self._slots if self._slots.acquire(timeout=self.REQUEST_TIMEOUT) else None + + def process_request_thread(self, request, client_address): + # Wait on the request's own thread so a full browser pool never blocks accepting. + slots = self._admit(request) + if slots is None: self.shutdown_request(request) return try: - super().process_request(request, client_address) - except Exception: - self._slots.release() - raise - - def process_request_thread(self, request, client_address): - try: - super().process_request_thread(request, client_address) + if self._draining: + self.shutdown_request(request) + else: + super().process_request_thread(request, client_address) finally: - self._slots.release() + slots.release() def drain_requests(self, timeout: float) -> bool: """Wait for accepted requests before closing their shared data sources.""" + self._draining = True deadline = time.monotonic() + timeout - acquired = 0 + acquired: list[threading.BoundedSemaphore] = [] try: - for _ in range(self.MAX_CONNECTIONS): - if not self._slots.acquire(timeout=max(0.0, deadline - time.monotonic())): - return False - acquired += 1 + for slots, count in ((self._slots, self.MAX_CONNECTIONS), (self._loopback_slots, self.LOOPBACK_CONNECTIONS)): + for _ in range(count): + if not slots.acquire(timeout=max(0.0, deadline - time.monotonic())): + return False + acquired.append(slots) return True finally: - for _ in range(acquired): - self._slots.release() + for slots in acquired: + slots.release() def service_actions(self): super().service_actions() diff --git a/openpilot/starpilot/galaxy/pairing_ready.py b/openpilot/starpilot/galaxy/pairing_ready.py new file mode 100644 index 0000000000..6f14f7823b --- /dev/null +++ b/openpilot/starpilot/galaxy/pairing_ready.py @@ -0,0 +1,51 @@ +"""Whether comma can take a phone's Bluetooth pairing right now, so companion apps wait instead of failing.""" +from collections.abc import Callable + +# Short-lived states a phone may wait out; the others need someone to do something on comma. +TRANSIENT = frozenset(('starting', 'bridge_starting')) +BOOT_GRACE_SECONDS = 45.0 + + +def system_uptime() -> float: + try: + with open('/proc/uptime') as stream: + return float(stream.read().split()[0]) + except (OSError, ValueError, IndexError): + return BOOT_GRACE_SECONDS # no grace where the uptime cannot be read (not a comma) + + +def bridge_state() -> tuple[str, str]: + """The phone bridge's (state, message); 'pending' whenever it cannot be read.""" + try: + from openpilot.starpilot.galaxy_bluetooth.bootstrap import setup_state + return setup_state() + except Exception: + return 'pending', 'Bridge startup pending' + + +def pairing_readiness(*, parked: Callable[[], bool], snapshot: Callable[[], dict], + bridge: Callable[[], tuple[str, str]], uptime: Callable[[], float] = system_uptime) -> dict: + """{'ready': bool, 'code': str, 'message': str}; checked in the order a person would fix them.""" + def answer(ready: bool, code: str, message: str) -> dict: + return {'schema': 1, 'ready': ready, 'code': code, 'message': message} + + if uptime() < BOOT_GRACE_SECONDS: + return answer(False, 'starting', 'Comma just started and Bluetooth is still getting ready.') + try: + is_parked = parked() + state = snapshot() if is_parked else {} + except Exception: # a readiness question must always get an answer, never an error + return answer(False, 'bluetooth_unavailable', 'Comma’s Bluetooth service is not responding.') + if not is_parked: + return answer(False, 'park_required', 'Park the car or turn it off to pair a phone.') + if state.get('errorCode') or not state.get('available', True): + return answer(False, 'bluetooth_unavailable', 'Comma’s Bluetooth adapter is not available.') + if not state.get('powered'): + return answer(False, 'bluetooth_off', 'Bluetooth is off on comma. Turn it on in Settings > Bluetooth.') + try: + bridge_state, bridge_message = bridge() + except Exception: + bridge_state, bridge_message = 'pending', '' + if bridge_state != 'ready': + return answer(False, 'bridge_starting', bridge_message or 'The phone Bluetooth bridge is starting.') + return answer(True, 'ready', 'Ready to pair.') diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index e1e95ff019..68e174f2f6 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -5,6 +5,7 @@ import hashlib from contextlib import nullcontext from http.cookies import SimpleCookie import json +import os import re import secrets import select @@ -14,11 +15,12 @@ import threading import time from urllib.parse import parse_qs, unquote, urlsplit +from openpilot.common.swaglog import cloudlog from openpilot.starpilot.galaxy.batch_routes import change_drive_state, delete_records, non_default_settings, preview_layout, preview_settings_recovery from openpilot.starpilot.galaxy.access import AccessStatus, default_owner from openpilot.starpilot.galaxy.auth import LocalSessions from openpilot.starpilot.galaxy.http_server import (_LocalHTTPHandler, _LocalHTTPServer, allowed_authority, - direct_local_connection, static_asset) + direct_local_connection, install_origin_allowed, static_asset) from openpilot.starpilot.galaxy.camera_snapshot import CameraSnapshot, SnapshotDenied, SnapshotUnavailable from openpilot.starpilot.galaxy.remote import default_remote_pairing, gateway_cookie_valid from openpilot.starpilot.galaxy.crash_reports import CrashChanged, CrashMissing, CrashReports, CrashUnavailable @@ -39,6 +41,7 @@ from openpilot.starpilot.galaxy.map_status import MapStatus, MapUnavailable from openpilot.starpilot.galaxy.map_operations import MapOperationError, MapOperations, validate_action from openpilot.starpilot.galaxy.plots import Plots from openpilot.starpilot.galaxy.software_status import SoftwareStatus, SoftwareUnavailable +from openpilot.starpilot.galaxy.web_manifest import web_manifest from openpilot.starpilot.galaxy.software_operations import SoftwareOperations, SoftwareOperationError from openpilot.starpilot.galaxy.settings import PAGES, SettingsGateway, SettingsChanged, SettingsUnavailable from openpilot.starpilot.galaxy.onroad_layout import LayoutChanged, OnroadLayoutOwner @@ -59,6 +62,34 @@ from openpilot.starpilot.audio.downloads import SoundDownloads, SoundDownloadErr WEB = Path(__file__).resolve().parent / 'web' INSTANCE = secrets.token_hex(8) + + +def phone_claim_view(): + from openpilot.starpilot.galaxy_bluetooth.bridge import claim + return claim.view(claim.CLAIM_PATH) + + +def link_music_phone(address): + """Music paired in the window an app claimed (either order): remember which device is the app's phone.""" + try: + from openpilot.starpilot.galaxy_bluetooth.bridge import claim, pairing + claim.note_paired(address) + linked = claim.take_link() + if linked: + pairing.link_address(*linked) + except (OSError, ValueError): + pass + + +def forget_app_keys(address): + """Forgetting a Bluetooth device also forgets the app key that was paired with it.""" + try: + from openpilot.starpilot.galaxy_bluetooth.bridge import pairing + pairing.remove_by_address(address) + except (OSError, ValueError): + pass + + def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashes=None, software=None, maps=None, models=None, settings=None, plots=None, map_operations=None, recordings=None, recording_media=None, segment_summary=None, sentry_events=None, notifications=None, training_results=None, bluetooth=None, vehicle_selection=None, @@ -66,8 +97,13 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe sounds=None, software_operations=None, drive_stats=None, layout_preview_socket=None, controllers_socket=None, remote_pairing=None, parked=None, camera_snapshot=None, clock=time.monotonic, android_auto_setup=None, android_auto_client=None, navigation=None, offline_maps=None, drive_state=None, cloud_provider=None, cloud_offroad=None, projection_layout=None, - local_access=None, tmux_live=None, android_auto_logs=None, hotspot=None, toyota_tools=None, replay_client=None, replay_root=None): + local_access=None, tmux_live=None, android_auto_logs=None, hotspot=None, toyota_tools=None, replay_client=None, replay_root=None, + spotify_setup=None, companion=None, phone_location=None): + from openpilot.starpilot.galaxy.companion import CompanionRegistry, APK_PATH + from openpilot.starpilot.gps.phone import PhoneGpsPublisher + companion_source = companion if companion is not None else CompanionRegistry() training_results_source = training_results if training_results is not None else FlmTrainingResults() + phone_location_source = phone_location if phone_location is not None else PhoneGpsPublisher() cloud = cloud_provider hotspot_source = hotspot hotspot_source_lock = threading.Lock() @@ -175,6 +211,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe bluetooth_lock = threading.Lock() bluetooth_source = bluetooth aa_setup_source = android_auto_setup + spotify_setup_source = spotify_setup aa_client_source = android_auto_client aa_source_lock = threading.Lock() aa_registry_source = None @@ -304,13 +341,18 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe return record is not None and generation == remote_generation(current, record) and \ (sessions.valid(token, generation) or gateway_cookie_valid(token, record)) + def bluetooth_paired(address, name, uuids): + link_music_phone(address) + spotify_owner().adopt_phone(address, name, uuids) + def bluetooth_owner(): nonlocal bluetooth_source with bluetooth_lock: if bluetooth_source is None: from openpilot.common.params import Params authority = context_source(Params()) - bluetooth_source = BluetoothOwner(authority.configuration_allowed, session_valid=bluetooth_session_valid) + bluetooth_source = BluetoothOwner(authority.configuration_allowed, session_valid=bluetooth_session_valid, + on_paired=bluetooth_paired) server.bluetooth_authority = authority server.bluetooth_source = bluetooth_source return bluetooth_source @@ -343,6 +385,18 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe server.android_auto_setup_source = aa_setup_source return aa_setup_source + def spotify_owner(): + nonlocal spotify_setup_source + with bluetooth_lock: + if spotify_setup_source is None: + from openpilot.starpilot.galaxy.spotify_setup import SpotifySetup + def paired_devices(identity): + return bluetooth_owner().snapshot(session=identity).get('devices', []) + spotify_setup_source = SpotifySetup(parked=configuration_allowed, session_valid=bluetooth_session_valid, + paired_devices=paired_devices) + server.spotify_setup_source = spotify_setup_source + return spotify_setup_source + def aa_source_registry(): nonlocal aa_registry_source with aa_source_lock: @@ -389,7 +443,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe if settings_gateway is None: from openpilot.common.params import Params params = Params() - settings_gateway = SettingsGateway(params, context_source(params)) + settings_gateway = SettingsGateway(params, context_source(params), android_auto=aa_client) server.settings_source = settings_gateway return settings_gateway @@ -515,6 +569,10 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe return not getattr(self.server, 'remote_transport', False) and \ direct_local_connection(self.client_address[0], self.connection.getsockname()[0], self.headers) + def spotify_install_allowed(self): + return install_origin_allowed(self.client_address[0], self.connection.getsockname()[0], self.headers, + getattr(self.server, 'remote_transport', False)) + def authenticated(self): return self.settings_session() is not None @@ -564,6 +622,51 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe finally: aa_source_registry().revoke_source(source) + def phone_claim(self, operation, result): + """The same window that lets a phone pair for music also lets the StarPilot app claim its key.""" + from openpilot.starpilot.galaxy_bluetooth.bridge import claim + try: + if operation == 'cancel_pair': + claim.clear() + elif type(result) is dict: + claim.arm(result.get('adapterAddress', ''), result.get('adapterName', '')) + except OSError: + pass + + def aa_phone_window(self, identity, operation, payload=None): + """The phone pairing window inside a running Android Auto projection; handled False when none holds the adapter.""" + registry = aa_source_registry() + minted = operation in ('accept_pair', 'pair', 'scan', 'stop_scan') + source = registry.mint(identity) if minted else registry.current(identity) + if source is None: + return {'handled': False} + arguments = {'address': payload['address']} if operation == 'pair' and payload is not None else {} + if operation == 'pairing_response' and payload is not None: + arguments = {'prompt_id': payload['promptId'], 'accepted': payload['accepted'], 'value': payload.get('value', '')} + try: + if self.settings_session() != identity: + raise ValueError('Galaxy session changed') + delegated = aa_client().call('phone_window', source=source, operation=operation, **arguments) + except (OSError, RuntimeError) as error: + # Falling back to this process cannot work while Android Auto holds the adapter, so say why it was skipped. + cloudlog.warning(f'android auto phone window {operation} not delegated: {error!r}') + delegated = {'handled': False} + if type(delegated) is not dict or type(delegated.get('handled')) is not bool: + delegated = {'handled': False} + if minted and (not delegated['handled'] or 'error_code' in delegated or operation in ('scan', 'stop_scan')) or \ + operation == 'cancel_pair' and delegated['handled']: + registry.revoke_source(source) + found = delegated.get('bluetooth') + paired = found.pop('windowPaired', None) if type(found) is dict else None + if type(paired) is dict: + link_music_phone(paired.get('address', '')) + if type(paired) is dict and configuration_allowed(): + try: + spotify_owner().adopt_phone(paired['address'], paired['name'], tuple(paired['uuids'])) + except Exception: + pass + return delegated + def do_GET(self): if not self.local_request(): return @@ -626,6 +729,33 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe else: if self.require_session(): self.json(200, result) + elif path in ('/app.apk', '/api/companion/app.apk'): + if getattr(self.server, 'remote_transport', False): + self.json(403, {'error': 'Download the app over local Wi-Fi'}) + return + if not self.direct_local() and not self.require_session(): + return + try: + handle = APK_PATH.open('rb') + except OSError: + self.json(404, {'error': 'Companion APK is not installed'}) + return + with handle: + self.send_response(200) + self.send_header('Content-Type', 'application/vnd.android.package-archive') + self.send_header('Content-Length', str(os.fstat(handle.fileno()).st_size)) + self.send_header('Content-Disposition', 'attachment; filename="starpilot-companion.apk"') + self.send_header('Cache-Control', 'no-store') + self.end_headers() + if self.command != 'HEAD': + while chunk := handle.read(65536): + self.wfile.write(chunk) + elif path == '/api/companion/status': + if self.require_session(): + try: + self.json(200, companion_source.snapshot()) + except (OSError, ValueError): + self.json(503, {'error': 'Companion settings unavailable'}) elif path == '/api/galaxy/hotspot': if not self.require_session(): return @@ -648,15 +778,16 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe 'tunnelClientAvailable': frpc_binary() is not None, 'legacyPassword': access.status().status == AccessStatus.LEGACY_IMPORT_AVAILABLE, 'legacyPairingAvailable': legacy_available is True}) - elif path == '/api/galaxy/qr.svg': + elif path in ('/api/galaxy/qr.svg', '/api/companion/qr.svg'): if not self.require_session(): return record = pairing.read() - if record is None: + if record is None and path == '/api/galaxy/qr.svg': self.json(404, {'error': 'Galaxy is not paired'}) return from openpilot.common.qrcode import _Qr, _capacity - raw = pairing.url(record['slug']).encode() + from openpilot.starpilot.galaxy.hotspot import ADDRESS + raw = (f"http://{ADDRESS}:8082/app.apk" if path == '/api/companion/qr.svg' or record is None else pairing.url(record['slug'])).encode() version = next(version for version in range(1, 21) if 4 + 8 + len(raw) * 8 <= _capacity(version) * 8) matrix = _Qr(version, raw).modules @@ -666,8 +797,15 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe svg = (f'' + f'{cells}') self.respond(200, svg.encode(), 'image/svg+xml') - elif path == '/data/runtime.json': + elif path in ('/data/runtime.json', '/api/companion/runtime'): self.json(200, {'schemaVersion': 1, 'monitor': 'local', 'instance': INSTANCE}) + elif path == '/api/companion/web-manifest': + self.json(200, web_manifest(WEB)) + elif path == '/api/companion/bluetooth-ready': + if not self.require_session(): + return + from openpilot.starpilot.galaxy.pairing_ready import bridge_state, pairing_readiness + self.json(200, pairing_readiness(parked=configuration_allowed, snapshot=lambda: bluetooth_owner().snapshot(), bridge=bridge_state)) elif path in ('/api/local-access', '/api/tmux/live', '/api/troubleshoot'): if not self.require_session(): return @@ -821,6 +959,11 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe return try: result = bluetooth_owner().snapshot(session=identity) + result['claim'] = phone_claim_view() + if result.get('pairing') is None and aa_setup_owner().enabled(): + delegated = self.aa_phone_window(identity, 'status') + if delegated['handled'] and type(delegated.get('bluetooth')) is dict: + result['pairing'] = delegated['bluetooth'].get('pairing') except (OSError, RuntimeError): self.json(503, {'error': 'Bluetooth is unavailable', 'code': 'unavailable'}) else: @@ -844,6 +987,22 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe self.json(200, result) else: self.json(401, {'error': 'Sign in to Galaxy'}) + elif path == '/api/spotify/setup': + if not self.require_session(): + return + identity = self.settings_session() + if identity is None: + self.json(401, {'error': 'Sign in to Galaxy'}) + return + try: + result = {**spotify_owner().status(identity), 'uploadAllowed': self.spotify_install_allowed()} + except Exception: + self.json(503, {'error': 'Spotify setup is unavailable'}) + else: + if self.settings_session() == identity: + self.json(200, result) + else: + self.json(401, {'error': 'Sign in to Galaxy'}) elif path == '/api/android-auto/pairing/status': if not self.require_session(): return @@ -1352,12 +1511,52 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe finally: self.connection.settimeout(old_timeout) return + if path == '/api/spotify/upload': + if not self.require_session(): + return + identity = self.settings_session() + if identity is None: + self.json(401, {'error': 'Sign in to Galaxy'}) + return + if not self.spotify_install_allowed(): + self.json(403, {'error': 'Install Spotify over your local network or the Galaxy tunnel'}) + return + if self.headers.get('Transfer-Encoding') is not None or \ + self.headers.get('Content-Type', '').lower() != 'application/octet-stream': + self.json(415, {'error': 'Binary .tar.gz upload required'}) + return + try: + size = int(self.headers.get('Content-Length', '')) + except ValueError: + size = -1 + from openpilot.starpilot.soloist.package import MAX_UPLOAD_BYTES + if not 0 < size <= MAX_UPLOAD_BYTES: + self.json(413, {'error': 'Soloist download size is invalid'}) + return + from openpilot.starpilot.galaxy.spotify_setup import SetupRejected as SpotifyRejected + old_timeout = self.connection.gettimeout() + try: + self.connection.settimeout(30.0) + result = spotify_owner().upload(identity, self.rfile, size) + except SpotifyRejected as error: + self.json(409, {'error': str(error)}) + except (OSError, TimeoutError): + self.json(503, {'error': 'Soloist upload failed'}) + else: + if self.settings_session() == identity: + self.json(202, result) + else: + self.json(401, {'error': 'Sign in to Galaxy'}) + finally: + self.connection.settimeout(old_timeout) + return if path.startswith('/api/flm/'): self.close_connection = True if self.require_session(): self.json(404, {'error': 'Not found'}) return - if path not in ('/api/connect/provider', '/api/auth/login', '/api/auth/logout', '/api/settings/preview', '/api/settings/confirm', + if path not in ('/api/companion/uplink', '/api/companion/settings', '/api/companion/location', + '/api/connect/provider', '/api/auth/login', '/api/auth/logout', '/api/settings/preview', '/api/settings/confirm', '/api/settings/recovery/preview', '/api/settings/recovery/confirm', '/api/settings/reset-default', '/api/settings/restore', '/api/recordings/action', '/api/replay/start', '/api/replay/control', '/api/replay/stop', @@ -1365,6 +1564,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe '/api/galaxy/pair', '/api/galaxy/unpair', '/api/galaxy/device-name', '/api/galaxy/hotspot', '/api/cameras/snapshot', '/api/android-auto/layout', '/api/android-auto/enable', '/api/android-auto/control', '/api/android-auto/pairing', '/api/android-auto/pairing/response', '/api/android-auto/pairing/cancel', '/api/android-auto/pairing/select', + '/api/spotify/enable', '/api/spotify/key', '/api/spotify/preferences', '/api/spotify/control', '/api/spotify/maintenance', '/api/ui/layout', '/api/ui/layout/preview', '/api/favorites/slots', '/api/maps/start', '/api/maps/cancel', '/api/bluetooth/action', '/api/controllers/action', @@ -1377,8 +1577,9 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe '/api/toyota-tools/action', '/api/vehicle-selection/preview', '/api/vehicle-selection/confirm'): self.json(405, {'error': 'Method unavailable'}) return - if (path.startswith(('/api/replay/', '/api/recordings/', '/api/connect/', '/api/cameras/', '/api/settings/', '/api/maps/', '/api/bluetooth/', - '/api/android-auto/', '/api/controllers/', '/api/vehicle-selection/', '/api/toyota-tools/', + if (path.startswith(('/api/companion/', '/api/replay/', '/api/recordings/', '/api/connect/', '/api/cameras/', '/api/settings/', '/api/maps/', + '/api/bluetooth/', + '/api/android-auto/', '/api/spotify/', '/api/controllers/', '/api/vehicle-selection/', '/api/toyota-tools/', '/api/models/', '/api/ui/', '/api/favorites/', '/api/sounds/', '/api/software/', '/api/drives/', '/api/navigation/', '/api/drive-state/', '/api/sentry/', '/api/crash-reports/')) and not self.require_session()): @@ -1411,6 +1612,33 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe if path.startswith('/api/replay/'): replay_api.post(self, path, payload) return + if path == '/api/companion/location': + if getattr(self.server, 'remote_transport', False) or not self.direct_local(): + self.json(403, {'error': 'Phone location requires a direct local connection'}) + return + try: + self.json(200, phone_location_source.publish(payload)) + except ValueError as error: + self.json(400, {'error': str(error)}) + return + if path in ('/api/companion/uplink', '/api/companion/settings'): + if getattr(self.server, 'remote_transport', False) or not self.direct_local(): + self.json(403, {'error': 'Companion controls require a direct local connection'}) + return + try: + result = (companion_source.heartbeat(payload, self.client_address[0]) if path.endswith('/uplink') + else companion_source.save(payload)) + except PermissionError as error: + self.json(403, {'error': str(error)}) + except FileExistsError as error: + self.json(409, {'error': str(error)}) + except ValueError as error: + self.json(400, {'error': str(error)}) + except OSError: + self.json(503, {'error': 'Companion settings unavailable'}) + else: + self.json(200, result) + return if path == '/api/galaxy/hotspot': identity = self.settings_session() if identity is None: @@ -1949,6 +2177,37 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe aa_registry_source.revoke(identity) self.respond(200, b'{"authenticated":false}', cookie='galaxy_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0') return + if path.startswith('/api/spotify/'): + identity = self.settings_session() + if identity is None: + self.json(401, {'error': 'Sign in to Galaxy'}) + return + from openpilot.starpilot.galaxy.spotify_setup import SetupRejected as SpotifyRejected + try: + owner = spotify_owner() + if path == '/api/spotify/enable' and type(payload) is dict and set(payload) == {'enabled'}: + result = owner.enable(identity, payload['enabled']) + elif path == '/api/spotify/key' and type(payload) is dict and set(payload) == {'key'}: + result = owner.set_key(identity, payload['key']) + elif path == '/api/spotify/preferences' and type(payload) is dict: + result = owner.save_preferences(identity, payload) + elif path == '/api/spotify/control' and type(payload) is dict and set(payload) in ({'action'}, {'action', 'args'}): + result = owner.control(identity, payload['action'], payload.get('args', {})) + elif path == '/api/spotify/maintenance' and type(payload) is dict and set(payload) == {'action'}: + result = owner.maintenance(identity, payload['action']) + else: + self.json(400, {'error': 'Invalid Spotify request'}) + return + except SpotifyRejected as error: + self.json(409, {'error': str(error)}) + except (OSError, RuntimeError): + self.json(503, {'error': 'Spotify is unavailable'}) + else: + if self.settings_session() == identity: + self.json(200, result) + else: + self.json(401, {'error': 'Sign in to Galaxy'}) + return if path == '/api/android-auto/enable': if type(payload) is not dict or set(payload) not in ({'enabled'}, {'enabled', 'enableBluetooth'}) or \ type(payload['enabled']) is not bool or type(payload.get('enableBluetooth', False)) is not bool or \ @@ -2008,7 +2267,8 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe self.json(403, {'error': 'Park before changing automatic connection'}) return command, arguments = 'set_auto_connect', {'enabled': payload['enabled']} - elif action == 'connection' and set(payload) == {'action', 'connection'} and payload['connection'] in ('wireless', 'wired'): + elif (action == 'connection' and set(payload) == {'action', 'connection'} and + payload['connection'] in ('wireless', 'wired', 'headunitpad', 'openheadunit')): if not configuration_allowed(): self.json(403, {'error': 'Park before changing the connection type'}) return @@ -2030,7 +2290,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe runtime = aa_client().call('status')['status'] if runtime.get('pairing_ready'): raise ValueError('Finish or cancel pairing first') - if command == 'start' and not runtime.get('receiver_address') and runtime.get('connection') != 'wired': + if command == 'start' and not runtime.get('receiver_address') and runtime.get('connection') == 'wireless': raise ValueError('Select a car first') if command == 'start' and not aa_setup_owner().identity_status().get('installed'): raise ValueError('Verify your Android Auto package first') @@ -2220,7 +2480,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe if payload['operation'] == 'power': if set(payload) != {'operation', 'enabled'} or type(payload['enabled']) is not bool: raise ValueError('Invalid Bluetooth request') - elif payload['operation'] in ('scan', 'stop_scan', 'cancel_pair'): + elif payload['operation'] in ('scan', 'stop_scan', 'cancel_pair', 'accept_pair'): if set(payload) != {'operation'}: raise ValueError('Invalid Bluetooth request') elif payload['operation'] == 'pairing_response': @@ -2238,18 +2498,50 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe self.json(401, {'error': 'Sign in to Galaxy'}) return delegated = {'handled': False} - if payload['operation'] in ('connect', 'disconnect') and aa_setup_owner().enabled(): - delegated = self.aa_command(identity, 'bluetooth_action', operation=payload['operation'], - address=normalized_address(payload['address'])) + if payload['operation'] == 'accept_pair' and configuration_allowed(): + spotify_owner().prepare_phone_pairing() + if payload['operation'] in ('connect', 'disconnect', 'forget') and aa_setup_owner().enabled(): + try: + delegated = self.aa_command(identity, 'bluetooth_action', operation=payload['operation'], + address=normalized_address(payload['address'])) + except (OSError, RuntimeError): + # Without a running Android Auto service nothing else holds the adapter, so forgetting is local. + if payload['operation'] != 'forget': + raise + delegated = {'handled': False} if type(delegated) is not dict or type(delegated.get('handled')) is not bool: raise BluetoothUnavailable('service_unavailable') if 'error_code' in delegated: code = delegated['error_code'] - if type(code) is str and code in ('busy', 'park_required', 'changed', 'session_expired'): + if type(code) is str and code in ('busy', 'park_required', 'changed', 'session_expired', 'projection_active'): raise BluetoothRejected('Android Auto could not complete the Bluetooth change', code=code) unavailable = ('service_unavailable', 'adapter_unavailable', 'radio_unavailable', 'radio_preference_unavailable') raise BluetoothUnavailable('Android Auto Bluetooth service is unavailable', code=code if type(code) is str and code in unavailable else 'service_unavailable') + elif payload['operation'] == 'power' and aa_setup_owner().enabled(): + # Android Auto may hold the adapter in its own process; it releases that hold before the radio stops. + try: + delegated = self.aa_command(identity, 'bluetooth_power', enabled=payload['enabled']) + except (OSError, RuntimeError): + delegated = {'handled': False} + if type(delegated) is not dict or type(delegated.get('handled')) is not bool: + delegated = {'handled': False} + if 'error_code' in delegated: + code = delegated['error_code'] + if type(code) is str and code in ('busy', 'park_required', 'changed', 'session_expired', 'projection_active'): + why = delegated.get('message') + raise BluetoothRejected(f'Android Auto: {why}' if type(why) is str and why else + 'Android Auto could not complete the Bluetooth change', code=code) + raise BluetoothUnavailable('Android Auto Bluetooth service is unavailable') + elif payload['operation'] in ('accept_pair', 'pair', 'scan', 'stop_scan', 'pairing_response', 'cancel_pair') and aa_setup_owner().enabled(): + delegated = self.aa_phone_window(identity, payload['operation'], payload) + if 'error_code' in delegated: + code = delegated['error_code'] + if type(code) is str and code in ('busy', 'park_required', 'changed', 'session_expired'): + why = delegated.get('message') + raise BluetoothRejected(f'Android Auto: {why}' if type(why) is str and why else + 'Android Auto could not complete the Bluetooth change', code=code) + raise BluetoothUnavailable('Android Auto Bluetooth service is unavailable') if delegated['handled']: result = delegated.get('bluetooth') if type(result) is not dict: @@ -2258,13 +2550,19 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe result = bluetooth_owner().request(payload['operation'], address=payload.get('address'), enabled=payload.get('enabled'), session=identity, prompt_id=payload.get('promptId'), accepted=payload.get('accepted'), value=payload.get('value', '')) + if payload['operation'] in ('accept_pair', 'cancel_pair'): + self.phone_claim(payload['operation'], result) + elif payload['operation'] == 'forget': + forget_app_keys(payload['address']) except ValueError: if self.settings_session() == identity: self.json(400, {'error': 'Invalid Bluetooth request'}) except BluetoothRejected as error: + cloudlog.warning(f'bluetooth {payload.get("operation")} refused ({error.code}): {error}') if self.settings_session() == identity: self.json(409, {'error': 'Bluetooth change was not accepted', 'code': error.code}) except BluetoothUnavailable as error: + cloudlog.warning(f'bluetooth {payload.get("operation")} unavailable ({error.code}): {error}') if self.settings_session() == identity: self.json(503, {'error': 'Bluetooth service could not complete the request', 'code': error.code}) except (OSError, RuntimeError): @@ -2426,6 +2724,26 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe def do_DELETE(self): if not self.local_request(mutation=True): return + if urlsplit(self.path).path == '/api/spotify': + if not self.require_session(): + return + identity = self.settings_session() + if identity is None: + self.json(401, {'error': 'Sign in to Galaxy'}) + return + from openpilot.starpilot.galaxy.spotify_setup import SetupRejected as SpotifyRejected + try: + result = spotify_owner().remove(identity) + except SpotifyRejected as error: + self.json(409, {'error': str(error)}) + except (OSError, RuntimeError): + self.json(503, {'error': 'Spotify data could not be removed'}) + else: + if self.settings_session() == identity: + self.json(200, result) + else: + self.json(401, {'error': 'Sign in to Galaxy'}) + return if urlsplit(self.path).path != '/api/android-auto/identity': self.json(405, {'error': 'Method unavailable'}) return diff --git a/openpilot/starpilot/galaxy/test_android_auto_http.py b/openpilot/starpilot/galaxy/test_android_auto_http.py index a3b4b7997c..a32896d7b1 100644 --- a/openpilot/starpilot/galaxy/test_android_auto_http.py +++ b/openpilot/starpilot/galaxy/test_android_auto_http.py @@ -403,18 +403,19 @@ def test_existing_paired_car_controls_are_local_authenticated_and_source_bound(a cookie=cookie, content_type='application/json')[0] == 401 -def test_wired_connection_requires_park_but_not_a_paired_receiver(api): +@pytest.mark.parametrize('connection', ['wired', 'headunitpad', 'openheadunit']) +def test_wired_connection_requires_park_but_not_a_paired_receiver(api, connection): request, _, state, client = api code, _, headers = request('POST', '/api/auth/login', json.dumps({'password': 'password123'}), content_type='application/json') assert code == 200 cookie = headers['Set-Cookie'].split(';', 1)[0] - payload = json.dumps({'action': 'connection', 'connection': 'wired'}) + payload = json.dumps({'action': 'connection', 'connection': connection}) state['parked'] = False assert request('POST', '/api/android-auto/control', payload, cookie=cookie, content_type='application/json')[0] == 403 state['parked'] = True code, result, _ = request('POST', '/api/android-auto/control', payload, cookie=cookie, content_type='application/json') - assert code == 200 and result['status']['connection'] == 'wired' + assert code == 200 and result['status']['connection'] == connection assert request('POST', '/api/android-auto/control', json.dumps({'action': 'start'}), cookie=cookie, content_type='application/json')[0] == 200 assert client.running diff --git a/openpilot/starpilot/galaxy/test_projection_bluetooth_http.py b/openpilot/starpilot/galaxy/test_projection_bluetooth_http.py index 450025f077..18a9ed4f75 100644 --- a/openpilot/starpilot/galaxy/test_projection_bluetooth_http.py +++ b/openpilot/starpilot/galaxy/test_projection_bluetooth_http.py @@ -48,8 +48,8 @@ class AA: self.http = None def call(self, command, **kwargs): self.calls.append((command, kwargs)) - assert command == 'bluetooth_action' - assert set(kwargs) == {'source', 'operation', 'address'} + assert command in ('bluetooth_action', 'bluetooth_power') + assert set(kwargs) == ({'source', 'operation', 'address'} if command == 'bluetooth_action' else {'source', 'enabled'}) assert self.http is not None code, data, _ = self.http('/api/android-auto/source/' + kwargs['source']) assert code == 200 and data == {'valid': True}, 'Real minted source proof was not live' @@ -113,6 +113,37 @@ class ProjectionBluetoothHttpTest(unittest.TestCase): return self.http('/api/bluetooth/action', {'operation': operation, 'address': CAR}, self.cookie if cookie is None else cookie) + def power(self, enabled=False): + return self.http('/api/bluetooth/action', {'operation': 'power', 'enabled': enabled}, self.cookie) + + def test_power_goes_to_the_daemon_holding_the_adapter_and_falls_back_without_it(self): + snapshot = self.bluetooth.snapshot() + snapshot['powered'] = False + self.aa.result = {'handled': True, 'bluetooth': snapshot} + code, result, _ = self.power(False) + self.assertEqual((code, result), (200, snapshot)) + self.assertEqual(self.bluetooth.calls, []) + command, arguments = self.aa.calls[-1] + self.assertEqual((command, arguments['enabled']), ('bluetooth_power', False)) + self.assertEqual(self.http('/api/android-auto/source/' + arguments['source'])[0], 410) + self.aa.result = {'handled': False} + self.assertEqual(self.power(False)[0], 200) + self.assertEqual(self.bluetooth.calls[-1][0], 'power') + self.aa.error = OSError('daemon is not running') + self.assertEqual(self.power(True)[0], 200) + self.assertEqual([call[0] for call in self.bluetooth.calls], ['power', 'power']) + self.params.enabled = False + calls = len(self.aa.calls) + self.assertEqual(self.power(False)[0], 200) + self.assertEqual(len(self.aa.calls), calls, 'Android Auto off: the daemon is never contacted') + + def test_power_refusals_from_the_daemon_never_fall_back(self): + for error, code in [('projection_active', 409), ('busy', 409), ('park_required', 409), ('service_unavailable', 503)]: + self.aa.result = {'handled': True, 'error_code': error} + actual, result, _ = self.power(False) + self.assertEqual((actual, result['code']), (code, error)) + self.assertEqual(self.bluetooth.calls, []) + def test_layout_requires_actual_auth_then_saves_only_projection_document(self): self.assertEqual(self.http('/api/android-auto/layout')[0], 401) payload = self.layout_payload() @@ -170,6 +201,21 @@ class ProjectionBluetoothHttpTest(unittest.TestCase): self.assertEqual(len(self.bluetooth.calls), 2) self.assertEqual(self.bluetooth.calls[-1][0], 'disconnect') + def test_forget_is_delegated_to_a_running_projection_and_falls_back_without_the_daemon(self): + snapshot = self.bluetooth.snapshot() + self.aa.result = {'handled': True, 'bluetooth': snapshot} + code, result, _ = self.action('forget') + self.assertEqual((code, result), (200, snapshot)) + self.assertEqual(self.aa.calls[0][1]['operation'], 'forget') + self.assertEqual(self.bluetooth.calls, []) + self.aa.result = {'handled': False} + self.assertEqual(self.action('forget')[0], 200) + self.assertEqual(self.bluetooth.calls[-1][0], 'forget') + self.aa.error = OSError('daemon is not running') + self.assertEqual(self.action('forget')[0], 200) + self.assertEqual([call[0] for call in self.bluetooth.calls], ['forget', 'forget']) + self.assertEqual(self.action('connect')[0], 503) + def test_owner_busy_and_error_codes_never_fall_back(self): for error, code in [('busy', 409), ('park_required', 409), ('changed', 409), ('service_unavailable', 503)]: self.aa.result = {'handled': True, 'error_code': error} diff --git a/openpilot/starpilot/galaxy/test_projection_phone_window_http.py b/openpilot/starpilot/galaxy/test_projection_phone_window_http.py new file mode 100644 index 0000000000..287114a683 --- /dev/null +++ b/openpilot/starpilot/galaxy/test_projection_phone_window_http.py @@ -0,0 +1,147 @@ +"""Galaxy routes a phone pairing window to Android Auto while it projects; radio and AA IPC are fakes.""" +import http.client +import json +from pathlib import Path +import tempfile +import threading +from types import SimpleNamespace +import unittest +from unittest import mock + +from openpilot.starpilot.galaxy.access import GalaxyAccessOwner +from openpilot.starpilot.galaxy.server import make_server + +PROMPT = '0123456789abcdef0123456789abcdef' +PHONE = '11:22:33:44:55:66' + + +class Params: + def __init__(self, root): self.root, self.enabled = root, True + def get(self, key): return None + def get_param_path(self, key): return str(self.root / key) + def get_bool(self, key): return self.enabled + + +class Bluetooth: + def __init__(self): self.calls = [] + def snapshot(self, **kwargs): + return {'version': 1, 'available': True, 'parked': True, 'powered': True, + 'discovering': False, 'devices': [], 'errorCode': None, 'pairing': None} + def request(self, operation, **kwargs): + self.calls.append(operation) + return self.snapshot() + def cancel_session(self, identity): pass + + +class AA: + def __init__(self): + self.calls, self.window, self.http, self.paired = [], True, None, None + def call(self, command, **kwargs): + assert command == 'phone_window' + self.calls.append(kwargs) + code, data, _ = self.http('/api/android-auto/source/' + kwargs['source']) # ty: ignore[call-non-callable] # set by the test + assert code == 200 and data == {'valid': True}, 'Source proof was not live' + if not self.window: + return {'handled': False} + pairing = {'address': PHONE, 'state': 'pairing', 'incoming': True, 'name': 'iPhone', + 'prompt': {'id': PROMPT, 'kind': 'confirmation', 'value': '123456'}} + return {'handled': True, 'bluetooth': {'version': 1, 'devices': [], 'pairing': pairing, 'windowPaired': self.paired}} + + +class ProjectionPhoneWindowHttpTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + claim_path = mock.patch('openpilot.starpilot.galaxy_bluetooth.bridge.claim.CLAIM_PATH', Path(temporary.name) / 'claim.json') + claim_path.start() + self.addCleanup(claim_path.stop) + root = Path(temporary.name) + self.params = Params(root) + self.access = GalaxyAccessOwner(root / 'access') + self.assertTrue(self.access.configure('password123', lambda: True)) + self.bluetooth, self.aa = Bluetooth(), AA() + self.spotify = SimpleNamespace(prepared=[], adopted=[]) + self.spotify.prepare_phone_pairing = lambda: self.spotify.prepared.append(True) + self.spotify.adopt_phone = lambda *args: self.spotify.adopted.append(args) + self.server = make_server(port=0, owner=self.access, parked=lambda: True, + android_auto_setup=SimpleNamespace(enabled=lambda: self.params.enabled), + android_auto_client=self.aa, bluetooth=self.bluetooth, spotify_setup=self.spotify) + self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': .01}, daemon=True) + self.worker.start() + self.addCleanup(self.stop) + self.aa.http = self.http + code, _, headers = self.http('/api/auth/login', {'password': 'password123'}) + self.assertEqual(code, 200) + self.cookie = headers['Set-Cookie'].split(';', 1)[0] + + def stop(self): + self.server.shutdown() + self.worker.join(timeout=2) + self.server.server_close() + + def http(self, path, payload=None, cookie=None): + connection = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=4) + headers = {'Cookie': self.cookie if cookie is None and hasattr(self, 'cookie') else cookie or ''} + if payload is not None: + headers.update({'Content-Type': 'application/json', 'Origin': f'http://127.0.0.1:{self.server.server_port}'}) + try: + connection.request('POST' if payload is not None else 'GET', path, + body=json.dumps(payload) if payload is not None else None, headers=headers) + response = connection.getresponse() + return response.status, json.loads(response.read()), dict(response.getheaders()) + finally: + connection.close() + + def action(self, payload): + return self.http('/api/bluetooth/action', payload) + + def test_window_opens_answers_and_reports_through_android_auto_while_projecting(self): + code, result, _ = self.action({'operation': 'accept_pair'}) + self.assertEqual(code, 200) + self.assertTrue(result['pairing']['incoming']) + self.assertNotIn('windowPaired', result) + self.assertEqual(self.bluetooth.calls, []) + self.assertEqual(self.spotify.prepared, [True]) + code, status, _ = self.http('/api/bluetooth/status') + self.assertEqual((code, status['pairing']['prompt']['value']), (200, '123456')) + self.aa.paired = {'address': PHONE, 'name': 'iPhone', 'uuids': ['0000110a-0000-1000-8000-00805f9b34fb']} + code, _, _ = self.action({'operation': 'pairing_response', 'promptId': PROMPT, 'accepted': True}) + self.assertEqual(code, 200) + self.assertEqual(self.spotify.adopted, [(PHONE, 'iPhone', ('0000110a-0000-1000-8000-00805f9b34fb',))]) + sources = {call['source'] for call in self.aa.calls} + self.assertEqual(len(sources), 1, 'every window request must reuse one source') + self.assertEqual(self.aa.calls[2]['prompt_id'], PROMPT) + self.assertEqual(self.bluetooth.calls, []) + + def test_without_projection_the_comma_owner_opens_the_window(self): + self.aa.window = False + code, _, _ = self.action({'operation': 'accept_pair'}) + self.assertEqual(code, 200) + self.assertEqual(self.bluetooth.calls, ['accept_pair']) + self.aa.calls.clear() + self.http('/api/bluetooth/status') + self.assertEqual(self.aa.calls, [], 'an unhandled window must not keep a source alive') + + def test_android_auto_off_never_contacts_the_daemon(self): + self.params.enabled = False + self.assertEqual(self.action({'operation': 'accept_pair'})[0], 200) + self.assertEqual(self.aa.calls, []) + self.assertEqual(self.bluetooth.calls, ['accept_pair']) + + def test_accessory_scan_pair_prompt_and_cancel_are_delegated(self): + for operation in ('scan', 'stop_scan'): + self.assertEqual(self.action({'operation': operation})[0], 200) + self.assertNotEqual(self.http('/api/android-auto/source/' + self.aa.calls[-1]['source'])[1], {'valid': True}) + self.assertEqual(self.action({'operation': 'pair', 'address': PHONE})[0], 200) + source = self.aa.calls[-1]['source'] + self.assertEqual(self.aa.calls[-1]['address'], PHONE) + self.assertEqual(self.http('/api/bluetooth/status')[0], 200) + self.assertEqual(self.action({'operation': 'pairing_response', 'promptId': PROMPT, 'accepted': True})[0], 200) + self.assertEqual(self.action({'operation': 'cancel_pair'})[0], 200) + self.assertEqual({c['source'] for c in self.aa.calls[-4:]}, {source}) + self.assertEqual(self.bluetooth.calls, []) + self.assertEqual(self.spotify.prepared, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/openpilot/starpilot/galaxy/tests/test_auth_runtime.py b/openpilot/starpilot/galaxy/tests/test_auth_runtime.py index e8965e7896..c8516ba088 100644 --- a/openpilot/starpilot/galaxy/tests/test_auth_runtime.py +++ b/openpilot/starpilot/galaxy/tests/test_auth_runtime.py @@ -272,7 +272,8 @@ class AuthRuntimeTest(unittest.TestCase): held = [] with mock.patch.object(self.server.RequestHandlerClass, 'setup', observed_setup), \ - mock.patch.object(self.server.RequestHandlerClass, 'finish', observed_finish): + mock.patch.object(self.server.RequestHandlerClass, 'finish', observed_finish), \ + mock.patch.object(self.server, 'loopback', return_value=False): try: for _ in range(self.server.MAX_CONNECTIONS): held.append(socket.create_connection(('127.0.0.1', self.server.server_port), timeout=2)) diff --git a/openpilot/starpilot/galaxy/tests/test_bluetooth_http.py b/openpilot/starpilot/galaxy/tests/test_bluetooth_http.py index a3f74743da..7580fed9d5 100644 --- a/openpilot/starpilot/galaxy/tests/test_bluetooth_http.py +++ b/openpilot/starpilot/galaxy/tests/test_bluetooth_http.py @@ -47,6 +47,9 @@ class BluetoothHttpTest(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) + claim_path = mock.patch('openpilot.starpilot.galaxy_bluetooth.bridge.claim.CLAIM_PATH', Path(temporary.name) / 'claim.json') + claim_path.start() + self.addCleanup(claim_path.stop) access = GalaxyAccessOwner(Path(temporary.name) / 'access') self.assertTrue(access.configure('password123', lambda: True)) self.access = access @@ -77,12 +80,68 @@ class BluetoothHttpTest(unittest.TestCase): finally: connection.close() + def test_accept_pair_opens_a_window_and_prepares_the_music_receiver(self): + self.stop() + aa_setup = mock.Mock() + aa_setup.enabled.return_value = False + self.server = make_server(port=0, owner=self.access, bluetooth=self.bluetooth, android_auto_setup=aa_setup, parked=lambda: True) + self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True) + self.worker.start() + _, _, headers = self.request('/api/auth/login', {'password': 'password123'}) + cookie = headers['Set-Cookie'].split(';', 1)[0] + with mock.patch('openpilot.starpilot.galaxy.spotify_setup.SpotifySetup.prepare_phone_pairing') as prepare: + self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'accept_pair'}, cookie)[0], 200) + prepare.assert_called_once_with() + self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'accept_pair', 'address': 'AA:BB:CC:DD:EE:FF'}, cookie)[0], 400) + prepare.assert_called_once_with() + operation, address, *_ = self.bluetooth.calls[-1] + self.assertEqual((operation, address), ('accept_pair', None)) + self.assertEqual(len(self.bluetooth.calls), 1) + + def test_the_music_window_also_opens_and_closes_the_app_claim(self): + from openpilot.starpilot.galaxy_bluetooth.bridge import claim + self.stop() + aa_setup = mock.Mock() + aa_setup.enabled.return_value = False + self.server = make_server(port=0, owner=self.access, bluetooth=self.bluetooth, android_auto_setup=aa_setup, parked=lambda: True) + self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True) + self.worker.start() + _, _, headers = self.request('/api/auth/login', {'password': 'password123'}) + cookie = headers['Set-Cookie'].split(';', 1)[0] + with tempfile.TemporaryDirectory() as directory, mock.patch.object(claim, 'CLAIM_PATH', Path(directory) / 'claim.json'), \ + mock.patch('openpilot.starpilot.galaxy.spotify_setup.SpotifySetup.prepare_phone_pairing'), \ + mock.patch.object(self.bluetooth, 'snapshot', return_value={'adapterAddress': 'AA:BB:CC:DD:EE:FF', 'adapterName': 'comma'}): + self.assertIsNone(claim.window(claim.CLAIM_PATH)) + self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'accept_pair'}, cookie)[0], 200) + window = claim.window(claim.CLAIM_PATH) + self.assertEqual((window['adapterAddress'], window['adapterName']), ('AA:BB:CC:DD:EE:FF', 'comma')) + self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'cancel_pair'}, cookie)[0], 200) + self.assertIsNone(claim.window(claim.CLAIM_PATH)) + self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'scan'}, cookie)[0], 200) + self.assertIsNone(claim.window(claim.CLAIM_PATH)) + + def test_forgetting_a_device_also_forgets_its_app_key(self): + self.stop() + aa_setup = mock.Mock() + aa_setup.enabled.return_value = False + self.server = make_server(port=0, owner=self.access, bluetooth=self.bluetooth, android_auto_setup=aa_setup, parked=lambda: True) + self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True) + self.worker.start() + _, _, headers = self.request('/api/auth/login', {'password': 'password123'}) + cookie = headers['Set-Cookie'].split(';', 1)[0] + with mock.patch('openpilot.starpilot.galaxy_bluetooth.bridge.pairing.remove_by_address') as remove: + self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'connect', 'address': 'AA:BB:CC:DD:EE:FF'}, cookie)[0], 200) + remove.assert_not_called() + self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'forget', 'address': 'AA:BB:CC:DD:EE:FF'}, cookie)[0], 200) + remove.assert_called_once_with('AA:BB:CC:DD:EE:FF') + def test_authenticated_status_and_parked_mutation(self): self.assertEqual(self.request('/api/bluetooth/status')[0], 401) code, _, headers = self.request('/api/auth/login', {'password': 'password123'}) self.assertEqual(code, 200) cookie = headers['Set-Cookie'].split(';', 1)[0] self.assertEqual(self.request('/api/bluetooth/status', cookie=cookie)[1]['powered'], True) + self.assertEqual(self.request('/api/bluetooth/status', cookie=cookie)[1]['claim']['open'], False) self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'connect', 'address': 'AA:BB:CC:DD:EE:FF'}, cookie)[0], 200) self.assertEqual(self.bluetooth.calls[0][:3], ('connect', 'AA:BB:CC:DD:EE:FF', None)) self.bluetooth.parked = False diff --git a/openpilot/starpilot/galaxy/tests/test_companion_http.py b/openpilot/starpilot/galaxy/tests/test_companion_http.py new file mode 100644 index 0000000000..a066286ecb --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_companion_http.py @@ -0,0 +1,212 @@ +import http.client +import json +from pathlib import Path +import sys +import threading +from unittest.mock import Mock, patch + +import pytest + +from openpilot.starpilot.galaxy.access import GalaxyAccessOwner +from openpilot.starpilot.galaxy.companion import CompanionRegistry +from openpilot.starpilot.galaxy.server import make_server + + +@pytest.fixture +def companion_http(tmp_path): + registry = CompanionRegistry(tmp_path / 'settings', tmp_path / 'lease', tmp_path / 'status') + access = GalaxyAccessOwner(tmp_path / 'access') + access.configure('password123', lambda: True) + aa = Mock() + aa.enabled.return_value = False + parked = Mock(return_value=True) + server = make_server(port=0, owner=access, companion=registry, parked=parked, android_auto_setup=aa) + server.test_parked = parked + worker = threading.Thread(target=server.serve_forever, kwargs={'poll_interval': .01}, daemon=True) + worker.start() + + def request(path, payload=None, cookie='', origin=None, method=None): + connection = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3) + headers = {'Cookie': cookie} + if payload is not None: + headers.update({'Content-Type': 'application/json', 'Origin': origin or f'http://127.0.0.1:{server.server_port}'}) + try: + connection.request(method or ('POST' if payload is not None else 'GET'), path, + json.dumps(payload) if payload is not None else None, headers) + response = connection.getresponse() + return response.status, response.read(), dict(response.getheaders()) + finally: + connection.close() + try: + yield request, registry, server + finally: + server.shutdown() + worker.join(timeout=2) + server.server_close() + + +def test_auth_and_local_mutations(companion_http): + request, registry, _ = companion_http + assert request('/api/companion/status')[0] == 401 + assert request('/api/companion/settings', {'enabled': True})[0] == 401 + with patch('openpilot.starpilot.galaxy.server.direct_local_connection', return_value=True): + status, _, headers = request('/api/auth/session') + assert status == 200 + cookie = headers['Set-Cookie'].split(';', 1)[0] + assert request('/api/companion/settings', {'enabled': True}, cookie, 'https://evil.test')[0] == 403 + assert not registry.enabled() + assert request('/api/companion/settings', {'enabled': True}, cookie)[0] == 200 + assert registry.enabled() + assert request('/api/companion/settings', {'enabled': 1}, cookie)[0] == 400 + # A signed-in LAN client still cannot register a SOCKS endpoint outside galaxy0. + payload = {'port': 1080, 'user': 'a' * 32, 'pass': 'b' * 32, 'version': '1.0.0', 'protocol': 1} + assert request('/api/companion/uplink', payload, cookie)[0] == 400 + assert registry.lease() is None + status, body, _ = request('/api/companion/status', cookie=cookie) + assert status == 200 and json.loads(body)['enabled'] + assert b'pass' not in body + assert request('/api/companion/qr.svg', cookie=cookie)[0] == 200 + + +def test_apk_direct_download_without_cookie_and_head(companion_http, tmp_path): + request, _, server = companion_http + apk = tmp_path / 'sample.apk' + apk.write_bytes(b'APK' * 30000) + # Path is captured during server construction; patch its file operation. + from openpilot.starpilot.galaxy.companion import APK_PATH + real_open = type(APK_PATH).open + + def open_apk(path, *args, **kwargs): + return real_open(apk if path == APK_PATH else path, *args, **kwargs) + + with patch('openpilot.starpilot.galaxy.server.direct_local_connection', return_value=True), patch('pathlib.Path.open', open_apk): + code, body, headers = request('/app.apk') + assert code == 200 and body == apk.read_bytes() + assert headers['Content-Type'] == 'application/vnd.android.package-archive' + code, body, headers = request('/api/companion/app.apk', method='HEAD') + assert code == 200 and body == b'' and headers['Content-Length'] == '90000' + server.remote_transport = True + assert request('/app.apk')[0] == 403 + + +def phone_bridge_proxy(port): + # The phone bridge imports its siblings directly, exactly as it runs on comma. + bridge = str(Path(__file__).resolve().parents[2] / 'galaxy_bluetooth/bridge') + if bridge not in sys.path: + sys.path.insert(0, bridge) + from proxy import GalaxyProxy + return GalaxyProxy(port) + + +def test_phone_bridge_against_current_galaxy(companion_http): + _, registry, server = companion_http + proxy = phone_bridge_proxy(server.server_port) + request = {'id': 'test', 'method': 'GET', 'path': '/api/companion/status'} + assert proxy.handle(request)['status'] == 200 + token = proxy.cookie + assert token.startswith('galaxy_session=') + import base64 + request.update(method='POST', path='/api/companion/settings', headers={'Content-Type': 'application/json'}, + body=base64.b64encode(b'{"enabled":true}').decode()) + assert proxy.handle(request)['status'] == 200 + assert registry.enabled() + assert proxy.cookie == token + for path in ('/api/companion/uplink', '/api/companion/app.apk'): + request['path'] = path + assert proxy.handle(request)['status'] == 501 + + +def test_bluetooth_runtime_tracks_upstream_restart_and_blocks_replay(companion_http): + import base64 + request, _, server = companion_http + proxy = phone_bridge_proxy(server.server_port) + for instance in ('before-restart', 'after-restart'): + with patch('openpilot.starpilot.galaxy.server.INSTANCE', instance): + result = proxy.handle({'id': 'runtime', 'method': 'GET', 'path': '/api/companion/runtime'}) + assert result['status'] == 200 + runtime = json.loads(base64.b64decode(result['body'])) + assert runtime == json.loads(request('/data/runtime.json')[1]) + assert runtime['instance'] == instance + for path in ('/api/replay/start', '/api/replay/media?sequence=1'): + assert proxy.handle({'id': 'replay', 'method': 'POST', 'path': path})['status'] == 501 + + +def test_web_manifest_lists_verifiable_files_the_apps_accept(companion_http): + import hashlib + from openpilot.starpilot.galaxy.server import WEB + request, _, _ = companion_http + status, body, _ = request('/api/companion/web-manifest') + assert status == 200 + manifest = json.loads(body) + assert manifest['schema'] == 1 + files = manifest['files'] + paths = [entry['path'] for entry in files] + assert paths == sorted(paths) and len(set(paths)) == len(paths) + assert 'data/runtime.json' not in paths and not any(p.endswith('.sample.json') for p in paths) + for entry in files: + data = (WEB / entry['path']).read_bytes() + assert entry['size'] == len(data) and entry['sha256'] == hashlib.sha256(data).hexdigest() + assert request('/' + entry['path'])[1] == data + # The companion apps reject a download that breaks these rules, so catch a new root file or odd name here. + root_files = {'index.html', 'manifest.webmanifest', 'sentry-push-worker.js'} + root_dirs = ('assets/', 'css/', 'js/', 'vendor/', 'data/') + allowed = set('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-./_') + assert 1 <= len(files) <= 512 and sum(entry['size'] for entry in files) <= 32 * 1024 * 1024 + for entry in files: + assert set(entry['path']) <= allowed and entry['size'] <= 4 * 1024 * 1024 + assert entry['path'] in root_files or entry['path'].startswith(root_dirs), entry['path'] + assert {'index.html', 'js/boot.js', 'js/app.js', 'data/catalog.json'} <= set(paths) + + +def test_phone_location_needs_local_session(tmp_path): + access = GalaxyAccessOwner(tmp_path / 'access') + access.configure('password123', lambda: True) + phone = Mock() + phone.publish.side_effect = lambda payload: {'ok': True} if payload.get('ageMs', 0) <= 2000 else (_ for _ in ()).throw(ValueError('Location is too old')) + aa = Mock() + aa.enabled.return_value = False + server = make_server(port=0, owner=access, companion=CompanionRegistry(tmp_path / 's', tmp_path / 'l', tmp_path / 't'), + parked=Mock(return_value=True), android_auto_setup=aa, phone_location=phone) + worker = threading.Thread(target=server.serve_forever, kwargs={'poll_interval': .01}, daemon=True) + worker.start() + origin = f'http://127.0.0.1:{server.server_port}' + + def post(payload, cookie=''): + connection = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3) + try: + connection.request('POST', '/api/companion/location', json.dumps(payload), + {'Cookie': cookie, 'Content-Type': 'application/json', 'Origin': origin}) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + try: + fix = {'latitude': 40., 'longitude': -110., 'horizontalAccuracy': 5., 'ageMs': 200} + assert post(fix)[0] == 401 + with patch('openpilot.starpilot.galaxy.server.direct_local_connection', return_value=True): + connection = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3) + connection.request('GET', '/api/auth/session') + cookie = connection.getresponse().getheader('Set-Cookie').split(';', 1)[0] + connection.close() + assert post(fix, cookie) == (200, {'ok': True}) + assert post({**fix, 'ageMs': 5000}, cookie) == (400, {'error': 'Location is too old'}) + server.remote_transport = True + assert post(fix, cookie)[0] == 403 + assert phone.publish.call_count == 2 + finally: + server.shutdown() + worker.join(timeout=2) + server.server_close() + + +def test_bluetooth_ready_needs_a_session_and_reports_what_blocks_pairing(companion_http): + request, _, _ = companion_http + assert request('/api/companion/bluetooth-ready')[0] == 401 + with patch('openpilot.starpilot.galaxy.server.direct_local_connection', return_value=True): + cookie = request('/api/auth/session')[2]['Set-Cookie'].split(';', 1)[0] + status, body, _ = request('/api/companion/bluetooth-ready', cookie=cookie) + assert status == 200 + answer = json.loads(body) + assert answer['schema'] == 1 and isinstance(answer['ready'], bool) + assert answer['code'] in {'ready', 'starting', 'park_required', 'bluetooth_off', 'bluetooth_unavailable', 'bridge_starting'} + assert answer['message'] diff --git a/openpilot/starpilot/galaxy/tests/test_http_server.py b/openpilot/starpilot/galaxy/tests/test_http_server.py index 4e91daf91f..77d99c1014 100644 --- a/openpilot/starpilot/galaxy/tests/test_http_server.py +++ b/openpilot/starpilot/galaxy/tests/test_http_server.py @@ -73,6 +73,9 @@ def server(tmp_path, monkeypatch): self.json(413, {'error': 'Rejected before reading the body'}) with _LocalHTTPServer(('127.0.0.1', 0), Handler) as http: + services: set[int] = set() + http.loopback = lambda request: request.getpeername()[1] in services + http.services = services thread = threading.Thread(target=http.serve_forever, kwargs={'poll_interval': .01}, daemon=True) thread.start() try: @@ -158,3 +161,46 @@ def test_idle_persistent_connections_release_capacity(server): finally: for connection in held: connection.close() + + +def test_full_browser_pool_still_serves_comma_services(server): + running, _, _ = server + held = [] + try: + for _ in range(running.MAX_CONNECTIONS + 2): + held.append(socket.create_connection(('127.0.0.1', running.server_port), timeout=2)) + service = socket.socket() + service.bind(('127.0.0.1', 0)) + running.services.add(service.getsockname()[1]) + service.settimeout(1) + service.connect(('127.0.0.1', running.server_port)) + with closing(service): + service.sendall(b'GET /service HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n') + assert service.recv(64).startswith(b'HTTP/1.1 200') + finally: + for connection in held: + connection.close() + + +def test_drain_turns_away_requests_still_waiting_for_capacity(server): + running, state, _ = server + held = [socket.create_connection(('127.0.0.1', running.server_port), timeout=2) for _ in range(running.MAX_CONNECTIONS)] + try: + with closing(socket.create_connection(('127.0.0.1', running.server_port), timeout=2)) as waiting: + waiting.sendall(b'GET /late HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n') + drained: list[bool] = [] + drain = threading.Thread(target=lambda: drained.append(running.drain_requests(2))) + drain.start() + for connection in held: + connection.close() + held.clear() + drain.join(3) + assert drained == [True] + try: + assert waiting.recv(64) == b'' + except ConnectionResetError: + pass + assert '/late' not in state['requests'] + finally: + for connection in held: + connection.close() diff --git a/openpilot/starpilot/galaxy/tests/test_pairing_ready.py b/openpilot/starpilot/galaxy/tests/test_pairing_ready.py new file mode 100644 index 0000000000..249a4ed3bc --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_pairing_ready.py @@ -0,0 +1,40 @@ +from unittest.mock import patch + +from openpilot.starpilot.galaxy.pairing_ready import BOOT_GRACE_SECONDS, TRANSIENT, bridge_state, pairing_readiness + +GOOD = {'available': True, 'powered': True, 'errorCode': None} + + +def ask(*, uptime=600., parked=True, state=None, bridge=('ready', 'Ready to pair')): + def snapshot(): + if isinstance(state, Exception): + raise state + return GOOD if state is None else state + return pairing_readiness(parked=lambda: parked, snapshot=snapshot, bridge=lambda: bridge, uptime=lambda: uptime) + + +def test_ready_only_when_everything_a_phone_needs_is_up(): + assert ask() == {'schema': 1, 'ready': True, 'code': 'ready', 'message': 'Ready to pair.'} + + +def test_each_blocker_has_its_own_code_in_the_order_a_person_would_fix_them(): + assert ask(uptime=BOOT_GRACE_SECONDS - 1)['code'] == 'starting' + assert ask(parked=False)['code'] == 'park_required' + assert ask(state={'available': True, 'powered': False})['code'] == 'bluetooth_off' + assert ask(state={'available': True, 'powered': True, 'errorCode': 'adapter_unavailable'})['code'] == 'bluetooth_unavailable' + assert ask(state=OSError('gone'))['code'] == 'bluetooth_unavailable' + assert ask(state=ValueError('anything else'))['code'] == 'bluetooth_unavailable', 'never an error' + assert ask(bridge=('pending', 'Bridge startup pending')) == { + 'schema': 1, 'ready': False, 'code': 'bridge_starting', 'message': 'Bridge startup pending'} + assert ask(uptime=1, parked=False)['code'] == 'starting', 'boot comes first' + + +def test_only_boot_and_bridge_start_are_worth_waiting_for(): + assert TRANSIENT == {'starting', 'bridge_starting'} + assert ask(parked=False)['code'] not in TRANSIENT + assert ask(state={'available': True, 'powered': False})['code'] not in TRANSIENT + + +def test_an_unreadable_or_stubbed_bridge_module_reads_as_not_ready_instead_of_failing(): + with patch.dict('sys.modules', {'openpilot.starpilot.galaxy_bluetooth.bootstrap': object()}): + assert bridge_state() == ('pending', 'Bridge startup pending') diff --git a/openpilot/starpilot/galaxy/tests/test_preview_contract.py b/openpilot/starpilot/galaxy/tests/test_preview_contract.py index 4ce82f7c41..3dfcba5009 100644 --- a/openpilot/starpilot/galaxy/tests/test_preview_contract.py +++ b/openpilot/starpilot/galaxy/tests/test_preview_contract.py @@ -18,8 +18,7 @@ class PreviewContractTest(unittest.TestCase): self.assertTrue(all(path.startswith("/") and path != "/tools" for path in paths)) self.assertEqual([tool["path"] for tool in catalog["tools"] if tool["availability"] == "partial-preview"], ["/logs"]) self.assertEqual([tool["path"] for tool in catalog["tools"] if tool["availability"] == "local-only"], - ["/bluetooth", "/cameras", "/galaxy", "/manage_models", "/model_laboratory", - "/navigation", "/system", "/theme_maker", "/tuning/plots", "/vehicle", "/android-auto"]) + ["/bluetooth", "/cameras", "/galaxy", "/manage_models", "/model_laboratory", "/navigation", "/system", "/theme_maker", "/tuning/plots", "/vehicle", "/android-auto", "/spotify"]) self.assertEqual([tool["path"] for tool in catalog["tools"] if tool["availability"] == "always"], []) self.assertTrue(all(tool["availability"] in {"always", "unavailable", "partial-preview", "local-only"} for tool in catalog["tools"])) self.assertTrue(all(tool["name"] and tool["description"] and tool["icon"] for tool in catalog["tools"])) diff --git a/openpilot/starpilot/galaxy/tests/test_remote_pairing.py b/openpilot/starpilot/galaxy/tests/test_remote_pairing.py index 329e99bd65..8d27e5c6c4 100644 --- a/openpilot/starpilot/galaxy/tests/test_remote_pairing.py +++ b/openpilot/starpilot/galaxy/tests/test_remote_pairing.py @@ -19,6 +19,9 @@ class RemotePairingTest(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) + claim_path = mock.patch('openpilot.starpilot.galaxy_bluetooth.bridge.claim.CLAIM_PATH', Path(temporary.name) / 'claim.json') + claim_path.start() + self.addCleanup(claim_path.stop) root = Path(temporary.name) self.owner = GalaxyAccessOwner(root / 'credentials') self.pairing = RemotePairing(root / 'pairing') @@ -186,7 +189,7 @@ class RemotePairingTest(unittest.TestCase): wrong_cookie = 'galaxy_session=' + record['slug'] + '%3A' + 'a' * 64 self.assertEqual(self.request(self.remote, '/api/galaxy/device-name', host=host, cookie=wrong_cookie)[0], 401) created = [] - def bluetooth_factory(authority, session_valid): + def bluetooth_factory(authority, session_valid, **_kwargs): owner = mock.Mock() owner.session_valid = session_valid owner.identities = [] @@ -200,7 +203,7 @@ class RemotePairingTest(unittest.TestCase): mock.patch('openpilot.starpilot.galaxy.settings.LiveContextSource'): status, body, _ = self.request(self.remote, '/api/bluetooth/status', host=host, cookie=cookie) self.assertEqual(status, 200) - self.assertEqual(json.loads(body), {'available': True}) + self.assertIs(json.loads(body)['available'], True) bluetooth = created[0] identity = bluetooth.identities[0] self.assertTrue(bluetooth.session_valid(identity)) diff --git a/openpilot/starpilot/galaxy/tests/test_spotify_setup.py b/openpilot/starpilot/galaxy/tests/test_spotify_setup.py new file mode 100644 index 0000000000..9ec42298a1 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_spotify_setup.py @@ -0,0 +1,251 @@ +import http.client +import io +import json +import threading + +import pytest + +from openpilot.starpilot.galaxy.access import GalaxyAccessOwner +from openpilot.starpilot.galaxy.http_server import install_origin_allowed +from openpilot.starpilot.galaxy.server import make_server +from openpilot.starpilot.galaxy.spotify_setup import SetupRejected, SpotifySetup +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}, + {'address': '11:22:33:44:55:66', 'name': 'Nearby', 'paired': False, 'connected': False}] + + +class FakeImport: + def __init__(self, layout): + self.work_dir = layout.imports + self.started = [] + self.running = False + def busy(self): return self.running + def status(self): return {'state': 'running' if self.running else 'idle'} + def start(self, *, path, enabled, may_activate): + self.started.append(path.read_bytes()) + self.running = True + + +class FakeClient: + def __init__(self): + self.available = False + self.calls = [] + def call(self, command, **payload): + if not self.available: + raise RuntimeError('Spotify isn’t running') + self.calls.append((command, payload)) + if command == 'status': + return {'status': {'state': 'ready', 'player': None}} + return {'ok': True} + + +@pytest.fixture +def owner(tmp_path): + state = {'parked': True, 'valid': True} + layout = Layout(tmp_path / 'soloist') + client = FakeClient() + setup = SpotifySetup(parked=lambda: state['parked'], session_valid=lambda _s: state['valid'], layout=layout, + client=client, import_job=FakeImport(layout), # ty: ignore[invalid-argument-type] # test doubles + paired_devices=lambda _s: DEVICES) + return setup, state, layout, client + + +def test_status_never_contains_the_key(owner): + setup, _, layout, _ = 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['links']['download'].startswith('https://developer.spotify.com/') + with pytest.raises(SetupRejected): + setup.set_key(SESSION, 'bad key') + + +def test_output_must_be_a_paired_device_and_needs_park(owner): + setup, state, layout, _ = owner + with pytest.raises(SetupRejected): + setup.save_preferences(SESSION, {'output': 'bluetooth', 'outputAddress': '11:22:33:44:55:66'}) + setup.save_preferences(SESSION, {'output': 'bluetooth', 'outputAddress': 'aa:bb:cc:dd:ee:ff'}) + assert preferences.load(layout).output_name == 'My Car' + state['parked'] = False + with pytest.raises(SetupRejected): + setup.save_preferences(SESSION, {'output': 'test', 'outputAddress': ''}) + setup.save_preferences(SESSION, {'deviceName': 'Civic'}) # naming is not configuration of the player output + assert preferences.load(layout).device_name == 'Civic' + with pytest.raises(SetupRejected): + setup.save_preferences(SESSION, {'apiKey': 'x'}) + + +def test_upload_streams_to_the_import_job_only_when_parked(owner): + setup, state, _, _ = owner + setup.upload(SESSION, io.BytesIO(b'\x1f\x8bdata'), 6) + assert setup.job.started == [b'\x1f\x8bdata'] + setup.job.running = False + state['parked'] = False + with pytest.raises(SetupRejected): + setup.upload(SESSION, io.BytesIO(b'x'), 1) + state['parked'], state['valid'] = True, False + with pytest.raises(SetupRejected): + setup.upload(SESSION, io.BytesIO(b'x'), 1) + + +def test_remove_deletes_only_feature_data(owner, monkeypatch): + setup, _, layout, _ = owner + credentials.store('sk_live_0123456789abcdef', layout) + preferences.set_enabled(True, layout) + monkeypatch.setattr('openpilot.starpilot.soloist.supervisor.reap_orphans', lambda _l: 0) + status = setup.remove(SESSION) + assert not layout.root.exists() and status['enabled'] is False and status['keyPresent'] is False + + +def test_controls_go_through_the_supervisor(owner): + setup, _, _, client = owner + client.available = True + setup.control(SESSION, 'pause', {}) + assert client.calls[-1] == ('control', {'action': 'pause', 'args': {}}) + with pytest.raises(SetupRejected): + setup.control(SESSION, 'pause', []) + + +@pytest.fixture +def api(tmp_path, owner): + setup, state, layout, client = owner + access = GalaxyAccessOwner(tmp_path / 'access') + assert access.configure('password123', lambda: True) + server = make_server(port=0, owner=access, parked=lambda: state['parked'], spotify_setup=setup) + worker = threading.Thread(target=server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True) + worker.start() + def request(method, path, body=None, *, cookie='', content_type='application/json'): + conn = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3) + headers = {'Cookie': cookie} + if method != 'GET': + headers['Origin'] = f'http://127.0.0.1:{server.server_port}' + if body is not None: + headers['Content-Type'] = content_type + try: + conn.request(method, path, body=body, headers=headers) + response = conn.getresponse() + return response.status, json.loads(response.read()) + finally: + conn.close() + conn = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3) + conn.request('POST', '/api/auth/login', body=json.dumps({'password': 'password123'}), + headers={'Content-Type': 'application/json', 'Origin': f'http://127.0.0.1:{server.server_port}'}) + response = conn.getresponse() + response.read() + cookie = response.getheader('Set-Cookie').split(';', 1)[0] + conn.close() + yield request, cookie, setup, layout + server.shutdown() + worker.join(timeout=2) + server.server_close() + + +def test_routes_require_a_session(api): + request, _, _, _ = api + assert request('GET', '/api/spotify/setup')[0] == 401 + assert request('POST', '/api/spotify/key', json.dumps({'key': 'sk_live_0123456789abcdef'}))[0] == 401 + assert request('POST', '/api/spotify/upload', b'\x1f\x8b', content_type='application/octet-stream')[0] == 401 + assert request('DELETE', '/api/spotify')[0] == 401 + + +def test_routes_round_trip(api, monkeypatch): + request, cookie, setup, layout = api + code, status = request('GET', '/api/spotify/setup', cookie=cookie) + assert code == 200 and status['release'] == {'installed': False} + code, status = request('POST', '/api/spotify/key', json.dumps({'key': 'sk_live_0123456789abcdef'}), cookie=cookie) + assert code == 200 and status['keyPresent'] is True and 'sk_live' not in json.dumps(status) + assert request('POST', '/api/spotify/key', json.dumps({'key': 'nope'}), cookie=cookie)[0] == 409 + assert request('POST', '/api/spotify/key', json.dumps({'key': 'sk_live_0123456789abcdef', 'x': 1}), cookie=cookie)[0] == 400 + code, status = request('POST', '/api/spotify/enable', json.dumps({'enabled': True}), cookie=cookie) + assert code == 200 and status['enabled'] is True and layout.enabled_flag.exists() + code, status = request('POST', '/api/spotify/preferences', json.dumps({'output': 'test', 'outputAddress': ''}), cookie=cookie) + assert code == 200 and status['preferences']['output'] == 'test' + # The phone Bluetooth bridge reaches the server from loopback, so installs are refused here. + code, refusal = request('POST', '/api/spotify/upload', b'\x1f\x8bxx', cookie=cookie, content_type='application/octet-stream') + assert code == 403 and 'local network' in refusal['error'] and setup.job.started == [] + assert request('GET', '/api/spotify/setup', cookie=cookie)[1]['uploadAllowed'] is False + # LAN, hotspot and tunnel peers are covered by the policy test below; here only the route's own checks are exercised. + monkeypatch.setattr('openpilot.starpilot.galaxy.server.install_origin_allowed', lambda *_args, **_kwargs: True) + assert request('GET', '/api/spotify/setup', cookie=cookie)[1]['uploadAllowed'] is True + assert request('POST', '/api/spotify/upload', b'\x1f\x8bxx', cookie=cookie, content_type='application/json')[0] == 415 + code, status = request('POST', '/api/spotify/upload', b'\x1f\x8bxx', cookie=cookie, content_type='application/octet-stream') + assert code == 202 and setup.job.started == [b'\x1f\x8bxx'] + assert request('POST', '/api/spotify/control', json.dumps({'action': 'pause'}), cookie=cookie)[0] == 409 # not running + + +def test_phone_source_requires_park_and_a_paired_device_without_spotify(owner): + setup, state, layout, _ = owner + state['parked'] = False + with pytest.raises(SetupRejected, match='Park'): + setup.save_preferences(SESSION, {'source': 'phone'}) + state['parked'] = True + with pytest.raises(SetupRejected, match='Pair your phone'): + setup.save_preferences(SESSION, {'source': 'phone', 'phoneAddress': '11:22:33:44:55:66'}) + result = setup.save_preferences(SESSION, {'source': 'phone', 'phoneAddress': 'AA:BB:CC:DD:EE:FF', 'output': 'android_auto'}) + assert result['preferences']['source'] == 'phone' and result['preferences']['phoneName'] == 'My Car' + assert not result['keyPresent'] and not result['release']['installed'] + with pytest.raises(SetupRejected, match='different device'): + setup.save_preferences(SESSION, {'output': 'bluetooth', 'outputAddress': 'AA:BB:CC:DD:EE:FF'}) + assert preferences.load(layout).output == 'android_auto' + + +@pytest.mark.parametrize('peer,destination,headers,remote,allowed', [ + ('192.168.1.20', '192.168.1.5', {}, False, True), # home LAN + ('172.31.254.10', '172.31.254.1', {}, False, True), # Galaxy hotspot + ('10.0.0.8', '10.0.0.2', {}, False, True), + ('127.0.0.1', '127.0.0.1', {}, False, False), # phone Bluetooth bridge and anything local to the comma + ('127.0.0.1', '127.0.0.1', {}, True, True), # Galaxy tunnel + ('203.0.113.9', '192.168.1.5', {}, False, False), # public peer + ('192.168.1.20', '192.168.1.5', {'X-Forwarded-For': '8.8.8.8'}, False, False), + ('not-an-address', '192.168.1.5', {}, False, False), +]) +def test_install_origin_policy(peer, destination, headers, remote, allowed): + assert install_origin_allowed(peer, destination, headers, remote) is allowed + + +def test_adopt_phone_offers_only_an_unchosen_audio_source_and_never_overrides(owner): + setup, _, layout, _ = owner + a2dp = '0000110a-0000-1000-8000-00805f9b34fb' + assert not setup.adopt_phone('11:22:33:44:55:66', 'Pixel', ['0000111e-0000-1000-8000-00805f9b34fb']) + assert preferences.load(layout).phone_address == '' + assert setup.adopt_phone('11:22:33:44:55:66', 'Pixel', [a2dp.upper()]) + chosen = preferences.load(layout) + assert (chosen.phone_address, chosen.phone_name, chosen.source) == ('11:22:33:44:55:66', 'Pixel', 'phone') + assert not setup.adopt_phone('66:55:44:33:22:11', 'Other', [a2dp]) + assert preferences.load(layout).phone_address == '11:22:33:44:55:66' + + +def test_adopt_phone_keeps_spotify_when_soloist_is_installed(owner): + setup, _, layout, _ = owner + release = layout.current + release.mkdir(parents=True) + (release / 'soloist').write_text('x') + (release / 'release.json').write_text('{}') + assert setup.adopt_phone('11:22:33:44:55:66', 'Pixel', ['0000110a-0000-1000-8000-00805f9b34fb']) + chosen = preferences.load(layout) + assert (chosen.phone_address, chosen.source) == ('11:22:33:44:55:66', 'spotify') + + +def test_prepare_phone_pairing_only_talks_to_a_running_enabled_feature(owner): + setup, _, layout, client = owner + setup.prepare_phone_pairing() + assert client.calls == [] + preferences.set_enabled(True, layout) + setup.prepare_phone_pairing() + assert client.calls == [] + client.available = True + setup.prepare_phone_pairing() + assert client.calls == [('prepare_phone_pairing', {})] + + +def test_unpaired_connected_device_is_not_offered_or_accepted_as_a_phone(owner): + setup, _, _, _ = owner + connected_only = {'address': '66:55:44:33:22:11', 'name': 'Companion phone', 'paired': False, 'connected': True} + setup.paired_devices = lambda _s: [*DEVICES, connected_only] + assert '66:55:44:33:22:11' not in {device['address'] for device in setup.status(SESSION)['devices']} + with pytest.raises(SetupRejected, match='Pair your phone'): + setup.save_preferences(SESSION, {'source': 'phone', 'phoneAddress': '66:55:44:33:22:11'}) diff --git a/openpilot/starpilot/galaxy/web_manifest.py b/openpilot/starpilot/galaxy/web_manifest.py new file mode 100644 index 0000000000..ff189f2b08 --- /dev/null +++ b/openpilot/starpilot/galaxy/web_manifest.py @@ -0,0 +1,39 @@ +"""Inventory of the Galaxy web files, so companion apps can download and verify them from comma.""" +import hashlib +from pathlib import Path + +# Served dynamically or preview-only; never part of the files an app caches. +EXCLUDED = ('data/runtime.json',) + +_cache: dict[str, tuple[tuple, dict]] = {} + + +def _commit() -> str | None: + try: + from openpilot.common.params import Params + value = Params().get('GitCommit') + except Exception: + return None + text = value.decode() if isinstance(value, bytes) else value + return text.strip() or None if isinstance(text, str) else None + + +def _wanted(relative: str) -> bool: + return relative not in EXCLUDED and not relative.endswith('.sample.json') + + +def web_manifest(web: Path) -> dict: + files = [] + for path in sorted(p for p in web.rglob('*') if p.is_file()): + relative = path.relative_to(web).as_posix() + if not _wanted(relative): + continue + stat = path.stat() + identity = (stat.st_ino, stat.st_mtime_ns, stat.st_size) + cached = _cache.get(str(path)) + if cached is None or cached[0] != identity: + body = path.read_bytes() + cached = (identity, {'path': relative, 'size': len(body), 'sha256': hashlib.sha256(body).hexdigest()}) + _cache[str(path)] = cached + files.append(cached[1]) + return {'schema': 1, 'commit': _commit(), 'files': files} diff --git a/openpilot/starpilot/galaxy_bluetooth/bootstrap.py b/openpilot/starpilot/galaxy_bluetooth/bootstrap.py index 274b6c5268..7439dcc4e2 100644 --- a/openpilot/starpilot/galaxy_bluetooth/bootstrap.py +++ b/openpilot/starpilot/galaxy_bluetooth/bootstrap.py @@ -29,15 +29,20 @@ def write_json(path, value): os.unlink(temporary) -def setup_status(data=DATA): +def setup_state(data=DATA): + """(state, message); the state is 'ready' only while a fresh status file says the bridge is ready.""" try: value = json.loads((data / 'setup-status.json').read_text()) # Monotonic timestamps: a file left from a previous boot reads as stale. if not 0 <= time.monotonic() - value['updated'] <= STATUS_STALE: - return 'Bridge startup pending' - return value['message'] + return 'pending', 'Bridge startup pending' + return str(value.get('state', 'pending')), value['message'] except (OSError, ValueError, KeyError, TypeError): - return 'Bridge startup pending' + return 'pending', 'Bridge startup pending' + + +def setup_status(data=DATA): + return setup_state(data)[1] def retry_setup(data=DATA, *, restart=True): diff --git a/openpilot/starpilot/galaxy_bluetooth/bridge/server.py b/openpilot/starpilot/galaxy_bluetooth/bridge/server.py index 6e7b8863de..27da001c7a 100755 --- a/openpilot/starpilot/galaxy_bluetooth/bridge/server.py +++ b/openpilot/starpilot/galaxy_bluetooth/bridge/server.py @@ -53,6 +53,7 @@ class Session: self.closed = False self.phone = None # (id, key) of the phone whose key opened this session self.introduced = False + self.claiming = False def close(self): self.closed = True @@ -86,6 +87,8 @@ class Gateway: if keys != self.keys: # Only sessions of forgotten (or re-keyed) phones are revoked; other phones stay connected. for path, state in list(self.sessions.items()): + if state.phone is None and state.claiming: + continue if state.phone is None or keys.get(state.phone[0]) != state.phone[1]: state.close() del self.sessions[path] @@ -104,7 +107,12 @@ class Gateway: window = claim.window(self.claim_path) if window is None: raise ValueError("Pairing window closed") - phone_id, key = await asyncio.to_thread(add_phone, name, self.key_path) + state.claiming = True + try: + phone_id, key = await asyncio.to_thread(add_phone, name, self.key_path) + state.phone = (phone_id, key) + finally: + state.claiming = False try: body = claim.reply(public, state.challenge, {"key": key.hex(), "id": phone_id, "adapterAddress": window["adapterAddress"], "adapterName": window["adapterName"]}) @@ -116,8 +124,10 @@ class Gateway: linked = await asyncio.to_thread(claim.take_link, self.claim_path) if linked: await asyncio.to_thread(link_address, linked[0], linked[1], self.key_path) - # The session now belongs to the new phone, so the key change does not close it before the reply is read. - state.phone = (phone_id, key) + if state.closed or load_keys(self.key_path).get(phone_id) != key: + state.closed = True + state.phone = None + raise ValueError("Pairing key was removed before delivery") frame = struct.pack(">I", len(body)) + body state.notification_stream = False state.notification_tag = b"" diff --git a/openpilot/starpilot/galaxy_bluetooth/phone_ui.py b/openpilot/starpilot/galaxy_bluetooth/phone_ui.py index fe1fa3b270..98238f7e6d 100644 --- a/openpilot/starpilot/galaxy_bluetooth/phone_ui.py +++ b/openpilot/starpilot/galaxy_bluetooth/phone_ui.py @@ -1,5 +1,6 @@ """One Pair phone button beside the Bluetooth controls: the StarPilot app and phone music pair in one window.""" import base64 +import datetime import json import threading import time @@ -41,6 +42,40 @@ def forget_phone_keys(allowed, address): pass +def app_pairing_title(phone, phones): + """The phone's name; the added date only tells apart phones that share a name.""" + label = phone['label'] + if sum(1 for other in phones if other['label'] == label) > 1: + try: + added = datetime.datetime.fromisoformat(phone['added']) + return f'{label} ({added:%b} {added.day})' + except (KeyError, ValueError): + pass + return label + + +def app_pairing_subtitle(phone): + try: + used = datetime.date.fromisoformat(phone['last_used']) + except (KeyError, ValueError): + return 'not used yet' + return f'last used {used:%b} {used.day}' + + +def forget_app_phone(allowed, phone_id): + if not permitted(allowed): + return + try: + remove_phone(phone_id) + except (OSError, ValueError): + pass + + +def is_app_link(device): + """A phone that is connected to comma's Bluetooth bridge for the StarPilot app but never bonded for music or audio.""" + return bool(device.get('connected')) and not device.get('paired') and not device.get('trusted') + + def forget_app_only(allowed): if not permitted(allowed): return @@ -285,10 +320,10 @@ def large_phone_rows(allowed): rows = [button_item('Pair phone', 'Pair', description='Pairs the StarPilot app and phone music in one step', callback=lambda: open_pairing(allowed), enabled=lambda: permitted(allowed))] if app_only_count(): - rows.append(button_item('Forget app-only phones', 'Forget', - description=f'{app_only_count()} phones have an app key but no Bluetooth device', + rows.append(button_item('Forget app pairings', 'Forget', + description=f'{app_only_count()} saved for the StarPilot app only (no Bluetooth music pairing)', callback=lambda: gui_app.push_widget(ConfirmDialog( - 'Forget phones that only have the StarPilot app? They must pair again.', 'Forget', + 'Forget phones saved for the StarPilot app only? They must pair again.', 'Forget', callback=lambda result: forget_app_only(allowed) if result == DialogResult.CONFIRM else None)), enabled=lambda: permitted(allowed))) return rows diff --git a/openpilot/starpilot/galaxy_bluetooth/tests/integration_secretgood_http.py b/openpilot/starpilot/galaxy_bluetooth/tests/integration_secretgood_http.py new file mode 100644 index 0000000000..8213def919 --- /dev/null +++ b/openpilot/starpilot/galaxy_bluetooth/tests/integration_secretgood_http.py @@ -0,0 +1,91 @@ +"""Run on a SecretGood runtime: real settings preview/confirm through BLE proxy. + +All settings and access credentials are in a temporary directory. No device +Params, vehicle behavior, radio or hardware connections are changed. +""" +import base64 +import json +from pathlib import Path +import sys +import tempfile +import threading +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'bridge')) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'vendor')) +from proxy import GalaxyProxy +from openpilot.common.params import Params +from openpilot.starpilot.galaxy.access import GalaxyAccessOwner +from openpilot.starpilot.galaxy.server import make_server +from openpilot.starpilot.galaxy.settings import AuthorityContext, SettingsGateway + + +class Context: + def __init__(self): self.value = AuthorityContext(True, None, None) + def sample(self): return self.value + + +class SecretGoodHTTPTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.params = Params(str(Path(self.temp.name) / 'params')) + self.context = Context() + self.settings = SettingsGateway(self.params, self.context) + self.access = GalaxyAccessOwner(Path(self.temp.name) / 'access') + self.server = make_server(port=0, owner=self.access, settings=self.settings) + self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': .01}, daemon=True) + self.worker.start() + self.proxy = GalaxyProxy(self.server.server_port) + self.addCleanup(self.stop) + + def stop(self): + self.server.shutdown() + self.worker.join(timeout=2) + self.server.server_close() + + def request(self, path, payload=None, proxy=None): + response = (proxy or self.proxy).handle({'id': 'test', 'path': path, 'method': 'GET' if payload is None else 'POST', + 'body': '' if payload is None else base64.b64encode(json.dumps(payload).encode()).decode()}) + return response['status'], json.loads(base64.b64decode(response['body'])) + + def test_native_ui_and_runtime_identity(self): + status, runtime = self.request('/data/runtime.json') + self.assertEqual(status, 200) + self.assertEqual(runtime['monitor'], 'local') + self.assertEqual(len(runtime['instance']), 16) + status, hub = self.request('/api/settings/pages/hub') + self.assertEqual(status, 200) + self.assertTrue(hub['rows']) + self.assertTrue(any(row.get('page') for row in hub['rows'])) + + def test_display_preference_round_trip_and_cookie_isolation(self): + status, page = self.request('/api/settings/pages/display') + self.assertEqual(status, 200) + index = next(i for i, row in enumerate(page['rows']) if row['label'] == 'Custom Display Settings') + status, preview = self.request('/api/settings/preview', {'view': page['view'], 'row': index, 'direction': 1}) + self.assertEqual(status, 200) + self.assertFalse(self.params.get_bool('StarPilotDisplayPreferencesEnabled')) + other = self.proxy.new_session() + status, _ = self.request('/api/settings/confirm', {'intent': preview['intent'], 'confirmed': True}, proxy=other) + self.assertNotEqual(status, 200) + status, result = self.request('/api/settings/confirm', {'intent': preview['intent'], 'confirmed': True}) + self.assertEqual((status, result), (200, {'saved': True})) + self.assertTrue(self.params.get_bool('StarPilotDisplayPreferencesEnabled')) + self.assertNotEqual(self.proxy.cookie, other.cookie) + + def test_fresh_handshake_rejects_old_preview(self): + status, page = self.request('/api/settings/pages/display') + self.assertEqual(status, 200) + index = next(i for i, row in enumerate(page['rows']) if row['label'] == 'Custom Display Settings') + status, preview = self.request('/api/settings/preview', {'view': page['view'], 'row': index, 'direction': 1}) + self.assertEqual(status, 200) + # A fresh encrypted handshake must not reuse an old page's mutation intent. + other = self.proxy.new_session() + status, _ = self.request('/api/settings/confirm', {'intent': preview['intent'], 'confirmed': True}, proxy=other) + self.assertNotEqual(status, 200) + self.assertFalse(self.params.get_bool('StarPilotDisplayPreferencesEnabled')) + + +if __name__ == '__main__': + unittest.main() diff --git a/openpilot/starpilot/galaxy_bluetooth/tests/test_claim.py b/openpilot/starpilot/galaxy_bluetooth/tests/test_claim.py index 7b6e22fc6d..861f669faf 100644 --- a/openpilot/starpilot/galaxy_bluetooth/tests/test_claim.py +++ b/openpilot/starpilot/galaxy_bluetooth/tests/test_claim.py @@ -7,6 +7,7 @@ import sys import tempfile import time import unittest +from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "bridge")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "vendor")) @@ -15,7 +16,7 @@ from Crypto.PublicKey import ECC from dbus_next import DBusError, Variant import claim -from pairing import ensure_store, load_keys, load_phones +from pairing import ensure_store, load_keys, load_phones, remove_phone from protocol import Assembler, packets, seal from server import Gateway @@ -160,6 +161,39 @@ class ClaimGatewayTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(state2.response) self.assertEqual(len(load_phones(self.keys)), 1) + async def test_key_refresh_during_claim_keeps_reply_session(self): + claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.claim) + private, data = phone_request() + + async def interleaved_work(function, *args): + result = function(*args) + self.gateway.refresh_key() + return result + + with patch("server.asyncio.to_thread", side_effect=interleaved_work): + state = await self.send(data) + self.assertFalse(state.closed) + self.assertIs(self.gateway.sessions[self.options["device"].value], state) + reply = phone_open(private, self.read_reply(state), state.challenge) + self.assertEqual(load_keys(self.keys)[reply["id"]].hex(), reply["key"]) + + async def test_key_removed_during_claim_is_not_delivered(self): + claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.claim) + _, data = phone_request() + + async def interleaved_work(function, *args): + result = function(*args) + if function.__name__ == "add_phone": + remove_phone(result[0], self.keys) + self.gateway.refresh_key() + return result + + with patch("server.asyncio.to_thread", side_effect=interleaved_work): + state = await self.send(data) + self.assertTrue(state.closed) + self.assertIsNone(state.phone) + self.assertFalse(state.response) + async def test_music_bond_made_first_links_the_claimed_phone(self): claim.arm("AA:BB:CC:DD:EE:FF", "comma", self.claim) claim.note_paired("11:22:33:44:55:66", self.claim) diff --git a/openpilot/starpilot/galaxy_bluetooth/tests/test_phone_ui.py b/openpilot/starpilot/galaxy_bluetooth/tests/test_phone_ui.py index 8204a5a7b0..23e3f48837 100644 --- a/openpilot/starpilot/galaxy_bluetooth/tests/test_phone_ui.py +++ b/openpilot/starpilot/galaxy_bluetooth/tests/test_phone_ui.py @@ -167,5 +167,26 @@ class PermissionTest(unittest.TestCase): self.assertEqual(list(pairing.load_keys(path)), [linked]) + def test_app_pairing_tiles_show_the_name_and_last_use_not_the_added_date(self): + one = {'label': 'iPhone', 'added': '2026-10-16T14:02:00', 'last_used': '2026-10-20'} + two = {'label': 'Pixel', 'added': '2026-10-01T09:00:00', 'last_used': ''} + self.assertEqual(phone_ui.app_pairing_title(one, [one, two]), 'iPhone') + self.assertEqual(phone_ui.app_pairing_subtitle(one), 'last used Oct 20') + self.assertEqual(phone_ui.app_pairing_subtitle(two), 'not used yet') + twin = {'label': 'iPhone', 'added': '2026-10-05T08:00:00', 'last_used': ''} + self.assertEqual(phone_ui.app_pairing_title(twin, [one, twin]), 'iPhone (Oct 5)') + + def test_one_app_pairing_can_be_forgotten_on_its_own(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'pairing.json' + first, _ = pairing.add_phone('iPhone', path) + second, _ = pairing.add_phone('Pixel', path) + with patch.object(phone_ui, 'remove_phone', side_effect=lambda phone_id: pairing.remove_phone(phone_id, path)): + phone_ui.forget_app_phone(lambda: False, first) + self.assertEqual(len(pairing.load_keys(path)), 2) + phone_ui.forget_app_phone(lambda: True, first) + self.assertEqual(list(pairing.load_keys(path)), [second]) + + if __name__ == '__main__': unittest.main() diff --git a/openpilot/starpilot/system/android_auto/bluetooth_bridge.py b/openpilot/starpilot/system/android_auto/bluetooth_bridge.py index f091c53457..6fde287819 100644 --- a/openpilot/starpilot/system/android_auto/bluetooth_bridge.py +++ b/openpilot/starpilot/system/android_auto/bluetooth_bridge.py @@ -319,7 +319,7 @@ class SharedBluetoothOwner(BluetoothOwner): self.companion_devices.clear() self.phone_lease = None window = self.pairing - if isinstance(window, OpenPairingSession) and not window.done.is_set(): + if window is not None and not window.done.is_set(): window.cancel() window.done.wait(5.0) if lease.session is not None: @@ -327,6 +327,7 @@ class SharedBluetoothOwner(BluetoothOwner): try: try: try: + self._stop_scan() if lease.discovering: try: self._client().operation('stop_scan') @@ -646,15 +647,18 @@ class SharedBluetoothOwner(BluetoothOwner): self.companion_devices.pop(address, None) return self.snapshot(session=session) hfp = '0000111e-0000-1000-8000-00805f9b34fb' - if device is None or not device['paired'] or hfp not in device.get('uuids', []): - raise BluetoothRejected('Choose an already paired hands-free car', code='changed') + if device is None or not device['paired']: + raise BluetoothRejected('Choose an already paired device', code='changed') if not device['path'].startswith(lease.adapter_path + '/'): raise BluetoothRejected('Bluetooth adapter changed', code='changed') + is_car = hfp in device.get('uuids', []) was_admitted = self.companion_devices.get(address) - if operation == 'connect': + if operation == 'connect' and is_car: self.companion_devices[address] = device['path'] try: - if operation == 'connect': + if not is_car: + client.operation(operation, address) + elif operation == 'connect': lease.phone._call(device['path'], 'org.bluez.Device1', 'ConnectProfile', 's', (hfp,), timeout=12.0) else: # Disconnect only the companion car, never the projection adapter. @@ -679,27 +683,69 @@ class SharedBluetoothOwner(BluetoothOwner): finally: self.lock.release() + def companion_power(self, enabled: bool, session: tuple) -> dict | None: + """Turn the radio off (or confirm it is on) from Galaxy while Android Auto holds the adapter. + + None means no lease holds it, so the caller's own owner handles the request. Turning it off releases the + projection lease first (see request), so Android Auto lets go of the adapter before the radio stops. + """ + lease = self.phone_lease + if lease is None or lease.released: + return None + if not self.session_valid(session): + raise BluetoothRejected('Galaxy session expired', code='session_expired') + if lease.session is not None: + raise BluetoothRejected('Finish Android Auto pairing first', code='busy') + if enabled: + return self.snapshot(session=session) + return self.request('power', enabled=False, session=session) + def phone_window(self, operation: str, session: tuple, *, prompt_id: str | None = None, - accepted: bool | None = None, value: str = '') -> dict | None: + accepted: bool | None = None, value: str = '', address: str | None = None) -> dict | None: """Open, answer or read a phone pairing window inside the projection lease; None means no projection holds the adapter.""" - if operation not in ('accept_pair', 'pairing_response', 'cancel_pair', 'status'): + if operation not in ('accept_pair', 'pair', 'scan', 'stop_scan', 'pairing_response', 'cancel_pair', 'status'): raise ValueError('Unsupported phone window operation') if not self.lock.acquire(blocking=False): raise BluetoothRejected('Bluetooth is busy', code='busy') try: lease = self.phone_lease - window = self.pairing if isinstance(self.pairing, OpenPairingSession) else None + window = self.pairing if lease is None or lease.released: return None if lease.session is not None: raise BluetoothRejected('Finish Android Auto pairing first', code='busy') if not self.session_valid(session): raise BluetoothRejected('Galaxy session expired', code='session_expired') - if operation != 'accept_pair' and (window is None or window.identity != session): + if operation in ('status', 'cancel_pair', 'pairing_response') and (window is None or window.identity != session): return None - if operation == 'accept_pair': + if operation != 'status' and not self.parked(): + raise BluetoothRejected('Park before changing Bluetooth devices', code='park_required') + if operation in ('scan', 'stop_scan'): + return super().request(operation, session=session) + if operation == 'pair': + address = normalized_address(address) + if address == lease.receiver: + raise BluetoothRejected('Use Android Auto to pair its receiver', code='projection_active') + self._end_idle_window() + if self.pairing is not None and not self.pairing.done.is_set(): + raise BluetoothRejected('Pairing is in progress', code='busy') + client = self._client() + path, _, sender = client.adapter_identity() + device = next((d for d in client._devices(client._objects()) if d['address'] == address), None) + if (path, sender) != (lease.adapter_path, lease.bluez_sender) or device is None or device['paired'] or not device['path'].startswith(path + '/'): + raise BluetoothRejected('Device changed before pairing', code='changed') + self._stop_scan() + window = PairingSession(session, address, device['path'], + lambda: self.phone_lease is lease and not lease.released and self.parked() and self.session_valid(session), self.clock) + self.pairing = window + self.window_paired = None + threading.Thread(target=self._pair_worker, args=(window,), daemon=True).start() + threading.Thread(target=self._watch_pair, args=(window,), daemon=True).start() + elif operation == 'accept_pair': if not self.parked(): raise BluetoothRejected('Park before pairing a phone', code='park_required') + # A window nobody has claimed yet gives way to a new one; a claimed one still blocks. + self._end_idle_window() if self.pairing is not None and not self.pairing.done.is_set(): raise BluetoothRejected('Pairing is in progress', code='busy') window = OpenPairingSession(session, lambda: self.session_valid(session) and self.parked(), self.clock) diff --git a/openpilot/starpilot/system/android_auto/daemon.py b/openpilot/starpilot/system/android_auto/daemon.py index e74769df7f..39df0de9c3 100644 --- a/openpilot/starpilot/system/android_auto/daemon.py +++ b/openpilot/starpilot/system/android_auto/daemon.py @@ -30,7 +30,7 @@ from openpilot.starpilot.system.android_auto.supervisor import Supervisor PAIRING_COMMANDS = {"prepare_pairing", "pairing_status", "pairing_response", "cancel_pairing", "pair_device"} AUTHENTICATED_COMMANDS = {"start", "set_auto_connect", "select_receiver", "set_view", "set_connection", "prepare_pairing", "pairing_status", "pairing_response", "cancel_pairing", "pair_device", "devices", "bluetooth_action", - "phone_window"} + "phone_window", "bluetooth_power"} def _offroad() -> bool: @@ -59,7 +59,7 @@ def handle(supervisor: Supervisor, request: dict[str, Any], verifier: GalaxySour source = request.get('source') if not isinstance(source, str) or verifier is None or not verifier.valid(source): raise RuntimeError('Authenticated Galaxy Android Auto source required') - if command not in ('bluetooth_action', 'phone_window'): + if command not in ('bluetooth_action', 'phone_window', 'bluetooth_power'): supervisor.bind_source_session(('galaxy', source)) if command in PAIRING_COMMANDS and not (parked or _offroad)(): raise RuntimeError("Disengage and shift into Park before pairing") @@ -72,23 +72,37 @@ def handle(supervisor: Supervisor, request: dict[str, Any], verifier: GalaxySour try: result = supervisor.companion_bluetooth_action(request['operation'], request['address'], ('galaxy', request['source'])) except (BluetoothRejected, BluetoothUnavailable) as error: - return {'handled': True, 'error_code': error.code} + return {'handled': True, 'error_code': error.code, 'message': str(error)[:160]} + except (OSError, RuntimeError): + return {'handled': True, 'error_code': 'service_unavailable'} + return {'handled': result is not None, **({'bluetooth': result} if result is not None else {})} + if command == 'bluetooth_power': + from openpilot.starpilot.bluetooth.owner import BluetoothRejected, BluetoothUnavailable + if set(request) != {'command', 'source', 'enabled'} or type(request['enabled']) is not bool: + raise ValueError('Invalid companion Bluetooth power request') + if not (parked or _offroad)(): + return {'handled': True, 'error_code': 'park_required'} + try: + result = supervisor.companion_power(request['enabled'], ('galaxy', request['source'])) + except (BluetoothRejected, BluetoothUnavailable) as error: + return {'handled': True, 'error_code': error.code, 'message': str(error)[:160]} except (OSError, RuntimeError): return {'handled': True, 'error_code': 'service_unavailable'} return {'handled': result is not None, **({'bluetooth': result} if result is not None else {})} if command == 'phone_window': from openpilot.starpilot.bluetooth.owner import BluetoothRejected, BluetoothUnavailable operation = request.get('operation') - fields = {'command', 'source', 'operation'} | ({'prompt_id', 'accepted', 'value'} if operation == 'pairing_response' else set()) - if set(request) != fields or operation not in ('accept_pair', 'pairing_response', 'cancel_pair', 'status'): + fields = {'command', 'source', 'operation'} + fields |= {'prompt_id', 'accepted', 'value'} if operation == 'pairing_response' else {'address'} if operation == 'pair' else set() + if set(request) != fields or operation not in ('accept_pair', 'pair', 'scan', 'stop_scan', 'pairing_response', 'cancel_pair', 'status'): raise ValueError('Invalid phone pairing request') if operation != 'status' and not (parked or _offroad)(): return {'handled': True, 'error_code': 'park_required'} - answer = {key: request[key] for key in ('prompt_id', 'accepted', 'value') if key in request} + answer = {key: request[key] for key in ('prompt_id', 'accepted', 'value', 'address') if key in request} try: result = supervisor.phone_window(operation, ('galaxy', request['source']), **answer) except (BluetoothRejected, BluetoothUnavailable) as error: - return {'handled': True, 'error_code': error.code} + return {'handled': True, 'error_code': error.code, 'message': str(error)[:160]} except (OSError, RuntimeError): return {'handled': True, 'error_code': 'service_unavailable'} return {'handled': result is not None, **({'bluetooth': result} if result is not None else {})} diff --git a/openpilot/starpilot/system/android_auto/projection_onroad.py b/openpilot/starpilot/system/android_auto/projection_onroad.py index 9e43faff74..2fb000f725 100644 --- a/openpilot/starpilot/system/android_auto/projection_onroad.py +++ b/openpilot/starpilot/system/android_auto/projection_onroad.py @@ -54,6 +54,17 @@ def certificate_notice(days_left: int | None) -> str: return f"Heads Up: AA Certificate Expires {when}" +def apply_plain_camera_look(camera, rl): + """The car screen keeps the plain camera look: the shader's non-road path, not the comma UI's engagement grading. + + The comma UI fades the road camera when disengaged (color_strength) and a car screen reads that as washed out. + """ + camera._color_strength_val[0] = 1.0 + camera._road_camera_val[0] = 0 + rl.set_shader_value(camera.shader, camera._color_strength_loc, camera._color_strength_val, rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT) + rl.set_shader_value(camera.shader, camera._road_camera_loc, camera._road_camera_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT) + + def native_dependencies(): import pyray as rl from openpilot.cereal.visionipc import VisionStreamType @@ -105,12 +116,7 @@ def native_dependencies(): def _update_texture_color_filtering(self): if getattr(self, 'large_ui_gamma_trial', False): return super()._update_texture_color_filtering() - # The comma UI fades the engaged road camera to 20% color (30477f3cef). On the car - # screen that reads as black and white, so keep the plain look: non-road shader path. - self._engaged_val[0] = 0 - self._road_camera_val[0] = 0 - rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT) - rl.set_shader_value(self.shader, self._road_camera_loc, self._road_camera_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT) + apply_plain_camera_look(self, rl) def standby_logo(height): """The StarPilot crescent, shrunk once on the CPU to its standby size; None if it cannot be loaded.""" diff --git a/openpilot/starpilot/system/android_auto/protocol.py b/openpilot/starpilot/system/android_auto/protocol.py index 9fd7a8cafb..74d2dafdb3 100644 --- a/openpilot/starpilot/system/android_auto/protocol.py +++ b/openpilot/starpilot/system/android_auto/protocol.py @@ -8,7 +8,9 @@ import socket from typing import Any ANDROID_AUTO_SOCKET_PATH = "/tmp/starpilot-android-auto.sock" -COMMAND_TIMEOUTS = {"stop": 12.0, "prepare_pairing": 20.0, "devices": 10.0, "select_receiver": 10.0, "bluetooth_action": 25.0, +COMMAND_TIMEOUTS = {"stop": 12.0, "prepare_pairing": 20.0, "devices": 10.0, "select_receiver": 10.0, "bluetooth_action": 25.0, "bluetooth_power": 25.0, + # Opening a window may wait on a slow BlueZ right after boot, and a closed one is given time to wind down. + "phone_window": 25.0, "forget_receiver": 15.0} diff --git a/openpilot/starpilot/system/android_auto/source_verifier.py b/openpilot/starpilot/system/android_auto/source_verifier.py index 0296a3edcb..017eb7486f 100644 --- a/openpilot/starpilot/system/android_auto/source_verifier.py +++ b/openpilot/starpilot/system/android_auto/source_verifier.py @@ -8,7 +8,7 @@ SOURCE_ID = re.compile(r'[0-9a-f]{64}\Z') class GalaxySourceVerifier: - def __init__(self, host: str = '127.0.0.1', port: int = 8082, timeout: float = 1.0): + def __init__(self, host: str = '127.0.0.1', port: int = 8082, timeout: float = 3.0): self.host, self.port, self.timeout = host, port, timeout def valid(self, source: str) -> bool: diff --git a/openpilot/starpilot/system/android_auto/supervisor.py b/openpilot/starpilot/system/android_auto/supervisor.py index f26374e3ca..2ea8e7550c 100644 --- a/openpilot/starpilot/system/android_auto/supervisor.py +++ b/openpilot/starpilot/system/android_auto/supervisor.py @@ -778,6 +778,17 @@ class Supervisor: owner = self._shared_bluetooth_owner return None if owner is None else owner.phone_window(operation, session, **arguments) + def companion_power(self, enabled: bool, session: tuple): + owner = self._shared_bluetooth_owner + if owner is None: + return None + if not enabled and self._session_alive(): + from openpilot.starpilot.bluetooth.owner import BluetoothRejected + raise BluetoothRejected('Stop Android Auto before turning Bluetooth off', code='projection_active') + # Same lock order as select_receiver: supervisor first, then the owner releases its lease. + with self._lock: + return owner.companion_power(enabled, session) + def companion_bluetooth_action(self, operation: str, address: str, session: tuple): owner = self._shared_bluetooth_owner if owner is None: @@ -802,6 +813,8 @@ class Supervisor: owner.phone_lease is not lease or lease.released or not owner.session_valid(session) or not lease.enabled()): raise RuntimeError('Selected car connection was not confirmed') + if '0000111e-0000-1000-8000-00805f9b34fb' not in device.get('uuids', []): + return result # An accessory must never replace the dongle's selected car. self.config.update(companion_address=device['address'], companion_name=device['name']) identity_store.save_config(self.config) for previous in list(owner.companion_devices): diff --git a/openpilot/starpilot/system/android_auto/tests/test_bluetooth_bridge.py b/openpilot/starpilot/system/android_auto/tests/test_bluetooth_bridge.py index bfbf18f3eb..db9767c1cf 100644 --- a/openpilot/starpilot/system/android_auto/tests/test_bluetooth_bridge.py +++ b/openpilot/starpilot/system/android_auto/tests/test_bluetooth_bridge.py @@ -299,6 +299,62 @@ def test_projection_survives_drive_and_browser_expiry_but_not_disable_or_receive shared.close() +def test_accessory_scan_and_pair_keep_projection_lock_and_cancel_on_release(tmp_path): + import threading + state = {'parked': True, 'session': ('galaxy', '42'), 'enabled': True, 'selected': 'AA:BB:CC:DD:EE:FF'} + bluez = FakeBlueZ() + target = '11:22:33:44:55:66' + bluez.paired.append({'address': target, 'path': '/org/bluez/hci0/dev_11_22_33_44_55_66', + 'paired': False, 'connected': False, 'name': 'Car'}) + shared = owner(tmp_path, state, bluez) + lease = shared.acquire_projection_role(state['selected'], Phone(), enabled=lambda: state['enabled'], + selected_receiver=lambda: state['selected']) + other = owner(tmp_path, state, bluez) + started = threading.Event() + def pair(address, path, session): + started.set() + session.done.wait(2) + return False + bluez.pair = pair + bluez.cancel_pair = lambda path: None + try: + assert shared.phone_window('scan', state['session'])['discovering'] + with pytest.raises(BluetoothRejected, match='another process'): + other.request('scan') + result = shared.phone_window('pair', state['session'], address=target) + assert result['pairing']['address'] == target + assert started.wait(1) + assert not bluez.props['Discovering'] + assert shared.phone_lease is lease and not lease.released + assert shared.phone_window('status', state['session'])['pairing']['state'] == 'pairing' + lease.release() + assert shared.pairing.done.wait(1) + assert shared.pairing.state == 'canceled' + assert other.request('scan')['discovering'] + finally: + shared.close() + other.close() + + +def test_projection_accessory_operations_require_park_and_reject_receiver_pairing(tmp_path): + state = {'parked': True, 'session': ('galaxy', '42')} + bluez = FakeBlueZ() + shared = owner(tmp_path, state, bluez) + receiver = bluez.paired[0]['address'] + shared.acquire_projection_role(receiver, Phone(), enabled=lambda: True, selected_receiver=lambda: receiver) + try: + with pytest.raises(BluetoothRejected, match='Use Android Auto'): + shared.phone_window('pair', state['session'], address=receiver) + state['parked'] = False + for operation in ('scan', 'pair'): + with pytest.raises(BluetoothRejected) as error: + shared.phone_window(operation, state['session'], address='11:22:33:44:55:66') + assert error.value.code == 'park_required' + assert not bluez.props['Discovering'] + finally: + shared.close() + + def test_projection_restores_original_visibility_on_release_and_failure(tmp_path): state = {'parked': True, 'session': ('galaxy', '42')} bluez = FakeBlueZ() diff --git a/openpilot/starpilot/system/android_auto/tests/test_companion_bluetooth.py b/openpilot/starpilot/system/android_auto/tests/test_companion_bluetooth.py index 6cb51b3f72..941e53a173 100644 --- a/openpilot/starpilot/system/android_auto/tests/test_companion_bluetooth.py +++ b/openpilot/starpilot/system/android_auto/tests/test_companion_bluetooth.py @@ -60,8 +60,8 @@ class CompanionTests(unittest.TestCase): self.assertTrue(self.devices[ADAPTER]['connected']) self.assertFalse(self.lease.released) - def test_selected_adapter_and_non_hfp_or_unpaired_peers_never_change_radio(self): - for address, alteration in [(ADAPTER, {}), (CAR, {'paired': False}), (CAR, {'uuids': []}), + def test_selected_adapter_and_unpaired_or_foreign_peers_never_change_radio(self): + for address, alteration in [(ADAPTER, {}), (CAR, {'paired': False}), (CAR, {'path': '/org/bluez/hci1/dev_car'})]: before = dict(self.devices[address]) self.devices[address].update(alteration) @@ -71,6 +71,27 @@ class CompanionTests(unittest.TestCase): self.assertEqual(self.calls, []) self.assertEqual(self.owner.companion_devices, {}) + def test_accessory_connect_preserves_the_selected_car_and_projection(self): + from openpilot.starpilot.system.android_auto.supervisor import Supervisor + self.devices[CAR]['uuids'] = [] # HID controller, not an HFP car. + def operation(operation, address): + self.forgotten.append((operation, address)) + self.devices[address]['connected'] = operation == 'connect' + self.client.operation = operation + host = Supervisor.__new__(Supervisor) + host._lock = threading.RLock() + host.config = {'receiver_address': ADAPTER, 'companion_address': 'saved-car', 'companion_name': 'My car'} + host._shared_bluetooth_owner = self.owner + host._phone = lambda: self.phone + with patch('openpilot.starpilot.system.android_auto.identity.save_config') as save: + host.companion_bluetooth_action('connect', CAR, SESSION) + host.companion_bluetooth_action('disconnect', CAR, SESSION) + save.assert_not_called() + self.assertEqual(self.forgotten, [('connect', CAR), ('disconnect', CAR)]) + self.assertEqual(host.config['companion_address'], 'saved-car') + self.assertEqual(self.owner.companion_devices, {}) + self.assertFalse(self.lease.released) + def test_session_pairing_and_changed_owner_are_fail_closed(self): alterations = [('valid', False), ('enabled', False), ('selected', CAR)] for key, value in alterations: @@ -275,15 +296,61 @@ class CompanionTests(unittest.TestCase): self.assertEqual(self.daemon_call(), {'handled': False}) def test_daemon_denials_and_timeouts_never_produce_normal_owner_fallback(self): - self.assertEqual(self.daemon_call(address=ADAPTER), {'handled': True, 'error_code': 'projection_active'}) + self.assertEqual(self.daemon_call(address=ADAPTER), {'handled': True, 'error_code': 'projection_active', + 'message': 'Stop projection before changing its adapter'}) self.lease.session = SESSION - self.assertEqual(self.daemon_call(), {'handled': True, 'error_code': 'busy'}) + self.assertEqual(self.daemon_call(), {'handled': True, 'error_code': 'busy', 'message': 'Finish Android Auto pairing first'}) self.lease.session = None def timeout(): raise TimeoutError('No profile reply') self.hook = timeout self.assertEqual(self.daemon_call(), {'handled': True, 'error_code': 'service_unavailable'}) self.assertFalse(self.lease.released) + def test_power_off_goes_through_the_owner_and_power_on_is_a_no_op(self): + self.owner.request = Mock(return_value={'powered': False}) # ty: ignore[invalid-assignment] # test double + self.assertEqual(self.owner.companion_power(False, SESSION), {'powered': False}) + self.owner.request.assert_called_once_with('power', enabled=False, session=SESSION) + self.owner.request.reset_mock() + result = self.owner.companion_power(True, SESSION) + assert result is not None + self.assertTrue(result['powered']) + self.owner.request.assert_not_called() + + def test_power_refusals_and_no_lease(self): + self.state['valid'] = False + with self.assertRaises(Rejected) as error: + self.owner.companion_power(False, SESSION) + self.assertEqual(error.exception.code, 'session_expired') + self.state['valid'] = True + self.lease.session = SESSION + with self.assertRaises(Rejected) as error: + self.owner.companion_power(False, SESSION) + self.assertEqual(error.exception.code, 'busy') + self.lease.session = None + self.owner.phone_lease = None + self.assertIsNone(self.owner.companion_power(False, SESSION)) + + def power_call(self, enabled=False, valid=True, parked=True): + supervisor = SimpleNamespace(companion_power=self.owner.companion_power, + bind_source_session=lambda session: self.fail('Companion command must not rebind projection source')) + request = {'command': 'bluetooth_power', 'source': SESSION[1], 'enabled': enabled} + with patch('openpilot.common.params.Params') as params: + params.return_value.get_bool.return_value = True + return daemon.handle(Mock(wraps=supervisor), request, Mock(wraps=SimpleNamespace(valid=lambda source: valid)), parked=lambda: parked) + + def test_daemon_power_command_requires_proof_park_and_a_boolean(self): + self.owner.request = Mock(return_value={'powered': False}) # ty: ignore[invalid-assignment] # test double + self.assertEqual(self.power_call(), {'handled': True, 'bluetooth': {'powered': False}}) + self.assertEqual(self.power_call(parked=False), {'handled': True, 'error_code': 'park_required'}) + with self.assertRaises(RuntimeError): + self.power_call(valid=False) + with self.assertRaises(ValueError): + self.power_call(enabled='off') # ty: ignore[invalid-argument-type] # wrong type on purpose + self.lease.session = SESSION + self.assertEqual(self.power_call(), {'handled': True, 'error_code': 'busy', 'message': 'Finish Android Auto pairing first'}) + self.owner.phone_lease = None + self.assertEqual(self.power_call(), {'handled': False}) + def test_daemon_requires_live_source_and_park_before_touching_owner(self): with self.assertRaises(RuntimeError): self.daemon_call(valid=False) diff --git a/openpilot/starpilot/system/android_auto/tests/test_phone_window.py b/openpilot/starpilot/system/android_auto/tests/test_phone_window.py index 042313d618..39be21b6fb 100644 --- a/openpilot/starpilot/system/android_auto/tests/test_phone_window.py +++ b/openpilot/starpilot/system/android_auto/tests/test_phone_window.py @@ -36,9 +36,20 @@ class PhoneWindowTest(unittest.TestCase): process_lock=Mock(), receiver=CAR) self.owner.phone_lease = self.lease # ty: ignore[invalid-assignment] # test double + def window_call(self, operation): + # A closed window's watcher briefly holds the owner lock while it lets go of the adapter. + for _ in range(300): + try: + return self.owner.phone_window(operation, SESSION) + except BluetoothRejected as error: + if str(error) != 'Bluetooth is busy': + raise + time.sleep(0.01) + self.fail('owner stayed busy') + def claimed(self): for _ in range(300): - result = self.owner.phone_window('status', SESSION) + result = self.window_call('status') if result and result['pairing'] and result['pairing']['prompt']: return result['pairing'] time.sleep(0.01) @@ -61,6 +72,20 @@ class PhoneWindowTest(unittest.TestCase): self.assertFalse(self.lease.released) self.assertEqual(self.owner.journal_path.read_text(), '{"lease": "original visibility"}') + def test_unclaimed_window_gives_way_to_a_new_one_but_a_claimed_window_blocks(self): + self.assertIsNotNone(self.window_call('accept_pair')) + first = self.owner.pairing + assert first is not None + self.claimed() + first.path = '' # nobody has claimed it + self.assertIsNotNone(self.window_call('accept_pair')) + self.assertTrue(first.done.is_set()) + self.assertIsNot(self.owner.pairing, first) + self.claimed() + with self.assertRaises(BluetoothRejected) as error: + self.window_call('accept_pair') + self.assertEqual((error.exception.code, str(error.exception)), ('busy', 'Pairing is in progress')) + def test_no_projection_means_not_handled(self): self.owner.phone_lease = None for operation in ('accept_pair', 'status', 'cancel_pair'): @@ -104,5 +129,13 @@ class PhoneWindowTest(unittest.TestCase): self.assertFalse(self.owner.journal_path.exists()) +class TimeoutTest(unittest.TestCase): + def test_opening_a_window_and_proving_the_source_get_time_after_boot(self): + from openpilot.starpilot.system.android_auto.protocol import COMMAND_TIMEOUTS + from openpilot.starpilot.system.android_auto.source_verifier import GalaxySourceVerifier + self.assertGreaterEqual(COMMAND_TIMEOUTS['phone_window'], 20.0) + self.assertGreaterEqual(GalaxySourceVerifier().timeout, 3.0) + + if __name__ == '__main__': unittest.main() diff --git a/openpilot/starpilot/system/android_auto/tests/test_projection_plain_look.py b/openpilot/starpilot/system/android_auto/tests/test_projection_plain_look.py new file mode 100644 index 0000000000..9c8334d699 --- /dev/null +++ b/openpilot/starpilot/system/android_auto/tests/test_projection_plain_look.py @@ -0,0 +1,21 @@ +"""The car screen's camera look follows the shader's current uniforms (color_strength and road_camera).""" +from types import SimpleNamespace +from unittest.mock import Mock + +from openpilot.starpilot.system.android_auto.projection_onroad import apply_plain_camera_look + + +def test_car_screen_uses_the_plain_non_road_path_with_the_current_shader_uniforms(): + rl = SimpleNamespace(set_shader_value=Mock(), ShaderUniformDataType=SimpleNamespace(SHADER_UNIFORM_FLOAT='float', SHADER_UNIFORM_INT='int')) + camera = SimpleNamespace(shader='shader', _color_strength_val=[0.2], _color_strength_loc=3, _road_camera_val=[1], _road_camera_loc=4) + apply_plain_camera_look(camera, rl) + assert camera._color_strength_val == [1.0] and camera._road_camera_val == [0] + assert [call.args for call in rl.set_shader_value.call_args_list] == [ + ('shader', 3, [1.0], 'float'), ('shader', 4, [0], 'int')] + + +def test_it_needs_none_of_the_removed_engaged_uniform(): + rl = SimpleNamespace(set_shader_value=Mock(), ShaderUniformDataType=SimpleNamespace(SHADER_UNIFORM_FLOAT='float', SHADER_UNIFORM_INT='int')) + camera = SimpleNamespace(shader='shader', _color_strength_val=[0.0], _color_strength_loc=3, _road_camera_val=[1], _road_camera_loc=4) + assert not hasattr(camera, '_engaged_val') + apply_plain_camera_look(camera, rl) diff --git a/openpilot/starpilot/ui/bluetooth_compact.py b/openpilot/starpilot/ui/bluetooth_compact.py index b62c62f3aa..5dc9d41011 100644 --- a/openpilot/starpilot/ui/bluetooth_compact.py +++ b/openpilot/starpilot/ui/bluetooth_compact.py @@ -8,7 +8,8 @@ import uuid from openpilot.selfdrive.ui.mici.widgets.button import BigButton, GreyBigButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog, BigInputDialog -from openpilot.starpilot.bluetooth.owner import BluetoothOwner, BluetoothRejected, BluetoothUnavailable +from openpilot.starpilot.bluetooth.owner import BluetoothRejected, BluetoothUnavailable +from openpilot.starpilot.bluetooth.native_owner import NativeBluetoothOwner as BluetoothOwner from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets.scroller import NavScroller @@ -123,6 +124,8 @@ class BluetoothCompact(NavScroller): prompt_id=prompt_id, accepted=accepted, value=value) elif operation in ("connect", "disconnect", "forget"): self.pending = self.executor.submit(self._device_request, operation, address) + elif operation == "power": + self.pending = self.executor.submit(self._power_request, bool(enabled)) else: self.pending = self.executor.submit(self.owner.request, operation, address=address, enabled=enabled) self.operation = operation @@ -131,18 +134,11 @@ class BluetoothCompact(NavScroller): self.return_to_root = return_to_root self._rebuild() + def _power_request(self, enabled: bool) -> dict: + return self.owner.request("power", address=None, enabled=enabled) + def _device_request(self, operation: str, address: str | None) -> dict: - try: - return self.owner.request(operation, address=address) - except BluetoothRejected as error: - if error.code != "busy": - raise - # Android Auto holds the adapter in its own process; Galaxy hands the change to it. - from openpilot.starpilot.galaxy_bluetooth.phone_ui import ACTION, galaxy_call - status, result = galaxy_call()("POST", ACTION, {"operation": operation, "address": address}) - if status != 200 or not isinstance(result.get("devices"), list): - raise BluetoothRejected("Android Auto could not complete the Bluetooth change") - return result + return self.owner.request(operation, address=address) def _tick(self): if self.pending is not None and self.pending.done(): @@ -266,7 +262,9 @@ class BluetoothCompact(NavScroller): pairing.set_enabled(lambda: self._ready() or self._pairing_ready()) cards.append(pairing) if status.get("powered"): - devices = [device for device in status.get("devices", ()) if device.get("paired") or device.get("connected")] + from openpilot.starpilot.galaxy_bluetooth.phone_ui import is_app_link + devices = [device for device in status.get("devices", ()) if (device.get("paired") or device.get("connected")) and not is_app_link(device)] + links = [device for device in status.get("devices", ()) if is_app_link(device)] cards.append(GreyBigButton("your devices", "Bluetooth connections")) for device in devices: value = "connected" if device.get("connected") else "disconnected" @@ -274,12 +272,23 @@ class BluetoothCompact(NavScroller): button.set_click_callback(lambda addr=device["address"]: self._open_device(addr)) button.set_enabled(self._ready) cards.append(button) - from openpilot.starpilot.galaxy_bluetooth.bridge.pairing import describe - phones = self._app_only() - for phone in phones: - cards.append(GreyBigButton(describe(phone).replace("·", "-"), "app only, music not linked")) - if not devices and not phones: + if not devices: cards.append(GreyBigButton("saved devices", "no saved pairings")) + from openpilot.starpilot.galaxy_bluetooth.phone_ui import app_pairing_subtitle, app_pairing_title + phones = self._app_only() + if links or phones: + cards.append(GreyBigButton("app pairings", "phones using the StarPilot app")) + for device in links: + button = BigButton(device_label(device), "app connected", scroll=True) + button.set_click_callback(lambda addr=device["address"]: self._open_device(addr)) + button.set_enabled(self._ready) + cards.append(button) + for phone in phones: + tile = BigButton(app_pairing_title(phone, phones).replace("·", "-"), app_pairing_subtitle(phone), scroll=True) + tile.set_enabled(lambda: self.phone_pairing_allowed()) + tile.set_click_callback(lambda phone_id=phone["id"]: gui_app.push_widget(BigConfirmationDialog( + "forget this app pairing", self.icon, lambda: self._forget_app_phone(phone_id), red=True))) + cards.append(tile) if self.pairing_page is not None and gui_app.widget_in_stack(self.pairing_page): self._rebuild_pairing() self._scroller.items.clear() @@ -323,7 +332,7 @@ class BluetoothCompact(NavScroller): button.set_enabled(self._ready) cards.append(button) if self._app_only(): - cleanup = BigButton("forget app keys", "app-only phones") + cleanup = BigButton("forget all app pairings", "app-only phones") cleanup.set_enabled(lambda: self.phone_pairing_allowed()) cleanup.set_click_callback(lambda: gui_app.push_widget(BigConfirmationDialog( "Remove phones paired for the StarPilot app only?", self.icon, @@ -332,6 +341,11 @@ class BluetoothCompact(NavScroller): self.pairing_page._scroller.items.clear() self.pairing_page._scroller.add_widgets(cards) + def _forget_app_phone(self, phone_id: str): + from openpilot.starpilot.galaxy_bluetooth.phone_ui import forget_app_phone + forget_app_phone(self.phone_pairing_allowed, phone_id) + self._rebuild() + def _forget_app_only(self): from openpilot.starpilot.galaxy_bluetooth.phone_ui import forget_app_only forget_app_only(self.phone_pairing_allowed) @@ -344,6 +358,11 @@ class BluetoothCompact(NavScroller): device = next((item for item in (self.status or {}).get("devices", ()) if item.get("address") == address), None) if device is None: return + from openpilot.starpilot.galaxy_bluetooth.phone_ui import is_app_link + if is_app_link(device): + # A phone running the StarPilot app, not a Bluetooth pairing: the only thing to do here is let go of it. + gui_app.push_widget(BigConfirmationDialog("forget this app connection", self.icon, lambda: self._forget(address), red=True)) + return if not device.get("paired"): self._request("pair", address=address) return diff --git a/openpilot/starpilot/ui/bluetooth_large.py b/openpilot/starpilot/ui/bluetooth_large.py index 237082cf9d..6518960f8d 100644 --- a/openpilot/starpilot/ui/bluetooth_large.py +++ b/openpilot/starpilot/ui/bluetooth_large.py @@ -5,8 +5,9 @@ import time import uuid from openpilot.starpilot.galaxy_bluetooth.bootstrap import setup_status -from openpilot.starpilot.galaxy_bluetooth.phone_ui import forget_phone_keys, large_phone_rows -from openpilot.starpilot.bluetooth.owner import BluetoothOwner, BluetoothRejected, BluetoothUnavailable +from openpilot.starpilot.galaxy_bluetooth.phone_ui import forget_phone_keys, is_app_link, large_phone_rows +from openpilot.starpilot.bluetooth.owner import BluetoothRejected, BluetoothUnavailable +from openpilot.starpilot.bluetooth.native_owner import NativeBluetoothOwner as BluetoothOwner from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog @@ -132,6 +133,11 @@ class BluetoothLarge(Widget): if operation in ("pair", "pairing_response", "cancel_pair"): self.pending = self.executor.submit(self.owner.request, operation, address=address, session=session, prompt_id=prompt_id, accepted=accepted, value=value) + elif operation == "power": + self.pending = self.executor.submit(self._power_request, bool(enabled)) + elif operation == "auto_scan": + # Opening the page never starts discovery while Android Auto holds the adapter. + self.pending = self.executor.submit(self.owner.request, "scan", delegate=False) else: self.pending = self.executor.submit(self.owner.request, operation, address=address, enabled=enabled) self.operation = operation @@ -140,6 +146,9 @@ class BluetoothLarge(Widget): self.return_to_root = return_to_root self._rebuild() + def _power_request(self, enabled: bool) -> dict: + return self.owner.request("power", address=None, enabled=enabled) + def _forget(self, address: str) -> None: self._request("forget", address=address) @@ -190,7 +199,7 @@ class BluetoothLarge(Widget): self.status.get("powered") and self.status.get("parked")): self.scan_on_ready = False if not self.status.get("discovering"): - self._request("scan") + self._request("auto_scan") return if self.pending is None and time.monotonic() >= self.next_refresh: self._snapshot() @@ -239,7 +248,7 @@ class BluetoothLarge(Widget): def _rebuild(self): status = self.status or {} - devices = tuple((item.get("address"), item.get("name"), item.get("paired"), item.get("connected")) + devices = tuple((item.get("address"), item.get("name"), item.get("paired"), item.get("connected"), item.get("trusted")) for item in status.get("devices", ())) signature = repr((self.status is None, status.get("available"), status.get("powered"), status.get("parked"), status.get("discovering"), status.get("errorCode"), status.get("pairing"), devices, @@ -277,7 +286,11 @@ class BluetoothLarge(Widget): rows.append(button_item("Reject pairing", "Reject", callback=lambda p=prompt: self._request("pairing_response", prompt_id=p["id"], accepted=False), enabled=self._pairing_ready)) rows.append(button_item("Cancel pairing", "Cancel", callback=lambda: self._request("cancel_pair"), enabled=self._pairing_ready)) + links = [] for device in status.get("devices", ()): + if is_app_link(device): + links.append(device) + continue address = device.get("address") name = "".join(char for char in str(device.get("name") or "") if char.isprintable()).strip() address_name = bool(re.fullmatch(r"(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}", name)) @@ -290,6 +303,12 @@ class BluetoothLarge(Widget): rows.append(button_item("Forget " + device_label(device), "Forget", callback=lambda a=address: self._confirm("Forget this Bluetooth device?", lambda: self._forget(a)), enabled=lambda: self.active and self._ready())) + if links: + rows.append(text_item("App pairings", "Phones connected through the StarPilot app. These are not Bluetooth pairings.")) + for device in links: + link_address = device.get("address") + rows.append(button_item(device_label(device), "Forget", description=link_address, callback=lambda a=link_address: + self._confirm("Forget this app connection?", lambda: self._forget(a)), enabled=lambda: self.active and self._ready())) rows.extend(large_phone_rows(self.phone_pairing_allowed)) previous = getattr(self, "_scroller", None) self._scroller = Scroller(rows, spacing=0, line_separator=True) diff --git a/openpilot/starpilot/ui/settings.py b/openpilot/starpilot/ui/settings.py index 5b6c76eb1a..81e8f41e10 100644 --- a/openpilot/starpilot/ui/settings.py +++ b/openpilot/starpilot/ui/settings.py @@ -104,6 +104,9 @@ class SettingsView: rl.draw_texture_ex(background, rl.Vector2(x, top), 0, 1, rl.WHITE) font_size = 40 if destination == Destination.FORCE_DRIVE else 42 if destination == Destination.DRIVING_MODEL else ( 48 if destination in (Destination.GALAXY, Destination.PAIR) else 64) + if destination == Destination.DRIVING_CONTROLS: + font_size = 56 + text = text.replace(" ", "\n", 1) measured = self.fonts.measure(text, FontRole.BOLD, font_size) self.fonts.draw(text, FontRole.BOLD, font_size, x + 40, top + 180 - 23 - measured.height, rl.Color(255, 255, 255, 229)) diff --git a/openpilot/starpilot/ui/speed_limit_shapes.py b/openpilot/starpilot/ui/speed_limit_shapes.py index e8e1fbbaa4..9e01362141 100644 --- a/openpilot/starpilot/ui/speed_limit_shapes.py +++ b/openpilot/starpilot/ui/speed_limit_shapes.py @@ -14,8 +14,9 @@ SPRITES = { class SpeedLimitShapes: - def __init__(self, scale=1): + def __init__(self, scale=1, *, headless_context=False): self.scale = scale + self._headless_context = headless_context self.texture = None self.sources = {name: rl.Rectangle(*rect) for name, rect in SPRITES.items()} self.origins = {name: rl.Vector2(rect[2] * scale / 2, rect[3] * scale / 2) for name, rect in SPRITES.items()} @@ -23,7 +24,7 @@ class SpeedLimitShapes: def _load(self): if self.texture is not None: return - if not rl.is_window_ready(): + if not (rl.is_window_ready() or self._headless_context): raise RuntimeError('Speed-limit shapes require a graphics context') image = rl.load_image(str(ATLAS_PATH)) try: @@ -56,6 +57,6 @@ class SpeedLimitShapes: self._draw('arrow_large' if size == 'large' else 'arrow_compact', x, y, color, 180 if direction < 0 else 0) def close(self): - if self.texture is not None and rl.is_window_ready(): + if self.texture is not None and (rl.is_window_ready() or self._headless_context): rl.unload_texture(self.texture) self.texture = None diff --git a/openpilot/starpilot/ui/speed_limit_widget.py b/openpilot/starpilot/ui/speed_limit_widget.py index d16c6fd5c4..a4cc572cc7 100644 --- a/openpilot/starpilot/ui/speed_limit_widget.py +++ b/openpilot/starpilot/ui/speed_limit_widget.py @@ -62,7 +62,7 @@ class SpeedLimitWidget: self.scale = LARGE_SIGN_SCALE if profile == Profile.LARGE else 1 self.text_scale = self.scale * Profile.COMPACT.font_scale / profile.font_scale self.pulse = SpeedLimitPulse() - self.shapes = SpeedLimitShapes(self.scale) + self.shapes = SpeedLimitShapes(self.scale, headless_context=getattr(fonts, '_headless_context', False)) def close(self): self.shapes.close() diff --git a/openpilot/starpilot/ui/tests/test_bluetooth_compact.py b/openpilot/starpilot/ui/tests/test_bluetooth_compact.py index fda0a33b6d..631fd14154 100644 --- a/openpilot/starpilot/ui/tests/test_bluetooth_compact.py +++ b/openpilot/starpilot/ui/tests/test_bluetooth_compact.py @@ -23,18 +23,18 @@ class TestBluetoothCompact(unittest.TestCase): page._open_device("AA:BB:CC:DD:EE:FF") request.assert_called_once_with("pair", address="AA:BB:CC:DD:EE:FF") - def test_forget_falls_back_to_galaxy_when_android_auto_holds_the_adapter(self): - from openpilot.starpilot.bluetooth.owner import BluetoothRejected + def test_connected_unpaired_phone_app_is_forgotten_not_paired(self): page = BluetoothCompact.__new__(BluetoothCompact) - page.owner = NS(request=Mock(side_effect=BluetoothRejected("busy elsewhere", code="busy"))) # ty: ignore[invalid-assignment] # test double - call = Mock(return_value=(200, {"devices": []})) - with patch("openpilot.starpilot.galaxy_bluetooth.phone_ui.galaxy_call", return_value=call): - self.assertEqual(page._device_request("forget", "AA:BB:CC:DD:EE:FF"), {"devices": []}) - call.assert_called_once_with("POST", "/api/bluetooth/action", {"operation": "forget", "address": "AA:BB:CC:DD:EE:FF"}) - page.owner.request.side_effect = BluetoothRejected("changed") - with patch("openpilot.starpilot.galaxy_bluetooth.phone_ui.galaxy_call") as galaxy, self.assertRaises(BluetoothRejected): - page._device_request("forget", "AA:BB:CC:DD:EE:FF") - galaxy.assert_not_called() + page.icon = object() + page.status = {"devices": [{"address": "AA:BB:CC:DD:EE:FF", "name": "iPhone", "paired": False, "connected": True}]} + with patch.object(page, "_request") as request, \ + patch("openpilot.starpilot.ui.bluetooth_compact.gui_app.push_widget") as push, \ + patch("openpilot.starpilot.ui.bluetooth_compact.BigConfirmationDialog") as dialog: + page._open_device("AA:BB:CC:DD:EE:FF") + request.assert_not_called() + push.assert_called_once() + dialog.call_args.args[2]() + request.assert_called_once_with("forget", address="AA:BB:CC:DD:EE:FF", return_to_root=True) def pair_page(self, kind="confirmation", value="123456"): class Button: @@ -165,7 +165,7 @@ class TestBluetoothCompact(unittest.TestCase): page.return_to_root = False with patch.object(BluetoothCompact, "_rebuild") as rebuild: page._request("power", enabled=True) - page.executor.submit.assert_called_once_with(owner.request, "power", address=None, enabled=True) + page.executor.submit.assert_called_once_with(page._power_request, True) owner.request.assert_not_called() self.assertIs(page.pending, future) result = {"available": True, "parked": True, "powered": True, "devices": []} diff --git a/openpilot/starpilot/ui/tests/test_bluetooth_large_pure.py b/openpilot/starpilot/ui/tests/test_bluetooth_large_pure.py index 531e2a383c..0a82ef74ba 100644 --- a/openpilot/starpilot/ui/tests/test_bluetooth_large_pure.py +++ b/openpilot/starpilot/ui/tests/test_bluetooth_large_pure.py @@ -6,6 +6,7 @@ import re import time import unittest import uuid +from unittest.mock import Mock, patch class Widget: @@ -110,7 +111,8 @@ class TestBluetoothLarge(unittest.TestCase): 'text_item': lambda *a, **kw: Row((a, kw)), 'ConfirmDialog': Dialog, 'alert_dialog': Dialog, 'DialogResult': type('Result', (), {'CONFIRM': 1}), 'Keyboard': Dialog, 'setup_status': lambda: 'Ready to pair', 'large_phone_rows': lambda allowed: [], - 'forget_phone_keys': lambda allowed, address: self.forgotten.append(address)} + 'forget_phone_keys': lambda allowed, address: self.forgotten.append(address), + 'is_app_link': lambda device: bool(device.get('connected')) and not device.get('paired') and not device.get('trusted')} tree.body.insert(0, ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0)) exec(compile(ast.fix_missing_locations(tree), str(path), 'exec'), scope) self.panel_type = scope['BluetoothLarge'] @@ -129,11 +131,30 @@ class TestBluetoothLarge(unittest.TestCase): self.assertEqual(self.panel.executor.jobs[0][2], ()) self.assertTrue(self.panel.owner.session_valid(self.panel.session)) + def test_large_device_buttons_reach_galaxy_when_projection_owns_bluetooth(self): + from openpilot.starpilot.bluetooth.native_owner import NativeBluetoothOwner + from openpilot.starpilot.bluetooth.owner import AdapterBusyElsewhere, BluetoothOwner + self.panel.owner = NativeBluetoothOwner(lambda: True) + self.panel.active = True + self.panel.session = ('large', 'test') + self.panel.executor = Executor() + call = Mock(return_value=(200, {'devices': [], 'available': True, 'powered': True})) + with patch.object(BluetoothOwner, 'request', side_effect=AdapterBusyElsewhere('AA holds adapter')), \ + patch('openpilot.starpilot.galaxy_bluetooth.phone_ui.galaxy_call', return_value=call): + for operation in ('scan', 'pair', 'connect', 'disconnect', 'forget'): + address = None if operation == 'scan' else 'AA:BB:CC:DD:EE:FF' + self.panel._submit_request(operation, address, None, False, self.panel.session, None, None, '') + future, callback, args, kwargs = self.panel.executor.jobs[-1] + future.set_result(callback(*args, **kwargs)) + payload = {'operation': operation, **({'address': address} if address else {})} + call.assert_called_with('POST', '/api/bluetooth/action', payload) + def test_open_powered_panel_scans_once_and_stops_on_hide(self): self.panel.show_event() self.panel.pending.set_result({'available': True, 'powered': True, 'parked': True, 'discovering': False}) self.panel._tick() - self.assertEqual(self.panel.operation, 'scan') + self.assertEqual(self.panel.operation, 'auto_scan') + self.assertEqual(self.panel.executor.jobs[-1][3], {'delegate': False}) self.panel.pending.set_result({'available': True, 'powered': True, 'parked': True, 'discovering': False}) self.panel._tick() self.assertEqual(sum(job[2] == ('scan',) for job in self.panel.executor.jobs), 1) @@ -141,6 +162,18 @@ class TestBluetoothLarge(unittest.TestCase): self.panel.hide_event() self.assertEqual(executor.jobs[-1][1], self.panel.owner.close) + def test_opening_the_page_never_scans_through_android_auto(self): + from openpilot.starpilot.bluetooth.native_owner import NativeBluetoothOwner + from openpilot.starpilot.bluetooth.owner import AdapterBusyElsewhere, BluetoothOwner + owner = NativeBluetoothOwner(lambda: True) + local = {'available': True, 'powered': True, 'parked': True, 'discovering': False, 'devices': []} + with patch.object(BluetoothOwner, 'request', side_effect=AdapterBusyElsewhere('AA holds adapter')), \ + patch.object(BluetoothOwner, 'snapshot', return_value=local), \ + patch('openpilot.starpilot.galaxy_bluetooth.phone_ui.galaxy_call') as factory: + self.assertEqual(owner.request('scan', delegate=False), local) + factory.assert_not_called() + self.assertIsNone(owner.galaxy) + def test_pairing_prompt_opens_once_and_expired_prompt_is_retired(self): prompt = {'id': 'a' * 32, 'kind': 'confirmation', 'value': '123456', 'displayOnly': False} status: dict[str, Any] = {'available': True, 'powered': True, 'parked': True, 'discovering': False, @@ -182,6 +215,19 @@ class TestBluetoothLarge(unittest.TestCase): self.assertIn(('Headphones', 'Disconnect'), [row[0] for row in rows]) self.assertIn(('Forget Headphones', 'Forget'), [row[0] for row in rows]) + def test_app_connections_are_grouped_apart_from_bluetooth_devices(self): + self.panel.status = {'available': True, 'powered': True, 'parked': True, 'devices': [ + {'address': 'AA:BB', 'name': 'Headphones', 'paired': True, 'connected': True}, + {'address': 'CC:DD', 'name': 'iPhone', 'paired': False, 'trusted': False, 'connected': True}]} + self.panel._scroller = None + self.panel._rebuild() + names = [row[0] for row in self.panel._scroller.rows] + self.assertIn(('Headphones', 'Disconnect'), names) + self.assertNotIn(('iPhone', 'Pair'), names) + self.assertNotIn(('iPhone', 'Disconnect'), names) + self.assertIn('App pairings', [name[0] for name in names]) + self.assertIn(('iPhone', 'Forget'), names) + def test_forgetting_a_device_also_forgets_its_app_key(self): self.panel.show_event() self.panel.status = {'available': True, 'powered': True, 'parked': True} diff --git a/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py b/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py index c524d5cac5..360cb38e63 100644 --- a/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py +++ b/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py @@ -83,3 +83,12 @@ def test_texture_setup_failure_releases_both_resources(gpu): shapes.SpeedLimitShapes().circle(0, 0, shapes.rl.RED) gpu['unload_texture'].assert_called_once() gpu['unload_image'].assert_called_once() + + +def test_headless_context_loads_atlas_without_a_window(gpu, monkeypatch): + monkeypatch.setattr(shapes.rl, 'is_window_ready', lambda: False) + with pytest.raises(RuntimeError, match='graphics context'): + shapes.SpeedLimitShapes().circle(0, 0, shapes.rl.RED) + owner = shapes.SpeedLimitShapes(headless_context=True) + owner.circle(0, 0, shapes.rl.RED) + gpu['load_texture_from_image'].assert_called_once()