Files
StarPilot/openpilot/starpilot/bluetooth/tests/test_owner.py
T

858 lines
38 KiB
Python

import subprocess
import tempfile
import threading
import time
import unittest
from contextlib import contextmanager
from pathlib import Path
from queue import Queue
from unittest.mock import Mock, patch
from jeepney import DBusAddress, new_method_call
from jeepney.low_level import HeaderFields, MessageType
from openpilot.starpilot.bluetooth.radio_preference import RadioPreference
from openpilot.starpilot.bluetooth.owner import (
AGENT, AGENT_PATH, BlueZ, BluetoothOwner, BluetoothRejected, BluetoothUnavailable, PairingSession, normalized_address,
)
class FakeBlueZ:
devices = [{'address': 'AA:BB:CC:DD:EE:FF', 'name': 'Saved speaker', 'paired': True,
'connected': False, 'trusted': True}]
operations = []
powered = False
closed = 0
def snapshot(self):
return {'adapter': True, 'powered': self.powered, 'discovering': False,
'devices': [dict(device) for device in self.devices]}
def operation(self, operation, address=None):
self.operations.append((operation, address))
if operation == 'connect':
self.devices[0]['connected'] = True
if operation == 'disconnect':
self.devices[0]['connected'] = False
if operation == 'forget':
self.devices.clear()
def _objects(self):
return None
def _devices(self, _objects):
return [dict(device, path='/org/bluez/hci0/dev_' + device['address'].replace(':', '_')) for device in self.devices]
def pair(self, address, path, session):
self.operations.append(('pair', address))
accepted, _ = session.ask('confirmation', path, '123456')
if accepted:
next(device for device in self.devices if device['address'] == address)['paired'] = True
return accepted
def cancel_pair(self, path):
self.operations.append(('cancel_pair', path))
def set_powered(self, enabled):
type(self).powered = enabled
def close(self):
type(self).closed += 1
class FakeTimer:
def __init__(self, delay, callback, args):
self.delay = delay
self.callback = callback
self.args = args
self.daemon = False
self.cancelled = False
def start(self):
pass
def cancel(self):
self.cancelled = True
def fire(self):
self.callback(*self.args)
class BluetoothOwnerTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.helper = Path(self.temp.name) / 'bluetooth-radio'
self.helper.write_bytes(b'helper')
self.parked = [True]
self.commands = []
self.preference = RadioPreference(Path(self.temp.name) / 'BluetoothEnabled')
FakeBlueZ.devices = [{'address': 'AA:BB:CC:DD:EE:FF', 'name': 'Saved speaker', 'paired': True,
'connected': False, 'trusted': True}]
FakeBlueZ.operations = []
FakeBlueZ.powered = False
FakeBlueZ.closed = 0
self.owner = BluetoothOwner(lambda: self.parked[0], bluez_factory=FakeBlueZ, radio_helper=self.helper,
systemctl=self.systemctl, radio_preference=self.preference,
timer_factory=FakeTimer, admission_lock=Path(self.temp.name) / 'adapter.lock')
forget_car = patch.object(BluetoothOwner, '_forget_starport_car')
self.forgotten_cars = forget_car.start()
self.addCleanup(forget_car.stop)
def systemctl(self, command, **kwargs):
self.commands.append((command, kwargs))
if command[1] == 'show':
return subprocess.CompletedProcess(command, 0, 'inactive\n', '')
if command[-2] == 'start':
self.assertTrue(self.preference.enabled(), 'AGNOS ExecCondition must see On before starting')
if command[-2] == 'stop':
FakeBlueZ.powered = False
def connectivity_fixture(self):
from openpilot.starpilot.ui.tests.test_runtime_snapshot import TestParkedClockDomains
fixture = TestParkedClockDomains()
fixture.setUp()
fixture.ui.sm['pandaStates'][0].ignitionCan = True
self.owner.parked = fixture.adapter.connectivity_allowed
self.addCleanup(self.owner.close)
return fixture
def test_forced_offroad_power_scan_connect_and_revocation_use_actual_owner(self):
fixture = self.connectivity_fixture()
self.assertTrue(fixture.adapter.confirmed_offroad())
self.assertTrue(self.owner.snapshot()['parked'])
self.assertTrue(self.owner.request('power', enabled=True)['powered'])
self.owner.request('scan')
self.assertIn(('scan', None), FakeBlueZ.operations)
self.owner.request('stop_scan')
self.assertTrue(self.owner.request('connect', address='AA:BB:CC:DD:EE:FF')['devices'][0]['connected'])
self.assertFalse(self.owner.request('disconnect', address='AA:BB:CC:DD:EE:FF')['devices'][0]['connected'])
fixture.ui.started = True
self.assertFalse(fixture.adapter.confirmed_offroad())
self.assertFalse(self.owner.snapshot()['parked'])
before = list(FakeBlueZ.operations)
self.assertTrue(self.owner.request('connect', address='AA:BB:CC:DD:EE:FF')['devices'][0]['connected'])
self.assertEqual(FakeBlueZ.operations[len(before):], [('connect', 'AA:BB:CC:DD:EE:FF')])
with self.assertRaises(BluetoothRejected):
self.owner.request('power', enabled=False)
def test_pairing_survives_road_transition_but_not_session_revocation(self):
fixture = self.connectivity_fixture()
FakeBlueZ.devices.append({'address': '11:22:33:44:55:66', 'name': 'Nearby', 'paired': False,
'connected': False, 'trusted': False})
identity = ('session-offroad', b'generation')
self.owner.request('pair', address='11:22:33:44:55:66', session=identity)
prompt = None
for _ in range(100):
prompt = self.owner.snapshot(session=identity)['pairing']['prompt']
if prompt is not None:
break
time.sleep(.01)
assert prompt is not None
with self.assertRaises(BluetoothRejected):
self.owner.request('pairing_response', session=('wrong', b'generation'), prompt_id=prompt['id'], accepted=True)
self.owner.request('pairing_response', session=identity, prompt_id=prompt['id'], accepted=True)
self.assertTrue(self.owner.pairing.done.wait(1))
self.assertTrue(FakeBlueZ.devices[-1]['paired'])
FakeBlueZ.devices[-1]['paired'] = False
with self.owner.lock:
self.owner.request('pair', address='11:22:33:44:55:66', session=identity)
pairing = self.owner.pairing
fixture.ui.sm['deviceState'].started = True
self.assertEqual(self.owner.snapshot(session=identity)['pairing']['state'], 'pairing')
self.owner.session_valid = lambda _identity: False
self.owner.snapshot(session=identity)
self.assertTrue(pairing.done.wait(1))
self.assertEqual(pairing.state, 'canceled')
self.assertFalse(FakeBlueZ.devices[-1]['paired'])
def test_forced_offroad_power_revocation_rolls_back_before_bluez(self):
fixture = self.connectivity_fixture()
def revoke_start(command, **kwargs):
result = self.systemctl(command, **kwargs)
if command[-2] == 'start':
fixture.ui.started = True
return result
self.owner.systemctl = revoke_start
with self.assertRaises(BluetoothRejected):
self.owner.request('power', enabled=True)
self.assertFalse(FakeBlueZ.powered)
self.assertFalse(self.preference.path.exists())
self.assertEqual(self.commands[-1][0], ['sudo', '-n', 'systemctl', 'stop', 'starpilot-bluetooth-radio.service'])
def test_power_status_and_device_changes_are_observed(self):
self.assertFalse(self.owner.snapshot()['powered'])
self.assertTrue(self.owner.request('power', enabled=True)['powered'])
self.assertEqual(self.commands[1][0], ['sudo', '-n', 'systemctl', 'start', 'starpilot-bluetooth-radio.service'])
self.assertTrue(self.owner.request('connect', address='aa:bb:cc:dd:ee:ff')['devices'][0]['connected'])
self.assertEqual(FakeBlueZ.operations[-1], ('connect', 'AA:BB:CC:DD:EE:FF'))
self.assertFalse(self.owner.request('disconnect', address='AA:BB:CC:DD:EE:FF')['devices'][0]['connected'])
self.assertEqual(self.owner.request('forget', address='AA:BB:CC:DD:EE:FF')['devices'], [])
self.forgotten_cars.assert_called_once_with('AA:BB:CC:DD:EE:FF')
self.assertFalse(self.owner.request('power', enabled=False)['powered'])
self.assertEqual(self.commands[-1][0], ['sudo', '-n', 'systemctl', 'stop', 'starpilot-bluetooth-radio.service'])
self.assertGreater(FakeBlueZ.closed, 0)
def test_parked_and_input_guard_precedes_side_effects(self):
for address in ('', 'AA:BB:CC:DD:EE:GG', 'AA:BB:CC:DD:EE:FF; touch /tmp/x', '00:11:22:33:44:55\n'):
with self.subTest(address=address), self.assertRaises(ValueError):
self.owner.request('connect', address=address)
with self.assertRaises(ValueError):
Mock(wraps=self.owner.request)('power', enabled=1)
with self.assertRaises(ValueError):
self.owner.request('pair', address='AA:BB:CC:DD:EE:FF')
self.parked[0] = False
with self.assertRaises(BluetoothRejected):
self.owner.request('power', enabled=True)
self.assertFalse(self.commands)
self.assertFalse(FakeBlueZ.operations)
def test_missing_radio_or_adapter_are_unavailable(self):
self.helper.unlink()
self.assertEqual(self.owner.snapshot()['errorCode'], 'radio_unavailable')
with self.assertRaises(BluetoothUnavailable):
self.owner.request('power', enabled=True)
def test_starport_can_power_on_onroad_with_a_valid_session(self):
self.parked[0] = False
session = ('android-auto-session',)
self.owner.session_valid = lambda identity: identity == session
result = self.owner.request('power', enabled=True, session=session, power_on_for_starport=True)
self.assertTrue(result['powered'])
self.assertFalse(result['parked'])
self.assertEqual(self.preference.path.read_bytes(), b'1')
with self.assertRaises(BluetoothRejected):
self.owner.request('power', enabled=False, session=session)
for operation, enabled, identity in [('power', False, session), ('power', True, None), ('scan', None, session)]:
with self.subTest(operation=operation, enabled=enabled), self.assertRaises(ValueError):
self.owner.request(operation, enabled=enabled, session=identity, power_on_for_starport=True)
def test_starport_power_on_rechecks_session_and_rolls_back(self):
self.parked[0] = False
valid = [True]
self.owner.session_valid = lambda _identity: valid[0]
def revoke_start(command, **kwargs):
result = self.systemctl(command, **kwargs)
if command[-2] == 'start':
valid[0] = False
return result
self.owner.systemctl = revoke_start
with self.assertRaises(BluetoothRejected) as rejected:
self.owner.request('power', enabled=True, session=('android-auto-session',), power_on_for_starport=True)
self.assertEqual(rejected.exception.code, 'session_expired')
self.assertFalse(FakeBlueZ.powered)
self.assertFalse(self.preference.path.exists())
self.assertIsNone(self.owner.admission_file)
self.assertEqual(self.commands[-1][0], ['sudo', '-n', 'systemctl', 'stop', 'starpilot-bluetooth-radio.service'])
def test_missing_adapter_and_service_failure_are_distinct_and_read_only(self):
with patch.object(FakeBlueZ, 'snapshot', return_value={'adapter': False, 'powered': False, 'discovering': False, 'devices': []}):
self.assertEqual(self.owner.snapshot()['errorCode'], 'adapter_unavailable')
self.preference.path.write_bytes(b'1')
with patch.object(FakeBlueZ, 'snapshot', side_effect=OSError('system bus unavailable')):
status = self.owner.snapshot()
self.assertEqual(status['errorCode'], 'service_unavailable')
self.assertFalse(status['powered'])
self.assertFalse(self.commands)
self.assertFalse(FakeBlueZ.operations)
self.assertIsNone(self.owner.snapshot()['errorCode'])
def test_rejections_identify_parked_and_other_radio_owner(self):
self.parked[0] = False
with self.assertRaises(BluetoothRejected) as rejected:
self.owner.request('power', enabled=True)
self.assertEqual(rejected.exception.code, 'park_required')
self.parked[0] = True
self.owner.request('scan')
other = BluetoothOwner(lambda: True, bluez_factory=FakeBlueZ, radio_helper=self.helper,
admission_lock=Path(self.temp.name) / 'adapter.lock', radio_preference=self.preference)
try:
with self.assertRaises(BluetoothRejected) as rejected:
other.request('power', enabled=True)
self.assertEqual(rejected.exception.code, 'busy')
finally:
other.close()
self.owner.close()
def test_cold_radio_status_is_read_only_and_explicit_power_starts_service(self):
running = [False]
def client():
if not running[0]:
raise OSError('org.bluez is not running')
return FakeBlueZ()
def systemctl(command, **kwargs):
result = self.systemctl(command, **kwargs)
if command[1] == 'show':
return result
running[0] = command[-2] == 'start'
self.owner.bluez_factory = client
self.owner.systemctl = systemctl
for _ in range(3):
status = self.owner.snapshot()
self.assertTrue(status['available'])
self.assertFalse(status['powered'])
self.assertIsNone(status['errorCode'])
self.assertFalse(self.preference.path.exists())
self.assertFalse(self.commands)
self.assertTrue(self.owner.request('power', enabled=True)['powered'])
self.assertEqual(self.preference.path.read_bytes(), b'1')
self.assertFalse(self.owner.request('power', enabled=False)['powered'])
self.assertEqual(self.preference.path.read_bytes(), b'0')
self.assertFalse(running[0])
def test_power_failure_restores_exact_prior_preference(self):
for previous in (None, b'0\n', b'1\n'):
with self.subTest(previous=previous):
self.owner.close()
if previous is None:
self.preference.path.unlink(missing_ok=True)
else:
self.preference.path.write_bytes(previous)
def refuse_start(command, **kwargs):
if command[-2] == 'start':
raise subprocess.CalledProcessError(1, command)
return self.systemctl(command, **kwargs)
self.owner.systemctl = refuse_start
with self.assertRaises(BluetoothUnavailable):
self.owner.request('power', enabled=True)
self.assertEqual(self.preference.path.read_bytes() if self.preference.path.exists() else None, previous)
self.assertIsNone(self.owner.admission_file)
def test_bluez_failure_rolls_back_preference_while_retaining_radio_lease(self):
other = BluetoothOwner(lambda: True, bluez_factory=FakeBlueZ, radio_helper=self.helper,
admission_lock=Path(self.temp.name) / 'adapter.lock', radio_preference=self.preference)
self.addCleanup(other.close)
original_begin = self.preference.begin
def begin(enabled):
change = original_begin(enabled)
original_rollback = change.rollback
def rollback():
with self.assertRaises(BluetoothRejected) as rejected:
other.request('power', enabled=False)
self.assertEqual(rejected.exception.code, 'busy')
return original_rollback()
replacement = patch.object(change, 'rollback', rollback)
replacement.start()
self.addCleanup(replacement.stop)
return change
replacement = patch.object(self.preference, 'begin', begin)
replacement.start()
self.addCleanup(replacement.stop)
with patch.object(FakeBlueZ, 'set_powered', side_effect=OSError('BlueZ disappeared')):
with self.assertRaises(BluetoothUnavailable):
self.owner.request('power', enabled=True)
self.assertFalse(self.preference.path.exists())
self.assertIsNone(self.owner.admission_file)
def test_failed_power_cleans_up_only_newly_started_service(self):
for service_state in ('inactive', 'failed', 'active', 'activating'):
with self.subTest(service_state=service_state):
self.commands.clear()
self.preference.path.write_bytes(b'0\n')
def systemctl(command, service_state=service_state, **kwargs):
if command[1] == 'show':
return subprocess.CompletedProcess(command, 0, service_state + '\n', '')
return self.systemctl(command, **kwargs)
self.owner.systemctl = systemctl
with patch.object(FakeBlueZ, 'set_powered', side_effect=BluetoothUnavailable('adapter disappeared')):
with self.assertRaises(BluetoothUnavailable):
self.owner.request('power', enabled=True)
self.assertEqual(self.preference.path.read_bytes(), b'0\n')
stops = [command for command, _ in self.commands if command[-2] == 'stop']
self.assertEqual(len(stops), int(service_state in ('inactive', 'failed')))
def test_unknown_service_state_aborts_before_preference_write(self):
self.owner.systemctl = Mock(return_value=subprocess.CompletedProcess([], 0, '', ''))
with self.assertRaises(BluetoothUnavailable):
self.owner.request('power', enabled=True)
self.assertFalse(self.preference.path.exists())
self.assertEqual(self.owner.systemctl.call_count, 1)
self.assertIsNone(self.owner.admission_file)
def test_cleanup_failure_is_reported_and_preference_is_restored(self):
self.preference.path.write_bytes(b'0\n')
def systemctl(command, **kwargs):
if command[-2] == 'stop':
raise subprocess.CalledProcessError(1, command)
return self.systemctl(command, **kwargs)
self.owner.systemctl = systemctl
with patch.object(FakeBlueZ, 'set_powered', side_effect=BluetoothUnavailable('adapter disappeared')):
with self.assertRaisesRegex(BluetoothUnavailable, 'startup could not be stopped'):
self.owner.request('power', enabled=True)
self.assertEqual(self.preference.path.read_bytes(), b'0\n')
self.assertIsNone(self.owner.admission_file)
def test_refusal_before_power_does_not_write_preference(self):
self.preference.path.write_bytes(b'0\n')
self.owner.session_valid = lambda _: False
with self.assertRaises(BluetoothRejected):
self.owner.request('power', enabled=True, session=('expired',))
self.assertEqual(self.preference.path.read_bytes(), b'0\n')
self.assertFalse(self.commands)
def test_failed_power_does_not_overwrite_concurrent_preference_change(self):
self.preference.path.write_bytes(b'0\n')
def externally_changed(command, **kwargs):
if command[1] == 'show':
return self.systemctl(command, **kwargs)
self.preference.path.write_bytes(b'1\n')
raise subprocess.CalledProcessError(1, command)
self.owner.systemctl = externally_changed
with self.assertRaises(BluetoothUnavailable):
self.owner.request('power', enabled=True)
self.assertEqual(self.preference.path.read_bytes(), b'1\n')
def test_power_off_failure_restores_on_without_disabling_bluez(self):
self.preference.path.write_bytes(b'1\n')
FakeBlueZ.powered = True
self.owner.systemctl = Mock(side_effect=subprocess.CalledProcessError(1, 'systemctl'))
with self.assertRaises(BluetoothUnavailable):
self.owner.request('power', enabled=False)
self.assertEqual(self.preference.path.read_bytes(), b'1\n')
self.assertTrue(FakeBlueZ.powered)
def test_scan_retains_sender_until_stop_expiry_or_owner_close(self):
now = [10.0]
self.owner.clock = lambda: now[0]
self.owner.request('scan')
self.assertEqual(FakeBlueZ.closed, 0)
self.assertEqual(FakeBlueZ.operations, [('scan', None)])
timer = self.owner.scan_timer
self.assertTrue(timer.daemon)
now[0] = 29.0
self.owner.snapshot()
self.assertEqual(FakeBlueZ.closed, 0)
now[0] = 30.0
timer.fire()
self.assertEqual(FakeBlueZ.operations[-1], ('stop_scan', None))
self.owner.request('scan')
old_timer = self.owner.scan_timer
self.owner.request('scan')
self.assertTrue(old_timer.cancelled)
old_timer.fire()
self.assertEqual(FakeBlueZ.operations[-1], ('scan', None))
self.owner.close()
self.assertEqual(FakeBlueZ.operations[-1], ('stop_scan', None))
self.assertEqual(FakeBlueZ.closed, 1)
self.owner.request('scan')
self.assertEqual(FakeBlueZ.closed, 1)
self.parked[0] = False
self.owner.snapshot()
self.assertEqual(FakeBlueZ.operations[-1], ('scan', None))
now[0] += self.owner.SCAN_SECONDS
self.owner.scan_timer.fire()
self.assertEqual(FakeBlueZ.operations[-1], ('stop_scan', None))
def test_power_bootstrap_rechecks_parked_before_bluez_action(self):
def revoke_during_start(command, **kwargs):
if command[1] == 'show':
return self.systemctl(command, **kwargs)
self.parked[0] = False
self.commands.append((command, kwargs))
self.owner.systemctl = revoke_during_start
with self.assertRaises(BluetoothRejected):
self.owner.request('power', enabled=True)
self.assertFalse(FakeBlueZ.powered)
self.assertFalse(self.preference.path.exists())
def test_close_does_not_block_ui_during_native_operation(self):
entered = threading.Event()
release = threading.Event()
original = FakeBlueZ.operation
def slow_operation(self, operation, address=None):
entered.set()
release.wait(2)
original(self, operation, address)
self.enterContext(patch.object(FakeBlueZ, 'operation', slow_operation))
errors = []
def work():
try:
self.owner.request('connect', address='AA:BB:CC:DD:EE:FF')
except Exception as error:
errors.append(error)
worker = threading.Thread(target=work)
worker.start()
self.assertTrue(entered.wait(1))
start = time.monotonic()
self.owner.close()
self.assertLess(time.monotonic() - start, 0.1)
release.set()
worker.join(timeout=2)
self.assertFalse(worker.is_alive())
self.assertFalse(errors)
self.assertEqual(FakeBlueZ.closed, 1)
def test_address_normalization(self):
self.assertEqual(normalized_address('aa:bb:cc:dd:ee:ff'), 'AA:BB:CC:DD:EE:FF')
def test_adapter_lease_serializes_separate_ui_and_galaxy_owners(self):
other = BluetoothOwner(lambda: self.parked[0], bluez_factory=FakeBlueZ, radio_helper=self.helper,
systemctl=self.owner.systemctl, timer_factory=FakeTimer, radio_preference=self.preference,
admission_lock=Path(self.temp.name) / 'adapter.lock')
self.addCleanup(other.close)
self.owner.request('scan')
with self.assertRaises(BluetoothRejected):
other.request('connect', address='AA:BB:CC:DD:EE:FF')
self.assertNotIn(('connect', 'AA:BB:CC:DD:EE:FF'), FakeBlueZ.operations)
self.owner.request('stop_scan')
self.assertTrue(other.request('connect', address='AA:BB:CC:DD:EE:FF')['devices'][0]['connected'])
def test_expired_session_rejected_before_pair_side_effect(self):
FakeBlueZ.devices.append({'address': '11:22:33:44:55:66', 'name': 'Nearby', 'paired': False,
'connected': False, 'trusted': False})
self.owner.session_valid = lambda _identity: False
with self.assertRaises(BluetoothRejected):
self.owner.request('pair', address='11:22:33:44:55:66', session=('closed-page',))
self.assertIsNone(self.owner.pairing)
self.assertFalse(FakeBlueZ.operations)
def test_selected_pairing_requires_same_session_prompt_and_observed_bond(self):
FakeBlueZ.devices.append({'address': '11:22:33:44:55:66', 'name': 'Nearby', 'paired': False,
'connected': False, 'trusted': False})
identity = ('session-a', b'generation')
started = self.owner.request('pair', address='11:22:33:44:55:66', session=identity)
self.assertEqual(started['pairing']['state'], 'pairing')
prompt = None
for _ in range(100):
prompt = self.owner.snapshot(session=identity)['pairing']['prompt']
if prompt is not None:
break
time.sleep(0.01)
assert prompt is not None
self.assertEqual(prompt['value'], '123456')
self.assertIsNone(self.owner.snapshot(session=('session-b', b'generation'))['pairing'])
with self.assertRaises(BluetoothRejected):
self.owner.request('pairing_response', session=('session-b', b'generation'), prompt_id=prompt['id'], accepted=True)
with self.assertRaises(BluetoothRejected):
self.owner.request('pairing_response', session=identity, prompt_id='0' * 32, accepted=True)
self.owner.request('pairing_response', session=identity, prompt_id=prompt['id'], accepted=True)
self.assertTrue(self.owner.pairing.done.wait(1))
result = self.owner.snapshot(session=identity)
self.assertEqual(result['pairing']['state'], 'paired')
self.assertTrue(next(device for device in result['devices'] if device['address'] == '11:22:33:44:55:66')['paired'])
def test_logout_cancels_only_owned_pair_and_new_session_can_pair(self):
FakeBlueZ.devices.append({'address': '11:22:33:44:55:66', 'name': 'Nearby', 'paired': False,
'connected': False, 'trusted': False})
identity = ('session-a', b'generation')
self.owner.request('pair', address='11:22:33:44:55:66', session=identity)
self.owner.cancel_session(('other', b'generation'))
self.assertEqual(self.owner.snapshot(session=identity)['pairing']['state'], 'pairing')
self.owner.cancel_session(identity)
self.assertTrue(self.owner.pairing.done.wait(1))
self.assertEqual(self.owner.snapshot(session=identity)['pairing']['state'], 'canceled')
self.assertFalse(FakeBlueZ.devices[-1]['paired'])
with self.owner.lock:
self.owner.request('pair', address='11:22:33:44:55:66', session=identity)
self.owner.session_valid = lambda _identity: False
self.owner.snapshot(session=identity)
self.assertTrue(self.owner.pairing.done.wait(1))
self.assertEqual(self.owner.pairing.state, 'canceled')
def test_radio_loss_cancels_pending_pair_even_without_followup_action(self):
FakeBlueZ.devices.append({'address': '11:22:33:44:55:66', 'name': 'Nearby', 'paired': False,
'connected': False, 'trusted': False})
identity = ('session-a', b'generation')
self.owner.request('pair', address='11:22:33:44:55:66', session=identity)
pairing = self.owner.pairing
self.helper.unlink()
self.assertEqual(self.owner.snapshot(session=identity)['errorCode'], 'radio_unavailable')
self.assertTrue(pairing.done.wait(1))
self.assertEqual(pairing.state, 'canceled')
self.assertFalse(FakeBlueZ.devices[-1]['paired'])
def test_session_revocation_cancels_pair_without_browser_poll(self):
FakeBlueZ.devices.append({'address': '11:22:33:44:55:66', 'name': 'Nearby', 'paired': False,
'connected': False, 'trusted': False})
valid = [True]
self.owner.session_valid = lambda _identity: valid[0]
identity = ('session-a', b'generation')
self.owner.request('pair', address='11:22:33:44:55:66', session=identity)
pairing = self.owner.pairing
valid[0] = False
self.assertTrue(pairing.done.wait(1))
self.assertEqual(pairing.state, 'canceled')
self.assertFalse(FakeBlueZ.devices[-1]['paired'])
def test_pairing_deadline_expires_without_browser_poll(self):
FakeBlueZ.devices.append({'address': '11:22:33:44:55:66', 'name': 'Nearby', 'paired': False,
'connected': False, 'trusted': False})
now = [10.0]
self.owner.clock = lambda: now[0]
identity = ('session-a', b'generation')
self.owner.request('pair', address='11:22:33:44:55:66', session=identity)
pairing = self.owner.pairing
now[0] = 71.0
self.assertTrue(pairing.done.wait(1))
self.assertEqual(pairing.state, 'canceled')
self.assertFalse(FakeBlueZ.devices[-1]['paired'])
def test_pairing_prompts_are_one_use_nonoverlapping_and_deadline_bound(self):
now = [10.0]
path = '/org/bluez/hci0/dev_11_22_33_44_55_66'
session = PairingSession(('session', b'generation'), '11:22:33:44:55:66', path, lambda: True, lambda: now[0])
answers = []
worker = threading.Thread(target=lambda: answers.append(session.ask('confirmation', path, '123456')))
worker.start()
for _ in range(100):
if session.prompt is not None:
break
time.sleep(0.01)
assert session.prompt is not None
prompt = session.prompt
if prompt is None:
self.fail('pairing prompt was not created')
prompt_id = prompt['id']
self.assertEqual(session.ask('authorization', path), (False, ''))
self.assertFalse(session.display('display_passkey', path, '654321'))
prompt = session.prompt
if prompt is None:
self.fail('overlapping prompt replaced the original')
self.assertEqual(prompt['id'], prompt_id)
self.assertTrue(session.respond(session.identity, prompt_id, True, ''))
self.assertFalse(session.respond(session.identity, prompt_id, True, ''))
worker.join(timeout=1)
self.assertEqual(answers, [(True, '')])
session.prompt = {'id': 'a' * 32, 'kind': 'confirmation', 'value': '', 'displayOnly': False}
now[0] = session.deadline
self.assertFalse(session.respond(session.identity, 'a' * 32, True, ''))
self.assertFalse(session.display('display_passkey', path, '654321'))
session.finish(True)
self.assertEqual(session.state, 'canceled')
def test_agent_rejects_spoofed_sender_and_malformed_body(self):
path = '/org/bluez/hci0/dev_11_22_33_44_55_66'
responses = []
incoming = Queue()
class Router:
@contextmanager
def filter(self, *_args, **_kwargs):
yield incoming
def send(self, message):
responses.append(message)
bluez = BlueZ.__new__(BlueZ)
self.enterContext(patch.object(bluez, 'router', Router(), create=True))
self.enterContext(patch.object(bluez, '_device_path', lambda _address: path))
self.enterContext(patch.object(bluez, '_bluez_sender', lambda: ':1.7'))
self.enterContext(patch.object(bluez, '_objects', lambda: None))
self.enterContext(patch.object(bluez, '_devices', lambda _objects: []))
def callback(sender, signature, body):
message = new_method_call(DBusAddress(AGENT_PATH, bus_name=':1.9', interface=AGENT),
'RequestConfirmation', signature, body)
message.header.fields[HeaderFields.sender] = sender
return message
def call(_path, _interface, member, *_args, **_kwargs):
if member == 'Pair':
incoming.put(callback(':1.8', 'ou', (path, 123456)))
incoming.put(callback(':1.7', 'o', (path,)))
for _ in range(100):
if len(responses) == 2:
break
time.sleep(0.01)
return ()
self.enterContext(patch.object(bluez, '_call', call))
session = PairingSession(('session', b'generation'), '11:22:33:44:55:66', path, lambda: True, time.monotonic)
self.assertFalse(bluez.pair(session.address, path, session))
self.assertEqual([item.header.message_type for item in responses], [MessageType.error, MessageType.error])
self.assertIsNone(session.prompt)
def test_agent_teardown_wakes_pending_prompt_without_answer(self):
path = '/org/bluez/hci0/dev_11_22_33_44_55_66'
session = PairingSession(('session', b'generation'), '11:22:33:44:55:66', path, lambda: True, time.monotonic)
answers = []
worker = threading.Thread(target=lambda: answers.append(session.ask('pin', path)))
worker.start()
for _ in range(100):
if session.prompt is not None:
break
time.sleep(0.01)
assert session.prompt is not None
prompt = session.prompt
if prompt is None:
self.fail('pairing prompt was not created')
prompt_id = prompt['id']
session.close_callbacks()
worker.join(timeout=1)
self.assertFalse(worker.is_alive())
self.assertEqual(answers, [(False, '')])
self.assertFalse(session.respond(session.identity, prompt_id, True, '1234'))
def test_pair_deadline_after_registration_prevents_device_pair_call(self):
path = '/org/bluez/hci0/dev_11_22_33_44_55_66'
incoming = Queue()
calls = []
now = [10.0]
class Router:
@contextmanager
def filter(self, *_args, **_kwargs):
yield incoming
bluez = BlueZ.__new__(BlueZ)
self.enterContext(patch.object(bluez, 'router', Router(), create=True))
self.enterContext(patch.object(bluez, '_device_path', lambda _address: path))
self.enterContext(patch.object(bluez, '_bluez_sender', lambda: ':1.7'))
def call(_path, _interface, member, *_args, **_kwargs):
calls.append(member)
if member == 'RegisterAgent':
now[0] = 71.0
return ()
self.enterContext(patch.object(bluez, '_call', call))
session = PairingSession(('session', b'generation'), '11:22:33:44:55:66', path, lambda: True, lambda: now[0])
self.assertFalse(bluez.pair(session.address, path, session))
self.assertEqual(calls, ['RegisterAgent', 'UnregisterAgent'])
def test_agent_release_during_successful_unregister_does_not_cancel_pair(self):
path = '/org/bluez/hci0/dev_11_22_33_44_55_66'
incoming = Queue()
replies = []
calls = []
class Router:
@contextmanager
def filter(self, *_args, **_kwargs):
yield incoming
def send(self, message):
replies.append(message)
bluez = BlueZ.__new__(BlueZ)
self.enterContext(patch.object(bluez, 'router', Router(), create=True))
self.enterContext(patch.object(bluez, '_device_path', lambda _address: path))
self.enterContext(patch.object(bluez, '_bluez_sender', lambda: ':1.7'))
self.enterContext(patch.object(bluez, '_objects', lambda: None))
self.enterContext(patch.object(bluez, '_devices', lambda _objects: [{'address': '11:22:33:44:55:66', 'paired': True}]))
def call(_path, _interface, member, *_args, **_kwargs):
calls.append((_path, _interface, member, _args))
if member == 'UnregisterAgent':
message = new_method_call(DBusAddress(AGENT_PATH, bus_name=':1.9', interface=AGENT), 'Release')
message.header.fields[HeaderFields.sender] = ':1.7'
incoming.put(message)
for _ in range(100):
if replies:
break
time.sleep(0.01)
return ()
self.enterContext(patch.object(bluez, '_call', call))
session = PairingSession(('session', b'generation'), '11:22:33:44:55:66', path, lambda: True, time.monotonic)
self.assertTrue(bluez.pair(session.address, path, session))
self.assertIn((path, 'org.freedesktop.DBus.Properties', 'Set', ('ssv', ('org.bluez.Device1', 'Trusted', ('b', True)))), calls)
self.assertEqual([item.header.message_type for item in replies], [MessageType.method_return])
session.finish(True)
self.assertEqual(session.state, 'paired')
def test_bluez_uses_object_paths_from_observed_adapter_and_device(self):
bluez = BlueZ.__new__(BlueZ)
calls = []
objects = {
'/org/bluez/hci0': {'org.bluez.Adapter1': {'Powered': ('b', True), 'Discovering': ('b', False)}},
'/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF': {'org.bluez.Device1': {
'Address': ('s', 'AA:BB:CC:DD:EE:FF'), 'Alias': ('s', 'Speaker'),
'Paired': ('b', True), 'Connected': ('b', False), 'Trusted': ('b', True),
}},
}
def call(path, interface, member, signature=None, body=()):
calls.append((path, interface, member, signature, body))
return (objects,) if member == 'GetManagedObjects' else ()
self.enterContext(patch.object(bluez, '_call', call))
self.assertEqual(bluez.snapshot()['devices'][0]['name'], 'Speaker')
self.assertTrue(bluez.snapshot()['powered'])
bluez.operation('connect', 'AA:BB:CC:DD:EE:FF')
self.assertIn(('/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF', 'org.bluez.Device1', 'Connect', None, ()), calls)
bluez.operation('forget', 'AA:BB:CC:DD:EE:FF')
self.assertIn(('/org/bluez/hci0', 'org.bluez.Adapter1', 'RemoveDevice', 'o',
('/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF',)), calls)
def test_each_bluez_close_releases_router_and_underlying_connection(self):
closed = []
class Router:
def __init__(self, index):
self.index = index
self.conn = self
def close(self):
closed.append(('router', self.index))
class Connection(Router):
def close(self):
closed.append(('connection', self.index))
for index in range(3):
client = BlueZ.__new__(BlueZ)
router = Router(index)
router.conn = Connection(index)
self.enterContext(patch.object(client, 'router', router, create=True))
client.close()
self.assertEqual(closed, [item for index in range(3) for item in (('router', index), ('connection', index))])
failed = BlueZ.__new__(BlueZ)
router = Router(3)
router.conn = Connection(3)
self.enterContext(patch.object(failed, 'router', router, create=True))
self.enterContext(patch.object(router, 'close', lambda: (_ for _ in ()).throw(OSError('receiver failed'))))
with self.assertRaises(OSError):
failed.close()
self.assertEqual(closed[-1], ('connection', 3))
if __name__ == '__main__':
unittest.main()
class BluetoothDiscoveryNamesTest(unittest.TestCase):
def test_anonymous_discovery_is_hidden_but_saved_device_can_be_removed(self):
objects = {f'/org/bluez/hci0/dev_{i}': {'org.bluez.Device1': props} for i, props in enumerate((
{'Address': '11:22:33:44:55:01', 'Alias': '11-22-33-44-55-01'},
{'Address': '11:22:33:44:55:02', 'Name': ' '},
{'Address': '11:22:33:44:55:03', 'Name': 'Controller', 'Alias': '11-22-33-44-55-03'},
{'Address': '11:22:33:44:55:04', 'Alias': 'My headset'},
{'Address': '11:22:33:44:55:05', 'Paired': True},
))}
found = BlueZ._devices(objects)
self.assertEqual([d['name'] for d in found], ['Controller', 'My headset', '11:22:33:44:55:05'])
self.assertTrue(found[-1]['paired'])
class BluetoothHeadUnitDiscoveryTest(unittest.TestCase):
def test_unnamed_pairable_profiles_and_device_class_are_visible(self):
props = [
{'UUIDs': ['0000111e-0000-1000-8000-00805f9b34fb']},
{'Class': 0x200404},
{'UUIDs': ['4de17a00-52cb-11e6-bdf4-0800200c9a66']},
{'UUIDs': ['00001812-0000-1000-8000-00805f9b34fb']},
{'LegacyPairing': True},
{'RSSI': -40},
{'Class': 0x200404, 'Blocked': True},
]
objects = {f'/org/bluez/hci0/dev_{i}': {'org.bluez.Device1': dict(p, Address=f'11:22:33:44:55:{i:02X}')}
for i, p in enumerate(props)}
found = BlueZ._devices(objects)
self.assertEqual({d['address'] for d in found}, {f'11:22:33:44:55:{i:02X}' for i in range(5)})
self.assertTrue(all(d['name'] for d in found))
self.assertTrue(all(not d['paired'] for d in found))