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()