Files
StarPilot/openpilot/starpilot/ui/tests/test_bluetooth_compact.py
T
Danny a531afd02a 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.
2026-10-22 15:37:20 -07:00

276 lines
14 KiB
Python

"""C4 Bluetooth delegates mutations to the parked owner off the render thread."""
from unittest.mock import patch
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import replace
from types import SimpleNamespace as NS
import time
import unittest
from unittest.mock import Mock
from openpilot.starpilot.ui.bluetooth_compact import BluetoothCompact, device_label
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.settings_state import Destination, SettingsInput, SettingsState
class TestBluetoothCompact(unittest.TestCase):
def test_device_names_are_bounded_and_unpaired_device_starts_owned_pairing(self):
self.assertEqual(device_label({"name": "A\nB\x00", "address": "AA:BB:CC:DD:EE:FF"}), "AB")
page = BluetoothCompact.__new__(BluetoothCompact)
page.status = {"devices": [{"address": "AA:BB:CC:DD:EE:FF", "name": "New", "paired": False}]}
with patch.object(page, "_request") as request:
page._open_device("AA:BB:CC:DD:EE:FF")
request.assert_called_once_with("pair", address="AA:BB:CC:DD:EE:FF")
def test_connected_unpaired_phone_app_is_forgotten_not_paired(self):
page = BluetoothCompact.__new__(BluetoothCompact)
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:
def __init__(self, label, subtitle="", *args, **kwargs):
self.label, self.subtitle, self.click = label, subtitle, None
def set_click_callback(self, callback):
self.click = callback
def set_enabled(self, callback):
self.enabled = callback
page = BluetoothCompact.__new__(BluetoothCompact)
page.session = ('compact', 'one')
page.active = True
page.status = {'available': True, 'parked': True, 'powered': True, 'devices': [],
'pairing': {'address': 'AA:BB:CC:DD:EE:FF', 'state': 'pairing',
'prompt': {'id': 'a' * 32, 'kind': kind, 'value': value, 'displayOnly': False}}}
page.pending = None
page.queued = None
page.return_to_root = False
page.icon = object()
page.pair_dialog = None
page.pair_dialog_id = None
self.enterContext(patch.object(page, 'executor', NS(submit=Mock(return_value=Future())), create=True))
self.enterContext(patch.object(page, 'owner', NS(request=Mock()), create=True))
self.enterContext(patch('openpilot.starpilot.ui.bluetooth_compact.BigButton', Button))
self.enterContext(patch('openpilot.starpilot.ui.bluetooth_compact.GreyBigButton', Button))
self.enterContext(patch.object(BluetoothCompact, '_rebuild'))
return page
def test_confirmation_requires_touch_then_explicit_slider_and_current_prompt(self):
page = self.pair_page()
dialogs = []
def dialog(title, _icon, callback):
made = NS(title=title, confirm=callback)
dialogs.append(made)
return made
with patch('openpilot.starpilot.ui.bluetooth_compact.BigConfirmationDialog', dialog), \
patch('openpilot.starpilot.ui.bluetooth_compact.gui_app.push_widget') as push:
cards = page._pairing_cards()
self.assertEqual(next(card.subtitle for card in cards if card.label == 'match this code'), '123456')
confirm = next(card for card in cards if card.label == 'confirm match')
self.assertTrue(confirm.enabled())
page.executor.submit.assert_not_called()
confirm.click()
self.assertIn('123456', dialogs[0].title)
push.assert_called_once_with(dialogs[0])
page.executor.submit.assert_not_called()
page.status['pairing']['prompt'] = {**page.status['pairing']['prompt'], 'id': 'b' * 32}
dialogs[0].confirm()
page.executor.submit.assert_not_called()
page.status['pairing']['prompt']['id'] = 'a' * 32
dialogs[0].confirm()
page.executor.submit.assert_called_once_with(page.owner.request, 'pairing_response', address=None,
session=page.session, prompt_id='a' * 32, accepted=True, value='')
def test_passkey_input_is_masked_bounded_and_cancel_uses_same_session(self):
page = self.pair_page('passkey', '')
dialogs = []
def input_dialog(hint, **kwargs):
made = NS(hint=hint, **kwargs)
dialogs.append(made)
return made
with patch('openpilot.starpilot.ui.bluetooth_compact.BigInputDialog', input_dialog), \
patch('openpilot.starpilot.ui.bluetooth_compact.gui_app.push_widget'):
cards = page._pairing_cards()
next(card for card in cards if card.label == 'enter passkey').click()
self.assertTrue(dialogs[0].password_mode)
self.assertFalse(dialogs[0].text_validator('1234567'))
self.assertFalse(dialogs[0].text_validator('abc'))
self.assertTrue(dialogs[0].text_validator('123456'))
dialogs[0].confirm_callback('123456')
page.executor.submit.assert_called_once_with(page.owner.request, 'pairing_response', address=None,
session=page.session, prompt_id='a' * 32,
accepted=True, value='123456')
page.pending = None
page.queued = None
next(card for card in page._pairing_cards() if card.label == 'cancel pairing').click()
page.executor.submit.assert_called_with(page.owner.request, 'cancel_pair', address=None,
session=page.session, prompt_id=None, accepted=None, value='')
def test_pin_input_is_masked_and_rejects_control_characters(self):
page = self.pair_page('pin', '')
with patch('openpilot.starpilot.ui.bluetooth_compact.BigInputDialog', side_effect=lambda *args, **kwargs: NS(**kwargs)), \
patch('openpilot.starpilot.ui.bluetooth_compact.gui_app.push_widget') as push:
next(card for card in page._pairing_cards() if card.label == 'enter PIN').click()
dialog = push.call_args.args[0]
self.assertTrue(dialog.password_mode)
self.assertTrue(dialog.text_validator('0000'))
self.assertFalse(dialog.text_validator('a\n'))
self.assertFalse(dialog.text_validator('x' * 17))
def test_dismissed_page_revokes_pair_session_before_worker_completion(self):
page = self.pair_page()
session = page.session
page.pending = Future()
page.operation = 'pair'
page.pending_session = session
page.executor.submit.return_value = Future()
page.executor.shutdown = Mock()
page.owner.close = Mock()
with patch('openpilot.starpilot.ui.bluetooth_compact.gui_app.remove_nav_stack_tick'), \
patch('openpilot.starpilot.ui.bluetooth_compact.NavScroller.hide_event'):
page.hide_event()
self.assertFalse(page.active)
self.assertIsNone(page.session)
self.assertIsNone(page.status)
page.active = True
page.session = ('compact', 'two')
page.executor = None
page.pending.set_result({'available': True, 'parked': True, 'pairing': {'state': 'pairing'}})
with patch.object(page, '_snapshot') as snapshot, \
patch('openpilot.starpilot.ui.bluetooth_compact.gui_app.push_widget') as push:
page._tick()
self.assertIsNone(page.status)
snapshot.assert_called_once_with()
push.assert_not_called()
def test_owner_operation_is_submitted_to_worker_and_result_updates_on_tick(self):
page = BluetoothCompact.__new__(BluetoothCompact)
future = Future()
owner = NS(request=Mock())
self.enterContext(patch.object(page, "owner", owner, create=True))
self.enterContext(patch.object(page, "executor", NS(submit=Mock(return_value=future)), create=True))
page.status = {"available": True, "parked": True, "powered": False}
page.pending = None
page.queued = None
page.next_refresh = float("inf")
page.return_to_root = False
with patch.object(BluetoothCompact, "_rebuild") as rebuild:
page._request("power", 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": []}
future.set_result(result)
page._tick()
self.assertEqual(page.status, result)
self.assertIsNone(page.pending)
self.assertEqual(rebuild.call_count, 2)
def test_parked_status_blocks_submission_and_hide_closes_owner_on_worker(self):
page = BluetoothCompact.__new__(BluetoothCompact)
owner = NS(close=Mock(), request=Mock())
self.enterContext(patch.object(page, "owner", owner, create=True))
self.enterContext(patch.object(page, "executor", NS(submit=Mock(return_value=Future()), shutdown=Mock()), create=True))
executor = page.executor
page.status = {"available": True, "parked": False}
page.pending = None
page.queued = None
with patch.object(page, "_rebuild"):
page._request("power", enabled=True)
executor.submit.assert_not_called()
with patch("openpilot.starpilot.ui.bluetooth_compact.gui_app.remove_nav_stack_tick"), \
patch("openpilot.starpilot.ui.bluetooth_compact.NavScroller.hide_event"):
page.hide_event()
executor.submit.assert_called_once_with(owner.close)
executor.shutdown.assert_called_once_with(wait=False, cancel_futures=False)
self.assertIsNone(page.executor)
def test_close_retires_worker_and_next_snapshot_recreates_it(self):
page = BluetoothCompact.__new__(BluetoothCompact)
owner = NS(close=Mock(), snapshot=Mock(return_value={"available": True, "parked": True}))
self.enterContext(patch.object(page, "owner", owner, create=True))
page.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="c4-bluetooth-test")
old_executor = page.executor
page.pending = old_executor.submit(lambda: None)
page.pending.result(timeout=1)
page.operation = "snapshot"
page.status = {"available": True, "parked": True}
page.queued = None
page.return_to_root = False
with patch("openpilot.starpilot.ui.bluetooth_compact.gui_app.remove_nav_stack_tick"), \
patch("openpilot.starpilot.ui.bluetooth_compact.NavScroller.hide_event"):
page.hide_event()
self.assertIsNone(page.executor)
page.pending.result(timeout=1)
for _ in range(50):
if not any(thread.is_alive() for thread in old_executor._threads):
break
time.sleep(0.01)
self.assertFalse(any(thread.is_alive() for thread in old_executor._threads))
page.pending = None
page._snapshot()
self.assertIsNot(page.executor, old_executor)
page.pending.result(timeout=1)
page.executor.shutdown(wait=True)
def test_action_clicked_during_refresh_is_queued_without_blocking_ui(self):
page = BluetoothCompact.__new__(BluetoothCompact)
refreshing, action = Future(), Future()
owner = NS(request=Mock())
self.enterContext(patch.object(page, "owner", owner, create=True))
self.enterContext(patch.object(page, "executor", NS(submit=Mock(return_value=action)), create=True))
page.status = {"available": True, "parked": True, "powered": True}
page.pending = refreshing
page.operation = "snapshot"
page.queued = None
page.return_to_root = False
page.next_refresh = float("inf")
with patch.object(BluetoothCompact, "_rebuild"):
page._request("scan")
page.executor.submit.assert_not_called()
refreshing.set_result(page.status)
page._tick()
page.executor.submit.assert_called_once_with(owner.request, "scan", address=None, enabled=None)
self.assertIs(page.pending, action)
self.assertIsNone(page.queued)
def test_main_menu_opens_bluetooth_panel(self):
from openpilot.starpilot.ui import runtime_app
layout = runtime_app.StarMiciMainLayout.__new__(runtime_app.StarMiciMainLayout)
object.__setattr__(layout, "_compact_panels", {})
panel = Mock()
object.__setattr__(layout, "star", NS(connectivity_allowed=Mock(return_value=True)))
with patch("openpilot.starpilot.ui.bluetooth_compact.BluetoothCompact", return_value=panel) as built, \
patch.object(runtime_app.gui_app, "push_widget") as push:
layout._open_compact_destination(Destination.BLUETOOTH)
layout._open_compact_destination(Destination.BLUETOOTH)
built.assert_called_once_with(layout.star.connectivity_allowed)
self.assertEqual(push.call_count, 2)
def test_firehose_is_not_a_compact_settings_destination(self):
from openpilot.selfdrive.ui.mici.layouts.settings import developer
self.assertFalse(hasattr(developer.DeveloperLayoutMici, "_open_firehose"))
def test_paired_compact_menu_removes_pair_card_and_preserves_hit_targets(self):
from openpilot.starpilot.ui.settings_state import compact_menu
paired = SettingsState(paired=True)
self.assertIn(Destination.PAIR, [item[0] for item in compact_menu(SettingsState())])
self.assertNotIn(Destination.PAIR, [item[0] for item in compact_menu(paired)])
self.assertEqual(len(compact_menu(SettingsState())) - len(compact_menu(paired)), 1)
emitted = []
touch = SettingsInput(Profile.COMPACT, emitted.append)
index = next(i for i, item in enumerate(compact_menu(paired)) if item[0] == Destination.DEVELOPER)
paired = replace(paired, compact_scroll_x=-422 * index)
touch.press(45, 75, paired)
touch.release(45, 75, paired)
self.assertEqual(emitted[-1].destination.destination, Destination.DEVELOPER)