mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
a531afd02a
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.
276 lines
14 KiB
Python
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)
|