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.
307 lines
13 KiB
Python
307 lines
13 KiB
Python
import json
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from queue import Queue
|
|
from unittest.mock import patch
|
|
|
|
from jeepney import DBusAddress, new_method_call
|
|
from jeepney.low_level import HeaderFields, MessageType
|
|
|
|
from openpilot.starpilot.bluetooth.owner import (
|
|
AGENT, AGENT_PATH, BlueZ, BluetoothOwner, BluetoothRejected, OpenPairingSession,
|
|
)
|
|
from openpilot.starpilot.bluetooth.radio_preference import RadioPreference
|
|
|
|
PHONE = '/org/bluez/hci0/dev_11_22_33_44_55_66'
|
|
SPEAKER = '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_01'
|
|
A2DP_SOURCE = '0000110a-0000-1000-8000-00805f9b34fb'
|
|
SENDER = ':1.7'
|
|
|
|
|
|
def device(address, device_class, *, paired=False, uuids=()):
|
|
return {'Address': address, 'Alias': 'Pixel' if device_class >> 8 & 0x1f == 2 else 'Speaker', 'Class': device_class,
|
|
'Paired': paired, 'Blocked': False, 'UUIDs': list(uuids)}
|
|
|
|
|
|
class WindowHarness:
|
|
def __init__(self, test, *, pairable=False, discoverable=False, seconds=120.0):
|
|
self.responses, self.calls, self.visibility, self.journal, self.restored, self.classes = [], [], [], [], [], []
|
|
self.incoming = Queue()
|
|
self.now = time.monotonic
|
|
harness = self
|
|
|
|
class Router:
|
|
@contextmanager
|
|
def filter(self, *_args, **_kwargs):
|
|
yield harness.incoming
|
|
|
|
def send(self, message):
|
|
harness.responses.append(message)
|
|
|
|
self.objects = {
|
|
'/org/bluez/hci0': {'org.bluez.Adapter1': {'Powered': True, 'Discovering': False, 'Pairable': pairable,
|
|
'Discoverable': discoverable, 'Address': 'AA:AA:AA:AA:AA:AA',
|
|
'Class': 0x4c0000}},
|
|
PHONE: {'org.bluez.Device1': device('11:22:33:44:55:66', 0x5a020c)},
|
|
SPEAKER: {'org.bluez.Device1': device('AA:BB:CC:DD:EE:01', 0x240414)},
|
|
}
|
|
self.bluez = BlueZ.__new__(BlueZ)
|
|
test.enterContext(patch.object(self.bluez, 'router', Router(), create=True))
|
|
test.enterContext(patch.object(self.bluez, '_bluez_sender', lambda: SENDER))
|
|
test.enterContext(patch.object(self.bluez, '_objects', lambda: self.objects))
|
|
test.enterContext(patch.object(self.bluez, 'set_pairing_visibility',
|
|
lambda path, *, pairable, discoverable: self.visibility.append((pairable, discoverable))))
|
|
test.enterContext(patch.object(self.bluez, '_call', self.call))
|
|
test.enterContext(patch.object(self.bluez, 'set_class', lambda major, minor: self.classes.append((major, minor)) or True))
|
|
self.session = OpenPairingSession(('session', b'generation'), lambda: True, self.now)
|
|
self.session.deadline = self.now() + seconds
|
|
|
|
def call(self, _path, _interface, member, *_args, **_kwargs):
|
|
self.calls.append(member)
|
|
return ()
|
|
|
|
def agent(self, path, sender=SENDER):
|
|
message = new_method_call(DBusAddress(AGENT_PATH, bus_name=':1.9', interface=AGENT),
|
|
'RequestConfirmation', 'ou', (path, 123456))
|
|
message.header.fields[HeaderFields.sender] = sender
|
|
self.incoming.put(message)
|
|
|
|
def run(self):
|
|
return self.bluez.accept_pair(self.session, lambda *args: self.journal.append(args), lambda: self.restored.append(True))
|
|
|
|
|
|
class AcceptPairWindowTest(unittest.TestCase):
|
|
def test_first_phone_claims_window_and_visibility_returns_to_prior_values(self):
|
|
harness = WindowHarness(self, pairable=True, discoverable=False)
|
|
harness.agent(SPEAKER)
|
|
harness.agent(PHONE)
|
|
|
|
def finish_bond():
|
|
for _ in range(400):
|
|
if harness.session.path == PHONE and len(harness.responses) == 2:
|
|
time.sleep(0.05)
|
|
harness.objects[PHONE]['org.bluez.Device1'].update(Paired=True, UUIDs=[A2DP_SOURCE])
|
|
return
|
|
time.sleep(0.01)
|
|
threading.Thread(target=finish_bond, daemon=True).start()
|
|
self.assertTrue(harness.run())
|
|
self.assertEqual(harness.session.address, '11:22:33:44:55:66')
|
|
self.assertIn(A2DP_SOURCE, harness.session.uuids)
|
|
self.assertEqual(harness.visibility, [(True, True), (True, False)])
|
|
self.assertEqual(harness.journal, [('/org/bluez/hci0', SENDER, True, False)])
|
|
self.assertEqual(harness.restored, [True])
|
|
self.assertEqual(harness.calls.count('RequestDefaultAgent'), 1)
|
|
self.assertEqual(harness.calls[-1], 'UnregisterAgent')
|
|
self.assertIn('Set', harness.calls)
|
|
self.assertEqual([item.header.message_type for item in harness.responses], [MessageType.error, MessageType.method_return],
|
|
'the speaker is refused and the phone is confirmed without waiting')
|
|
self.assertEqual(harness.classes, [(4, 5), (0, 0)], 'shown as a speaker, then the original class returns')
|
|
|
|
def test_devices_that_are_not_phones_cannot_claim_the_window(self):
|
|
harness = WindowHarness(self, seconds=0.8)
|
|
harness.agent(SPEAKER)
|
|
self.assertFalse(harness.run())
|
|
self.assertEqual(harness.session.path, '')
|
|
self.assertEqual([item.header.message_type for item in harness.responses], [MessageType.error])
|
|
self.assertEqual(harness.visibility[-1], (False, False))
|
|
self.assertEqual(harness.restored, [True])
|
|
|
|
def test_second_phone_is_rejected_after_the_first_claims(self):
|
|
harness = WindowHarness(self, seconds=1.5)
|
|
other = '/org/bluez/hci0/dev_66_55_44_33_22_11'
|
|
harness.objects[other] = {'org.bluez.Device1': device('66:55:44:33:22:11', 0x5a020c)}
|
|
harness.agent(PHONE)
|
|
harness.agent(other)
|
|
self.assertFalse(harness.run())
|
|
self.assertEqual(harness.session.path, PHONE)
|
|
self.assertEqual([item.header.message_type for item in harness.responses], [MessageType.method_return, MessageType.error])
|
|
|
|
def test_unpark_closes_the_window_and_restores_visibility(self):
|
|
harness = WindowHarness(self, discoverable=True)
|
|
parked = [True]
|
|
harness.session.parked = lambda: parked[0]
|
|
threading.Timer(0.4, lambda: parked.__setitem__(0, False)).start()
|
|
self.assertFalse(harness.run())
|
|
self.assertEqual(harness.visibility, [(True, True), (False, True)])
|
|
self.assertEqual(harness.calls[-1], 'UnregisterAgent')
|
|
|
|
def test_failed_restore_keeps_the_journal_for_the_next_owner(self):
|
|
harness = WindowHarness(self, seconds=0.5)
|
|
attempts = []
|
|
|
|
def visibility(_path, *, pairable, discoverable):
|
|
attempts.append((pairable, discoverable))
|
|
if len(attempts) == 2:
|
|
raise BluetoothRejected('Bluetooth Pairable change failed')
|
|
self.enterContext(patch.object(harness.bluez, 'set_pairing_visibility', visibility))
|
|
with self.assertRaises(BluetoothRejected):
|
|
harness.run()
|
|
self.assertEqual(harness.restored, [])
|
|
self.assertEqual(len(harness.journal), 1)
|
|
|
|
|
|
class FakeWindowBlueZ:
|
|
instances = []
|
|
adapter = ('/org/bluez/hci0', {}, SENDER)
|
|
bonded = threading.Event()
|
|
|
|
def __init__(self):
|
|
self.visibility = []
|
|
type(self).instances.append(self)
|
|
|
|
def adapter_identity(self):
|
|
return type(self).adapter
|
|
|
|
def set_pairing_visibility(self, path, *, pairable, discoverable):
|
|
self.visibility.append((path, pairable, discoverable))
|
|
|
|
def accept_pair(self, session, journal, restored):
|
|
journal(*type(self).adapter[:1], SENDER, False, False)
|
|
session.claim(PHONE, '11:22:33:44:55:66', 'Pixel')
|
|
session.uuids = (A2DP_SOURCE,)
|
|
accepted, _ = session.ask('confirmation', PHONE, '123456')
|
|
while accepted and session.state == 'pairing' and not type(self).bonded.wait(0.02):
|
|
pass
|
|
restored()
|
|
return accepted and type(self).bonded.is_set()
|
|
|
|
def snapshot(self):
|
|
return {'adapter': True, 'powered': True, 'discovering': False, 'devices': []}
|
|
|
|
def cancel_pair(self, _path):
|
|
pass
|
|
|
|
def _objects(self):
|
|
return None
|
|
|
|
def _devices(self, _objects):
|
|
return []
|
|
|
|
def operation(self, *_args):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class AcceptPairOwnerTest(unittest.TestCase):
|
|
identity = ('session-a', b'generation')
|
|
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
root = Path(self.temp.name)
|
|
(root / 'bluetooth-radio').write_bytes(b'helper')
|
|
FakeWindowBlueZ.instances = []
|
|
FakeWindowBlueZ.adapter = ('/org/bluez/hci0', {}, SENDER)
|
|
FakeWindowBlueZ.bonded = threading.Event()
|
|
self.paired = []
|
|
self.owner = BluetoothOwner(lambda: True, bluez_factory=FakeWindowBlueZ, radio_helper=root / 'bluetooth-radio',
|
|
systemctl=lambda command, **_kwargs: type('Result', (), {'stdout': 'active\n'})(),
|
|
radio_preference=RadioPreference(root / 'BluetoothEnabled'),
|
|
admission_lock=root / 'owner.lock', on_paired=lambda *args: self.paired.append(args))
|
|
|
|
def wait_for_claim(self):
|
|
for _ in range(300):
|
|
prompt = self.owner.snapshot(session=self.identity)['pairing']
|
|
if prompt and prompt['prompt']:
|
|
return prompt
|
|
time.sleep(0.01)
|
|
self.fail('phone never claimed the window')
|
|
|
|
def test_window_requires_a_session_and_no_address(self):
|
|
with self.assertRaises(ValueError):
|
|
self.owner.request('accept_pair')
|
|
with self.assertRaises(ValueError):
|
|
self.owner.request('accept_pair', address='11:22:33:44:55:66', session=self.identity)
|
|
|
|
def test_open_window_blocks_other_pairing_and_reports_the_claimed_phone(self):
|
|
started = self.owner.request('accept_pair', session=self.identity)
|
|
self.assertTrue(started['pairing']['incoming'])
|
|
prompt = self.wait_for_claim()
|
|
self.assertEqual(prompt['address'], '11:22:33:44:55:66')
|
|
self.assertEqual(prompt['name'], 'Pixel')
|
|
self.assertTrue(prompt['prompt']['displayOnly'], 'the code is shown, not waited on')
|
|
for operation in ('scan', 'accept_pair'):
|
|
with self.assertRaises(BluetoothRejected) as raised:
|
|
self.owner.request(operation, session=self.identity)
|
|
self.assertEqual(raised.exception.code, 'busy')
|
|
FakeWindowBlueZ.bonded.set()
|
|
self.assertTrue(self.owner.pairing.done.wait(2))
|
|
self.assertEqual(self.paired, [('11:22:33:44:55:66', 'Pixel', (A2DP_SOURCE,))])
|
|
self.assertEqual(self.owner.snapshot(session=self.identity)['pairing']['state'], 'paired')
|
|
|
|
def request_when_free(self, *args, **kwargs):
|
|
# The closed window's watcher briefly holds the owner lock while it lets go of the adapter.
|
|
for _ in range(100):
|
|
try:
|
|
return self.owner.request(*args, **kwargs)
|
|
except BluetoothRejected as error:
|
|
if str(error) != 'Bluetooth is busy':
|
|
raise
|
|
time.sleep(0.02)
|
|
self.fail('owner stayed busy')
|
|
|
|
def test_unclaimed_window_gives_way_to_forget_and_pair(self):
|
|
for operation in ('forget', 'connect', 'disconnect', 'scan'):
|
|
self.request_when_free('accept_pair', session=self.identity)
|
|
self.wait_for_claim()
|
|
window = self.owner.pairing
|
|
assert window is not None
|
|
window.path = ''
|
|
address = None if operation == 'scan' else '11:22:33:44:55:66'
|
|
self.request_when_free(operation, address=address, session=self.identity)
|
|
self.assertTrue(window.done.is_set(), operation)
|
|
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()
|
|
self.owner.request('cancel_pair', session=self.identity)
|
|
self.assertTrue(self.owner.pairing.done.wait(2))
|
|
self.assertEqual(self.paired, [])
|
|
|
|
def test_crash_journal_restores_visibility_only_on_the_same_bluez(self):
|
|
journal = self.owner.window_journal_path
|
|
journal.write_text(json.dumps({'path': '/org/bluez/hci0', 'sender': SENDER, 'pairable': True, 'discoverable': False}))
|
|
self.owner.request('accept_pair', session=self.identity)
|
|
self.wait_for_claim()
|
|
self.assertEqual(FakeWindowBlueZ.instances[0].visibility, [('/org/bluez/hci0', True, False)])
|
|
self.assertTrue(isinstance(self.owner.pairing, OpenPairingSession))
|
|
self.owner.request('cancel_pair', session=self.identity)
|
|
self.assertTrue(self.owner.pairing.done.wait(2))
|
|
self.assertFalse(journal.exists())
|
|
|
|
def test_stale_journal_from_another_bluez_instance_is_dropped_unapplied(self):
|
|
journal = self.owner.window_journal_path
|
|
journal.write_text(json.dumps({'path': '/org/bluez/hci0', 'sender': ':1.99', 'pairable': True, 'discoverable': True}))
|
|
self.owner.request('accept_pair', session=self.identity)
|
|
self.wait_for_claim()
|
|
self.assertEqual(FakeWindowBlueZ.instances[0].visibility, [])
|
|
self.owner.request('cancel_pair', session=self.identity)
|
|
self.assertTrue(self.owner.pairing.done.wait(2))
|
|
self.assertFalse(journal.exists())
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|