Files
Danny a531afd02a Bluetooth: Coordinate companion pairing and Android Auto ownership
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.
2026-10-22 15:37:20 -07:00

319 lines
15 KiB
Python

from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
import re
import time
import uuid
from openpilot.starpilot.galaxy_bluetooth.bootstrap import setup_status
from openpilot.starpilot.galaxy_bluetooth.phone_ui import forget_phone_keys, is_app_link, large_phone_rows
from openpilot.starpilot.bluetooth.owner import BluetoothRejected, BluetoothUnavailable
from openpilot.starpilot.bluetooth.native_owner import NativeBluetoothOwner as BluetoothOwner
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import DialogResult, Widget
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.list_view import button_item, text_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
def device_label(device: dict) -> str:
name = "".join(char for char in str(device.get("name") or "") if char.isprintable()).strip()
return (name or str(device.get("address") or "device"))[:80]
class BluetoothLarge(Widget):
REFRESH_SECONDS = 2.0
def __init__(self, connectivity_allowed: Callable[[], bool]):
super().__init__()
self.phone_pairing_allowed = connectivity_allowed
self.session = None
self.active = False
self.owner = BluetoothOwner(connectivity_allowed, session_valid=lambda identity: self.active and identity == self.session)
self.executor = None
self.pending = None
self.operation = ""
self.return_to_root = False
self.queued = None
self.pending_session = None
self.status = None
self.next_refresh = 0.0
self.pair_dialog = None
self.pair_dialog_id = None
self.last_prompt_id = None
self.scan_on_ready = False
self._signature = None
self._touch_held = False
self._rebuild()
def show_event(self):
super().show_event()
self.session = ('large', uuid.uuid4().hex)
self.active = True
self.scan_on_ready = True
gui_app.add_nav_stack_tick(self._tick)
self.next_refresh = 0.0
self._scroller.show_event()
self._tick()
def hide_event(self):
gui_app.remove_nav_stack_tick(self._tick)
self.active = False
self.session = None
if self.executor is not None:
self.pending = self.executor.submit(self.owner.close)
self.operation = "close"
self.pending_session = None
self.executor.shutdown(wait=False, cancel_futures=False)
self.executor = None
self.return_to_root = False
self.queued = None
self.status = None
self.scan_on_ready = False
self.last_prompt_id = None
self._retire_prompt()
self._signature = None
self._scroller.hide_event()
super().hide_event()
def _update_state(self):
for event in gui_app.mouse_events:
if event.slot == 0:
self._touch_held = event.left_down
self._rebuild()
def _render(self, rect):
self._scroller.render(rect)
def _ready(self) -> bool:
return bool(self.status and self.status.get("available") and self.queued is None and
not self._pairing_active() and
(self.pending is None or self.operation == "snapshot"))
def _pairing_active(self) -> bool:
return bool(((self.status or {}).get("pairing") or {}).get("state") == "pairing")
def _pairing_ready(self) -> bool:
return bool(self.active and self.session is not None and self._pairing_active() and
self.status and self.status.get("available") and self.queued is None and
(self.pending is None or self.operation == "snapshot"))
def _snapshot(self):
if self.pending is None:
if self.executor is None:
self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="big-bluetooth")
self.pending = self.executor.submit(self.owner.snapshot, session=getattr(self, "session", None))
self.operation = "snapshot"
self.pending_session = getattr(self, "session", None)
def _request(self, operation: str, *, address: str | None = None, enabled: bool | None = None,
return_to_root: bool = False, prompt_id: str | None = None,
accepted: bool | None = None, value: str = ""):
pairing_action = operation in ("pairing_response", "cancel_pair")
if not (self._pairing_ready() if pairing_action else self._ready()):
return
if operation == "power" and not self.status.get("parked"):
return
if pairing_action and operation == "pairing_response":
prompt = ((self.status or {}).get("pairing") or {}).get("prompt")
if prompt is None or prompt.get("id") != prompt_id:
return
session = getattr(self, "session", None) if operation in ("pair", "pairing_response", "cancel_pair") else None
request = (operation, address, enabled, return_to_root, session, prompt_id, accepted, value)
if self.pending is not None:
self.queued = request
self._rebuild()
return
self._submit_request(*request)
def _submit_request(self, operation: str, address: str | None, enabled: bool | None, return_to_root: bool,
session: tuple | None, prompt_id: str | None, accepted: bool | None, value: str):
if self.executor is None:
return
if operation in ("pair", "pairing_response", "cancel_pair"):
self.pending = self.executor.submit(self.owner.request, operation, address=address, session=session,
prompt_id=prompt_id, accepted=accepted, value=value)
elif operation == "power":
self.pending = self.executor.submit(self._power_request, bool(enabled))
elif operation == "auto_scan":
# Opening the page never starts discovery while Android Auto holds the adapter.
self.pending = self.executor.submit(self.owner.request, "scan", delegate=False)
else:
self.pending = self.executor.submit(self.owner.request, operation, address=address, enabled=enabled)
self.operation = operation
self.pending_address = address
self.pending_session = getattr(self, "session", None)
self.return_to_root = return_to_root
self._rebuild()
def _power_request(self, enabled: bool) -> dict:
return self.owner.request("power", address=None, enabled=enabled)
def _forget(self, address: str) -> None:
self._request("forget", address=address)
def _tick(self):
if self.pending is not None and self.pending.done():
future, operation = self.pending, self.operation
address = getattr(self, "pending_address", None)
self.pending = None
self.operation = ""
stale = getattr(self, "pending_session", None) != getattr(self, "session", None)
self.pending_session = None
try:
result = future.result()
except (BluetoothRejected, BluetoothUnavailable, ValueError):
self.return_to_root = False
self.queued = None
if not stale:
gui_app.push_widget(alert_dialog("Operation unavailable. Refresh and try again."))
except Exception:
self.return_to_root = False
self.queued = None
if not stale:
gui_app.push_widget(alert_dialog("Adapter is unavailable. Try again later."))
else:
if operation == "forget" and address:
forget_phone_keys(self.phone_pairing_allowed, address)
if operation != "close" and not stale:
self.status = result
if operation == "power":
self.scan_on_ready = bool(result.get("powered"))
self._retire_prompt()
self._rebuild()
if self.return_to_root:
gui_app.pop_widget()
self.return_to_root = False
self.next_refresh = 0.0 if operation == "close" or stale else time.monotonic() + \
(0.25 if self._pairing_active() else self.REFRESH_SECONDS)
if operation == "snapshot" and self.queued is not None and not stale:
queued = self.queued
self.queued = None
self._submit_request(*queued)
return
prompt = ((self.status or {}).get("pairing") or {}).get("prompt")
if prompt and self._pairing_ready() and prompt["id"] != self.last_prompt_id and not gui_app.mouse_events:
self.last_prompt_id = prompt["id"]
self._prompt(prompt)
if (self.active and self.scan_on_ready and self._ready() and
self.status.get("powered") and self.status.get("parked")):
self.scan_on_ready = False
if not self.status.get("discovering"):
self._request("auto_scan")
return
if self.pending is None and time.monotonic() >= self.next_refresh:
self._snapshot()
self.next_refresh = time.monotonic() + (0.25 if self._pairing_active() else self.REFRESH_SECONDS)
def _retire_prompt(self) -> None:
prompt = ((self.status or {}).get("pairing") or {}).get("prompt")
if getattr(self, "pair_dialog", None) is not None and (prompt is None or prompt.get("id") != self.pair_dialog_id):
if gui_app.get_active_widget() is self.pair_dialog:
gui_app.pop_widget()
self.pair_dialog = None
self.pair_dialog_id = None
def _show_pair_dialog(self, prompt: dict, dialog) -> None:
if not self._pairing_ready() or ((self.status or {}).get("pairing") or {}).get("prompt") != prompt:
return
self.pair_dialog, self.pair_dialog_id = dialog, prompt["id"]
gui_app.push_widget(dialog)
def _confirm(self, question, callback):
session = self.session
gui_app.push_widget(ConfirmDialog(question, "Confirm", callback=lambda result:
callback() if result == DialogResult.CONFIRM and self.active and self.session == session else None))
def _prompt(self, prompt):
if prompt.get("displayOnly"):
self._show_pair_dialog(prompt, alert_dialog(f"Bluetooth code: {prompt.get('value') or ''}"))
elif prompt.get("kind") in ("pin", "passkey"):
session = self.session
keyboard = Keyboard(max_text_size=6 if prompt["kind"] == "passkey" else 16, min_text_size=1, password_mode=True)
keyboard.set_title("Bluetooth passkey" if prompt["kind"] == "passkey" else "Bluetooth PIN")
def entered(result):
value = keyboard.text
valid = bool(re.fullmatch(r"[0-9]{1,6}", value)) if prompt["kind"] == "passkey" else value.isascii() and value.isprintable()
accepted = result == DialogResult.CONFIRM and valid
if self.active and self.session == session:
self._request("pairing_response", prompt_id=prompt["id"], accepted=accepted, value=value if accepted else "")
keyboard.clear()
keyboard.set_callback(entered)
self._show_pair_dialog(prompt, keyboard)
else:
question = f"Confirm Bluetooth code {prompt['value']}?" if prompt.get("value") else "Allow Bluetooth pairing?"
dialog = ConfirmDialog(question, "Confirm", callback=lambda result:
self._request("pairing_response", prompt_id=prompt["id"], accepted=result == DialogResult.CONFIRM))
self._show_pair_dialog(prompt, dialog)
def _rebuild(self):
status = self.status or {}
devices = tuple((item.get("address"), item.get("name"), item.get("paired"), item.get("connected"), item.get("trusted"))
for item in status.get("devices", ()))
signature = repr((self.status is None, status.get("available"), status.get("powered"), status.get("parked"),
status.get("discovering"), status.get("errorCode"), status.get("pairing"), devices,
setup_status()))
if hasattr(self, "_scroller") and (self._touch_held or gui_app.mouse_events):
return
if signature == self._signature:
return
self._signature = signature
status = self.status or {}
rows = [button_item("Bluetooth", "On" if status.get("powered") else "Off",
description=status.get("errorCode") or ("Checking adapter" if self.status is None else None),
callback=lambda: self._request("power", enabled=not bool((self.status or {}).get("powered"))),
enabled=lambda: self.active and self._ready() and bool((self.status or {}).get("parked")))]
error = {
"radio_unavailable": "Bluetooth radio support is not installed on this device.",
"adapter_unavailable": "No Bluetooth adapter detected. Turn Bluetooth on to start it.",
"service_unavailable": "Bluetooth service is not responding. Try turning Bluetooth on again.",
"radio_preference_unavailable": "The Bluetooth power setting could not be read.",
}.get(status.get("errorCode"))
if error:
rows.append(text_item("Bluetooth status", error))
elif self.status is not None and not status.get("parked"):
rows.append(text_item("Bluetooth status", "Switch to Offroad to change Bluetooth power."))
if status.get("powered"):
rows.append(button_item("Nearby devices", "Stop scan" if status.get("discovering") else "Scan",
callback=lambda: self._request("stop_scan" if (self.status or {}).get("discovering") else "scan"),
enabled=lambda: self.active and self._ready()))
pairing = status.get("pairing") or {}
if pairing.get("state") == "pairing":
prompt = pairing.get("prompt")
rows.append(text_item("Pairing", str((prompt or {}).get("value") or "Check your device")))
if prompt and not prompt.get("displayOnly"):
rows.append(button_item("Pairing request", "Review", callback=lambda p=prompt: self._prompt(p), enabled=self._pairing_ready))
rows.append(button_item("Reject pairing", "Reject", callback=lambda p=prompt:
self._request("pairing_response", prompt_id=p["id"], accepted=False), enabled=self._pairing_ready))
rows.append(button_item("Cancel pairing", "Cancel", callback=lambda: self._request("cancel_pair"), enabled=self._pairing_ready))
links = []
for device in status.get("devices", ()):
if is_app_link(device):
links.append(device)
continue
address = device.get("address")
name = "".join(char for char in str(device.get("name") or "") if char.isprintable()).strip()
address_name = bool(re.fullmatch(r"(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}", name))
if not address or not device.get("paired") and (not name or address_name or name.startswith(("Audio Device ·", "Bluetooth Device ·"))):
continue
operation = "disconnect" if device.get("connected") else "connect" if device.get("paired") else "pair"
rows.append(button_item(device_label(device), operation.title(), description=address,
callback=lambda a=address, op=operation: self._request(op, address=a), enabled=lambda: self.active and self._ready()))
if device.get("paired"):
rows.append(button_item("Forget " + device_label(device), "Forget", callback=lambda a=address:
self._confirm("Forget this Bluetooth device?", lambda: self._forget(a)),
enabled=lambda: self.active and self._ready()))
if links:
rows.append(text_item("App pairings", "Phones connected through the StarPilot app. These are not Bluetooth pairings."))
for device in links:
link_address = device.get("address")
rows.append(button_item(device_label(device), "Forget", description=link_address, callback=lambda a=link_address:
self._confirm("Forget this app connection?", lambda: self._forget(a)), enabled=lambda: self.active and self._ready()))
rows.extend(large_phone_rows(self.phone_pairing_allowed))
previous = getattr(self, "_scroller", None)
self._scroller = Scroller(rows, spacing=0, line_separator=True)
if previous is not None:
self._scroller.scroll_panel = previous.scroll_panel
for row in rows:
row.set_touch_valid_callback(self._scroller.scroll_panel.is_touch_valid)