Files
StarPilot/openpilot/starpilot/bluetooth/owner.py
T

766 lines
32 KiB
Python

from __future__ import annotations
from collections.abc import Callable
from queue import Full
import fcntl
import os
import re
import subprocess
import threading
import time
import uuid
from pathlib import Path
from typing import Any
from jeepney import DBusAddress, MatchRule, new_error, new_method_call, new_method_return
from jeepney.io.threading import DBusRouter, open_dbus_connection
from jeepney.low_level import HeaderFields, MessageType
from jeepney.wrappers import Properties
from openpilot.starpilot.bluetooth.radio_preference import RadioPreference
RADIO_HELPER = Path('/usr/comma/bluetooth-radio')
RADIO_UNIT = 'starpilot-bluetooth-radio.service'
ADDRESS = re.compile(r'(?:[0-9A-F]{2}:){5}[0-9A-F]{2}\Z')
BLUEZ = 'org.bluez'
ADAPTER = 'org.bluez.Adapter1'
DEVICE = 'org.bluez.Device1'
AGENT = 'org.bluez.Agent1'
AGENT_PATH = '/org/starpilot/bluetooth/pairing'
ADMISSION_LOCK = Path('/data/starpilot/bluetooth-owner.lock')
class PairingSession:
SECONDS = 60.0
def __init__(self, identity: tuple, address: str, path: str, parked: Callable[[], bool], clock: Callable[[], float]):
self.identity, self.address, self.path = identity, address, path
self.parked, self.clock = parked, clock
self.deadline = clock() + self.SECONDS
self.condition = threading.Condition()
self.prompt: dict | None = None
self.response: tuple[bool, str] | None = None
self.state = 'pairing'
self.callbacks_closed = False
self.cancel_callback: Callable[[], None] | None = None
self.done = threading.Event()
def cancel(self) -> None:
with self.condition:
if self.state != 'pairing':
return
self.state = 'canceled'
self.prompt = None
self.condition.notify_all()
callback = self.cancel_callback
if callback is not None:
threading.Thread(target=callback, daemon=True).start()
def finish(self, paired: bool) -> None:
with self.condition:
if self.state == 'pairing':
if not self.parked() or self.clock() >= self.deadline:
self.state = 'canceled'
else:
self.state = 'paired' if paired else 'failed'
self.prompt = None
self.condition.notify_all()
self.done.set()
def close_callbacks(self) -> None:
with self.condition:
self.callbacks_closed = True
self.prompt = None
self.condition.notify_all()
def ask(self, kind: str, path: str, value: str = '') -> tuple[bool, str]:
with self.condition:
if (self.state != 'pairing' or self.callbacks_closed or path != self.path or
(self.prompt is not None and not self.prompt['displayOnly']) or
not self.parked() or self.clock() >= self.deadline):
return False, ''
prompt_id = uuid.uuid4().hex
self.prompt = {'id': prompt_id, 'kind': kind, 'value': value[:16], 'displayOnly': False}
self.response = None
while self.response is None and self.state == 'pairing' and not self.callbacks_closed and self.parked():
remaining = self.deadline - self.clock()
if remaining <= 0:
break
self.condition.wait(min(remaining, 0.25))
result = (self.response if self.response is not None and self.state == 'pairing' and not self.callbacks_closed and
self.parked() and self.clock() < self.deadline else (False, ''))
self.response = None
self.prompt = None
return result
def display(self, kind: str, path: str, value: str) -> bool:
with self.condition:
if (self.state != 'pairing' or self.callbacks_closed or path != self.path or
(self.prompt is not None and not self.prompt['displayOnly']) or
not self.parked() or self.clock() >= self.deadline):
return False
self.prompt = {'id': uuid.uuid4().hex, 'kind': kind, 'value': value[:16], 'displayOnly': True}
return True
def respond(self, identity: tuple, prompt_id: str, accepted: bool, value: str) -> bool:
with self.condition:
prompt = self.prompt
if (self.state != 'pairing' or self.callbacks_closed or identity != self.identity or not self.parked() or
self.clock() >= self.deadline or prompt is None or prompt['id'] != prompt_id or
prompt['displayOnly'] or self.response is not None):
return False
kind = prompt['kind']
if accepted and ((kind == 'passkey' and (not re.fullmatch(r'[0-9]{1,6}', value) or int(value) > 999999)) or
(kind == 'pin' and (not 1 <= len(value) <= 16 or not value.isascii() or not value.isprintable())) or
(kind not in ('pin', 'passkey') and value)):
return False
self.response = accepted, value if accepted else ''
self.condition.notify_all()
return True
class BluetoothUnavailable(Exception):
def __init__(self, message: str, *, code: str = 'service_unavailable'):
super().__init__(message)
self.code = code
class BluetoothRejected(Exception):
def __init__(self, message: str, *, code: str = 'changed'):
super().__init__(message)
self.code = code
def normalized_address(value: str) -> str:
if not isinstance(value, str) or not ADDRESS.fullmatch(value.upper()):
raise ValueError('Invalid Bluetooth address')
return value.upper()
def _unwrap(value: Any) -> Any:
if isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], str):
return _unwrap(value[1])
if isinstance(value, dict):
return {key: _unwrap(item) for key, item in value.items()}
if isinstance(value, list):
return [_unwrap(item) for item in value]
return value
class BlueZ:
def __init__(self):
self.router = DBusRouter(open_dbus_connection(bus='SYSTEM'))
def close(self) -> None:
try:
self.router.close()
finally:
self.router.conn.close()
def _call(self, path: str, interface: str, member: str, signature: str | None = None, body: tuple = (), timeout: float = 10.0):
address = DBusAddress(path, bus_name=BLUEZ, interface=interface)
message = new_method_call(address, member, signature, body) if signature else new_method_call(address, member)
reply = self.router.send_and_get_reply(message, timeout=timeout)
if reply.header.message_type == MessageType.error:
error_name = str(reply.header.fields.get(HeaderFields.error_name, 'Bluetooth operation failed'))
if error_name.startswith('org.freedesktop.DBus.Error.'):
raise BluetoothUnavailable('Bluetooth service could not be reached')
raise BluetoothRejected(error_name)
return reply.body
def _objects(self) -> dict:
return _unwrap(self._call('/', 'org.freedesktop.DBus.ObjectManager', 'GetManagedObjects')[0])
@staticmethod
def _adapter(objects: dict) -> tuple[str, dict]:
return next(((path, interfaces[ADAPTER]) for path, interfaces in objects.items() if ADAPTER in interfaces), ('', {}))
@staticmethod
def _devices(objects: dict) -> list[dict]:
devices = []
for path, interfaces in objects.items():
if DEVICE not in interfaces:
continue
props = interfaces[DEVICE]
try:
address = normalized_address(props.get('Address', ''))
except ValueError:
continue
def named(value):
text = str(value or '').strip()
return text if text and not ADDRESS.fullmatch(text.replace('-', ':').upper()) else ''
name = named(props.get('Name')) or named(props.get('Alias'))
known = props.get('Paired') is True or props.get('Connected') is True
profiles = {str(value).lower() for value in props.get('UUIDs', ())}
audio_profiles = {f"0000{service}-0000-1000-8000-00805f9b34fb" for service in
('1108', '110a', '110b', '110c', '110e', '110f', '1112', '111e', '111f', '1124', '1812')}
relevant = bool(profiles & audio_profiles or '4de17a00-52cb-11e6-bdf4-0800200c9a66' in profiles)
device_class = props.get('Class', 0)
major_class = (device_class >> 8) & 0x1f if type(device_class) is int else 0
relevant = relevant or major_class in (2, 4, 5) or props.get('LegacyPairing') is True
if not known and (props.get('Blocked') is True or not name and not relevant):
continue
name = name or (f"Audio Device · {address[-5:]}" if major_class == 4 else
f"Bluetooth Device · {address[-5:]}" if relevant else address)
devices.append({'path': path, 'address': address, 'name': str(name)[:80],
'paired': props.get('Paired') is True, 'connected': props.get('Connected') is True,
'trusted': props.get('Trusted') is True})
return devices
def snapshot(self) -> dict:
objects = self._objects()
_, adapter = self._adapter(objects)
devices = sorted(self._devices(objects), key=lambda item: (not item['connected'], not item['paired'], item['name']))
return {'adapter': bool(adapter), 'powered': adapter.get('Powered') is True,
'discovering': adapter.get('Discovering') is True,
'devices': [{key: value for key, value in item.items() if key != 'path'} for item in devices[:64]]}
def _device_path(self, address: str) -> str:
for item in self._devices(self._objects()):
if item['address'] == address:
return item['path']
raise BluetoothRejected('Device is no longer available')
def operation(self, operation: str, address: str | None = None) -> None:
objects = self._objects()
adapter_path, _ = self._adapter(objects)
if not adapter_path:
raise BluetoothUnavailable('Bluetooth adapter is unavailable', code='adapter_unavailable')
if operation in ('scan', 'stop_scan'):
self._call(adapter_path, ADAPTER, 'StartDiscovery' if operation == 'scan' else 'StopDiscovery')
return
if address is None:
raise ValueError('Bluetooth address required')
path = self._device_path(address)
if operation == 'forget':
self._call(adapter_path, ADAPTER, 'RemoveDevice', 'o', (path,))
elif operation in ('connect', 'disconnect'):
self._call(path, DEVICE, operation.capitalize())
else:
raise ValueError('Unsupported Bluetooth operation')
def set_powered(self, enabled: bool) -> None:
path, _ = self._adapter(self._objects())
if not path:
raise BluetoothUnavailable('Bluetooth adapter is unavailable', code='adapter_unavailable')
address = DBusAddress(path, bus_name=BLUEZ, interface=ADAPTER)
reply = self.router.send_and_get_reply(Properties(address).set('Powered', 'b', enabled), timeout=10.0)
if reply.header.message_type == MessageType.error:
raise BluetoothRejected('Bluetooth power change failed')
def cancel_pair(self, path: str) -> None:
try:
self._call(path, DEVICE, 'CancelPairing', timeout=5.0)
except (BluetoothRejected, OSError, RuntimeError):
pass
def _bluez_sender(self) -> str:
address = DBusAddress('/org/freedesktop/DBus', bus_name='org.freedesktop.DBus', interface='org.freedesktop.DBus')
message = new_method_call(address, 'GetNameOwner', 's', (BLUEZ,))
reply = self.router.send_and_get_reply(message, timeout=5.0)
if reply.header.message_type == MessageType.error or len(reply.body) != 1 or not isinstance(reply.body[0], str) or \
not reply.body[0].startswith(':'):
raise BluetoothUnavailable('BlueZ identity is unavailable')
return reply.body[0]
def pair(self, address: str, expected_path: str, session: PairingSession) -> bool:
path = self._device_path(address)
if path != expected_path:
raise BluetoothRejected('Device changed before pairing')
bluez_sender = self._bluez_sender()
agent_filter = self.router.filter(MatchRule(type='method_call', interface=AGENT, path=AGENT_PATH), bufsize=16)
queue = agent_filter.__enter__()
finished = threading.Event()
pair_succeeded = threading.Event()
slots = threading.BoundedSemaphore(4)
handlers: set[threading.Thread] = set()
handlers_lock = threading.Lock()
def reply(message, *, accepted: bool, signature: str | None = None, body: tuple = ()):
result = new_method_return(message, signature, body) if accepted else new_error(message, 'org.bluez.Error.Rejected', 's', ('Pairing rejected',))
self.router.send(result)
def handle(message):
member = message.header.fields.get(HeaderFields.member, '')
body = message.body
try:
if message.header.fields.get(HeaderFields.sender) != bluez_sender:
reply(message, accepted=False)
return
if member in ('Release', 'Cancel'):
if body:
reply(message, accepted=False)
return
if member == 'Cancel' or not pair_succeeded.is_set():
session.cancel()
reply(message, accepted=True)
return
if not body or type(body[0]) is not str or body[0] != path:
reply(message, accepted=False)
return
device_path = body[0]
if member == 'RequestPinCode':
if len(body) != 1:
reply(message, accepted=False)
return
allowed, value = session.ask('pin', device_path)
reply(message, accepted=allowed, signature='s', body=(value,))
elif member == 'RequestPasskey':
if len(body) != 1:
reply(message, accepted=False)
return
allowed, value = session.ask('passkey', device_path)
reply(message, accepted=allowed, signature='u', body=(int(value),) if allowed else ())
elif member == 'RequestConfirmation':
if len(body) != 2 or type(body[1]) is not int or not 0 <= body[1] <= 999999:
reply(message, accepted=False)
return
allowed, _ = session.ask('confirmation', device_path, f'{body[1]:06d}')
reply(message, accepted=allowed)
elif member == 'RequestAuthorization':
if len(body) != 1:
reply(message, accepted=False)
return
allowed, _ = session.ask('authorization', device_path)
reply(message, accepted=allowed)
elif member == 'AuthorizeService':
if len(body) != 2 or type(body[1]) is not str or len(body[1]) > 64:
reply(message, accepted=False)
return
allowed, _ = session.ask('authorization', device_path)
reply(message, accepted=allowed)
elif member == 'DisplayPinCode':
if (len(body) != 2 or type(body[1]) is not str or not 1 <= len(body[1]) <= 16 or
not body[1].isascii() or not body[1].isprintable()):
reply(message, accepted=False)
return
reply(message, accepted=session.display('display_pin', device_path, body[1]))
elif member == 'DisplayPasskey':
if (len(body) != 3 or type(body[1]) is not int or not 0 <= body[1] <= 999999 or
type(body[2]) is not int or not 0 <= body[2] <= 6):
reply(message, accepted=False)
return
reply(message, accepted=session.display('display_passkey', device_path, f'{body[1]:06d}'))
else:
reply(message, accepted=False)
except (OSError, RuntimeError, ValueError, TypeError, IndexError):
session.cancel()
try:
reply(message, accepted=False)
except (OSError, RuntimeError):
pass
def agent_loop():
while not finished.is_set():
message = queue.get()
if message is None or finished.is_set():
return
if not slots.acquire(blocking=False):
try:
reply(message, accepted=False)
except (OSError, RuntimeError):
pass
continue
def bounded_handle(message=message):
try:
handle(message)
finally:
with handlers_lock:
handlers.discard(threading.current_thread())
slots.release()
handler = threading.Thread(target=bounded_handle, daemon=True)
with handlers_lock:
handlers.add(handler)
handler.start()
worker = threading.Thread(target=agent_loop, daemon=True)
worker.start()
registered = False
try:
self._call('/org/bluez', 'org.bluez.AgentManager1', 'RegisterAgent', 'os', (AGENT_PATH, 'KeyboardDisplay'))
registered = True
if session.state != 'pairing' or session.callbacks_closed or not session.parked() or session.clock() >= session.deadline:
return False
self._call(path, DEVICE, 'Pair', timeout=70.0)
paired = next((item['paired'] for item in self._devices(self._objects()) if item['address'] == address), False)
if paired:
if session.state != 'pairing' or not session.parked() or session.clock() >= session.deadline:
return False
self._call(path, 'org.freedesktop.DBus.Properties', 'Set', 'ssv', (DEVICE, 'Trusted', ('b', True)))
pair_succeeded.set()
return paired
finally:
session.close_callbacks()
try:
if registered:
try:
self._call('/org/bluez', 'org.bluez.AgentManager1', 'UnregisterAgent', 'o', (AGENT_PATH,))
except (BluetoothRejected, OSError, RuntimeError):
pass
finally:
finished.set()
try:
queue.put_nowait(None)
except Full:
pass
agent_filter.__exit__(None, None, None)
worker.join(timeout=1.0)
with handlers_lock:
pending = tuple(handlers)
for handler in pending:
handler.join(timeout=1.0)
class BluetoothOwner:
OPERATIONS = frozenset(('power', 'scan', 'stop_scan', 'connect', 'disconnect', 'forget', 'pair', 'pairing_response', 'cancel_pair'))
SCAN_SECONDS = 20.0
def __init__(self, parked: Callable[[], bool], *, bluez_factory=BlueZ, radio_helper: Path = RADIO_HELPER,
systemctl: Callable[..., Any] = subprocess.run, clock: Callable[[], float] = time.monotonic,
timer_factory: Callable[..., Any] = threading.Timer,
session_valid: Callable[[tuple], bool] = lambda _identity: True,
admission_lock: Path = ADMISSION_LOCK, radio_preference: RadioPreference | None = None):
self.parked = parked
self.bluez_factory = bluez_factory
self.radio_helper = radio_helper
self.radio_preference = radio_preference if radio_preference is not None else RadioPreference()
self.systemctl = systemctl
self.clock = clock
self.timer_factory = timer_factory
self.session_valid = session_valid
self.lock = threading.RLock()
self.client: BlueZ | None = None
self.scan_deadline = 0.0
self.scan_timer: Any | None = None
self.scan_generation = 0
self.close_pending = False
self.pairing: PairingSession | None = None
self.admission_lock = admission_lock
self.admission_file = None
self.power_change_active = False
def _admission_enter(self) -> None:
if self.admission_file is not None:
return
self.admission_lock.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(self.admission_lock, os.O_CREAT | os.O_RDWR | getattr(os, 'O_NOFOLLOW', 0), 0o600)
stream = os.fdopen(fd, 'r+')
try:
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
stream.close()
raise BluetoothRejected('Bluetooth adapter is busy in another process', code='busy') from None
self.admission_file = stream
def _admission_busy(self) -> bool:
return bool(self.scan_deadline or (self.pairing is not None and not self.pairing.done.is_set()))
def _admission_leave(self) -> None:
if not self.power_change_active and not self._admission_busy() and self.admission_file is not None:
self.admission_file.close()
self.admission_file = None
def _client(self) -> BlueZ:
if self.client is None:
self.client = self.bluez_factory()
return self.client
def _stop_scan(self) -> None:
self.scan_generation += 1
if self.scan_timer is not None:
self.scan_timer.cancel()
self.scan_timer = None
if self.scan_deadline:
try:
self._client().operation('stop_scan')
finally:
self.scan_deadline = 0.0
def _expire_scan(self, generation: int) -> None:
with self.lock:
if generation != self.scan_generation or not self.scan_deadline:
return
if self.clock() < self.scan_deadline:
remaining = self.scan_deadline - self.clock()
timer = self.timer_factory(remaining, self._expire_scan, args=(generation,))
timer.daemon = True
self.scan_timer = timer
timer.start()
return
try:
self._stop_scan()
self._admission_leave()
except Exception:
self.close()
def _start_scan_lease(self) -> None:
self.scan_generation += 1
if self.scan_timer is not None:
self.scan_timer.cancel()
self.scan_deadline = self.clock() + self.SCAN_SECONDS
timer = self.timer_factory(self.SCAN_SECONDS, self._expire_scan, args=(self.scan_generation,))
timer.daemon = True
self.scan_timer = timer
timer.start()
def _close_locked(self) -> None:
self.close_pending = False
if self.pairing is not None:
self.pairing.cancel()
try:
self._stop_scan()
except Exception:
pass
if self.client is not None:
try:
self.client.close()
except Exception:
pass
finally:
self.client = None
self._admission_leave()
def close(self) -> None:
if not self.lock.acquire(blocking=False):
self.close_pending = True
return
try:
self._close_locked()
finally:
self.lock.release()
def snapshot(self, *, session: tuple | None = None) -> dict:
parked = self.parked()
result = {'version': 1, 'available': self.radio_helper.is_file(), 'parked': parked,
'powered': False, 'discovering': False, 'devices': [], 'errorCode': None, 'pairing': None}
if not result['available']:
with self.lock:
self._close_locked()
result['errorCode'] = 'radio_unavailable'
return result
with self.lock:
try:
if self.pairing is not None and not self.session_valid(self.pairing.identity):
self.pairing.cancel()
if self.scan_deadline and (self.clock() >= self.scan_deadline):
self._stop_scan()
self._admission_leave()
result.update(self._client().snapshot())
if not result.get('adapter'):
result['errorCode'] = 'adapter_unavailable'
except Exception:
try:
result['errorCode'] = 'service_unavailable' if self.radio_preference.enabled() else None
except OSError:
result['errorCode'] = 'radio_preference_unavailable'
self._close_locked()
finally:
if self.close_pending:
self._close_locked()
pairing = self.pairing
if pairing is not None and session == pairing.identity:
with pairing.condition:
result['pairing'] = {'address': pairing.address, 'state': pairing.state,
'prompt': dict(pairing.prompt) if pairing.prompt is not None else None}
else:
result['pairing'] = None
return result
def _pair_worker(self, pairing: PairingSession) -> None:
client = None
paired = False
try:
client = self.bluez_factory()
with pairing.condition:
pairing.cancel_callback = lambda: client.cancel_pair(pairing.path)
canceled = pairing.state != 'pairing'
if not canceled:
paired = client.pair(pairing.address, pairing.path, pairing)
except (BluetoothRejected, BluetoothUnavailable, OSError, RuntimeError, ValueError):
pass
finally:
if client is not None:
try:
client.close()
except (OSError, RuntimeError):
pass
pairing.finish(paired and pairing.parked())
def _watch_pair(self, pairing: PairingSession) -> None:
while not pairing.done.wait(0.25):
if not pairing.parked() or self.clock() >= pairing.deadline:
pairing.cancel()
with self.lock:
self._admission_leave()
def cancel_session(self, identity: tuple) -> None:
with self.lock:
if self.pairing is not None and self.pairing.identity == identity:
self.pairing.cancel()
@staticmethod
def _forget_android_auto_car(address: str) -> None:
"""A deleted pairing also stops being Android Auto's car or companion, so it leaves the car pickers.
Runs in the background: clearing may first stop a running projection (whose daemon may be the
caller), and a failure here must not undo or delay the forget itself.
"""
def worker():
try:
from openpilot.starpilot.system.android_auto.protocol import forget_car
forget_car(address)
except Exception as error:
try:
from openpilot.common.swaglog import cloudlog
cloudlog.warning(f"Android Auto car clear failed for {address}: {error}")
except Exception:
pass
threading.Thread(target=worker, name='android_auto_forget_car', daemon=True).start()
def request(self, operation: str, *, address: str | None = None, enabled: bool | None = None,
session: tuple | None = None, prompt_id: str | None = None, accepted: bool | None = None,
value: str = '', power_on_for_android_auto: bool = False) -> dict:
# Only the confirmed Android Auto enable flow may power the radio on in either road state.
if type(power_on_for_android_auto) is not bool or power_on_for_android_auto and \
(operation != 'power' or enabled is not True or session is None):
raise ValueError('Android Auto Bluetooth startup requires an authenticated power-on request')
if operation not in self.OPERATIONS:
raise ValueError('Unsupported Bluetooth operation')
if operation == 'power':
if type(enabled) is not bool or address is not None or prompt_id is not None or accepted is not None or value:
raise ValueError('Invalid power request')
elif operation == 'pairing_response':
if (session is None or address is not None or enabled is not None or type(accepted) is not bool or
not isinstance(prompt_id, str) or not re.fullmatch('[0-9a-f]{32}', prompt_id) or
not isinstance(value, str) or len(value) > 16):
raise ValueError('Invalid pairing response')
elif enabled is not None or prompt_id is not None or accepted is not None or value or \
(address is None) != (operation in ('scan', 'stop_scan', 'cancel_pair')):
raise ValueError('Invalid Bluetooth request')
if operation in ('pair', 'cancel_pair') and session is None:
raise ValueError('Pairing requires a session')
if address is not None:
address = normalized_address(address)
def check_power_authority():
if operation != 'power':
return
if session is not None and not self.session_valid(session):
raise BluetoothRejected('Bluetooth session expired', code='session_expired')
if not power_on_for_android_auto and not self.parked():
raise BluetoothRejected('Bluetooth radio restart requires Park', code='park_required')
if not self.lock.acquire(blocking=False):
raise BluetoothRejected('Bluetooth is busy', code='busy')
radio_change = None
power_complete = False
radio_start_owned = False
radio_start_attempted = False
try:
check_power_authority()
if not self.radio_helper.is_file():
raise BluetoothUnavailable('Bluetooth radio is unavailable', code='radio_unavailable')
if session is not None and not self.session_valid(session):
raise BluetoothRejected('Pairing session expired', code='session_expired')
self._admission_enter()
check_power_authority()
if operation == 'pairing_response':
assert session is not None and prompt_id is not None and accepted is not None
if self.pairing is None or not self.pairing.respond(session, prompt_id, accepted, value):
raise BluetoothRejected('Pairing prompt changed')
return self.snapshot(session=session)
if operation == 'cancel_pair':
if self.pairing is None or self.pairing.identity != session or self.pairing.state != 'pairing':
raise BluetoothRejected('Pairing changed')
self.pairing.cancel()
return self.snapshot(session=session)
if self.pairing is not None and self.pairing.state == 'pairing' and operation != 'stop_scan':
raise BluetoothRejected('Pairing is in progress', code='busy')
if operation == 'power':
assert enabled is not None
self.power_change_active = True
if enabled:
state = self.systemctl(['systemctl', 'show', '--property=ActiveState', '--value', RADIO_UNIT],
check=True, capture_output=True, text=True, timeout=5).stdout.strip()
if state not in ('inactive', 'failed', 'active', 'activating', 'deactivating', 'reloading', 'maintenance'):
raise BluetoothUnavailable('Bluetooth service state is unavailable')
radio_start_owned = state in ('inactive', 'failed')
try:
radio_change = self.radio_preference.begin(enabled)
radio_change.apply()
except OSError as exc:
raise BluetoothUnavailable('Bluetooth power setting is unavailable', code='radio_preference_unavailable') from exc
check_power_authority()
if operation == 'power' and enabled:
radio_start_attempted = True
self.systemctl(['sudo', '-n', 'systemctl', 'start', RADIO_UNIT], check=True, timeout=30)
check_power_authority()
if operation == 'power':
assert enabled is not None
if not enabled:
self._stop_scan()
else:
self._client().set_powered(True)
elif operation == 'scan':
if self.scan_deadline == 0.0:
self._client().operation(operation, address)
self._start_scan_lease()
elif operation == 'stop_scan':
self._stop_scan()
elif operation == 'pair':
assert session is not None and address is not None
if self.pairing is not None and not self.pairing.done.is_set():
raise BluetoothRejected('Pairing is in progress', code='busy')
device = next((item for item in self._client()._devices(self._client()._objects()) if item['address'] == address), None)
if device is None or device['paired']:
raise BluetoothRejected('Device is unavailable for pairing')
self._stop_scan()
pairing = PairingSession(session, address, device['path'],
lambda: self.session_valid(session), self.clock)
self.pairing = pairing
threading.Thread(target=self._pair_worker, args=(pairing,), daemon=True).start()
threading.Thread(target=self._watch_pair, args=(pairing,), daemon=True).start()
else:
self._client().operation(operation, address)
if operation == 'forget':
assert address is not None
self._forget_android_auto_car(address)
if operation == 'power' and not enabled:
self.systemctl(['sudo', '-n', 'systemctl', 'stop', RADIO_UNIT], check=True, timeout=15)
self.close()
check_power_authority()
result = self.snapshot(session=session)
if radio_change is not None:
try:
radio_change.verify()
except OSError as exc:
raise BluetoothUnavailable('Bluetooth power setting changed', code='radio_preference_unavailable') from exc
if result['powered'] is not enabled:
raise BluetoothUnavailable('Bluetooth power change could not be verified')
power_complete = True
return result
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
self.close()
raise BluetoothUnavailable('Bluetooth service is unavailable') from exc
finally:
try:
if radio_change is not None and not power_complete:
try:
if radio_start_owned and radio_start_attempted and radio_change.current(radio_change.value, radio_change.owned_identity):
try:
self.systemctl(['sudo', '-n', 'systemctl', 'stop', RADIO_UNIT], check=True, timeout=15)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
raise BluetoothUnavailable('Bluetooth startup could not be stopped') from exc
finally:
try:
radio_change.rollback()
except OSError as exc:
raise BluetoothUnavailable('Bluetooth power setting could not be restored', code='radio_preference_unavailable') from exc
finally:
self.power_change_active = False
if self.close_pending:
self._close_locked()
self._admission_leave()
self.lock.release()