mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
Bluetooth: Coordinate companion pairing and Android Auto ownership
Expose pairing readiness and coordinate adapter hand-offs with Android Auto. Show app pairings separately, support individual removal, and improve pairing controls on both comma screens. Add Galaxy companion endpoints and web asset manifests, preserve the plain camera look, and fix headless speed-limit shapes.
This commit is contained in:
@@ -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()
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
e44a6965cd718d98829f344e9f300a5664d12020557127b7469bee7c8f3098b0 hev-socks5-tunnel_linux_arm64
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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.')
|
||||
@@ -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'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {size} {size}" shape-rendering="crispEdges">' +
|
||||
f'<rect width="100%" height="100%" fill="white"/><g fill="black">{cells}</g></svg>')
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
@@ -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"]))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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'})
|
||||
@@ -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}
|
||||
@@ -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):
|
||||
|
||||
@@ -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""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {})}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user