Files
StarPilot/openpilot/starpilot/system/android_auto/daemon.py
T
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

274 lines
12 KiB
Python

"""android_autod: local control socket for wireless Android Auto projection.
Runs only when Android Auto and Bluetooth are enabled. Projection starts when the user presses
Start, or on its own when auto-connect is on and the chosen car is on (see auto_connect.py);
until then nothing but a Bluetooth status check and the car's hands-free gateway runs. Commands are one JSON line per
connection on ``ANDROID_AUTO_SOCKET_PATH``, mirroring bluetooth_managerd.
python -m openpilot.starpilot.system.android_auto.daemon # service
python -m openpilot.starpilot.system.android_auto.daemon --once # foreground diagnostic run
python -m openpilot.starpilot.system.android_auto.daemon --once --synthetic # test pattern, no UI needed
"""
from __future__ import annotations
import argparse
from contextlib import ExitStack
import json
import os
import signal
import socketserver
import sys
import threading
import time
from typing import Any
from openpilot.starpilot.system.android_auto.protocol import ANDROID_AUTO_SOCKET_PATH
from openpilot.starpilot.system.android_auto.source_verifier import GalaxySourceVerifier
from openpilot.starpilot.system.android_auto.supervisor import Supervisor
PAIRING_COMMANDS = {"prepare_pairing", "pairing_status", "pairing_response", "cancel_pairing", "pair_device"}
AUTHENTICATED_COMMANDS = {"start", "set_auto_connect", "select_receiver", "set_view", "set_connection",
"prepare_pairing", "pairing_status", "pairing_response", "cancel_pairing", "pair_device", "devices", "bluetooth_action",
"phone_window", "bluetooth_power"}
def _offroad() -> bool:
try:
from openpilot.common.params import Params
return Params().get_bool("IsOffroad")
except Exception:
return False
def _enabled() -> bool:
try:
from openpilot.common.params import Params
return Params().get_bool('AndroidAutoEnabled')
except Exception:
return False
def handle(supervisor: Supervisor, request: dict[str, Any], verifier: GalaxySourceVerifier | None = None, *, parked=None) -> dict[str, Any]:
command = str(request.get("command", ""))
if command not in {"status", "stop", "forget_receiver"}: # forgetting a car must work with Android Auto off
from openpilot.common.params import Params
if not Params().get_bool("AndroidAutoEnabled"):
raise RuntimeError("Enable Android Auto under Toggles → Android Auto first")
if command in AUTHENTICATED_COMMANDS:
source = request.get('source')
if not isinstance(source, str) or verifier is None or not verifier.valid(source):
raise RuntimeError('Authenticated Galaxy Android Auto source required')
if command not in ('bluetooth_action', 'phone_window', 'bluetooth_power'):
supervisor.bind_source_session(('galaxy', source))
if command in PAIRING_COMMANDS and not (parked or _offroad)():
raise RuntimeError("Disengage and shift into Park before pairing")
if command == 'bluetooth_action':
from openpilot.starpilot.bluetooth.owner import BluetoothRejected, BluetoothUnavailable
if set(request) != {'command', 'source', 'operation', 'address'}:
raise ValueError('Invalid companion Bluetooth request')
if not (parked or _offroad)():
return {'handled': True, 'error_code': 'park_required'}
try:
result = supervisor.companion_bluetooth_action(request['operation'], request['address'], ('galaxy', request['source']))
except (BluetoothRejected, BluetoothUnavailable) as error:
return {'handled': True, 'error_code': error.code, 'message': str(error)[:160]}
except (OSError, RuntimeError):
return {'handled': True, 'error_code': 'service_unavailable'}
return {'handled': result is not None, **({'bluetooth': result} if result is not None else {})}
if command == 'bluetooth_power':
from openpilot.starpilot.bluetooth.owner import BluetoothRejected, BluetoothUnavailable
if set(request) != {'command', 'source', 'enabled'} or type(request['enabled']) is not bool:
raise ValueError('Invalid companion Bluetooth power request')
if not (parked or _offroad)():
return {'handled': True, 'error_code': 'park_required'}
try:
result = supervisor.companion_power(request['enabled'], ('galaxy', request['source']))
except (BluetoothRejected, BluetoothUnavailable) as error:
return {'handled': True, 'error_code': error.code, 'message': str(error)[:160]}
except (OSError, RuntimeError):
return {'handled': True, 'error_code': 'service_unavailable'}
return {'handled': result is not None, **({'bluetooth': result} if result is not None else {})}
if command == 'phone_window':
from openpilot.starpilot.bluetooth.owner import BluetoothRejected, BluetoothUnavailable
operation = request.get('operation')
fields = {'command', 'source', 'operation'}
fields |= {'prompt_id', 'accepted', 'value'} if operation == 'pairing_response' else {'address'} if operation == 'pair' else set()
if set(request) != fields or operation not in ('accept_pair', 'pair', 'scan', 'stop_scan', 'pairing_response', 'cancel_pair', 'status'):
raise ValueError('Invalid phone pairing request')
if operation != 'status' and not (parked or _offroad)():
return {'handled': True, 'error_code': 'park_required'}
answer = {key: request[key] for key in ('prompt_id', 'accepted', 'value', 'address') if key in request}
try:
result = supervisor.phone_window(operation, ('galaxy', request['source']), **answer)
except (BluetoothRejected, BluetoothUnavailable) as error:
return {'handled': True, 'error_code': error.code, 'message': str(error)[:160]}
except (OSError, RuntimeError):
return {'handled': True, 'error_code': 'service_unavailable'}
return {'handled': result is not None, **({'bluetooth': result} if result is not None else {})}
if command == "status":
return {"status": supervisor.status()}
if command == "start":
supervisor.user_start()
elif command == "stop":
supervisor.user_stop()
elif command == "set_auto_connect":
supervisor.set_auto_connect(bool(request.get("enabled", True)))
elif command == "set_dedicated_wifi":
supervisor.set_dedicated_wifi(request.get("enabled") is True)
elif command == "select_receiver":
supervisor.select_receiver(str(request.get("address", "")), str(request.get("name", "")))
elif command == "forget_receiver":
return {"cleared": supervisor.forget_receiver(str(request.get("address", "")))}
elif command == "set_view":
supervisor.set_view(str(request.get("view", "")))
elif command == "set_connection":
supervisor.set_connection(str(request.get("connection", "")))
elif command == "prepare_pairing":
supervisor.prepare_pairing()
elif command == "pairing_status":
return {"pairing": supervisor.pairing_status()}
elif command == "pair_device":
address = request.get("address")
if not isinstance(address, str):
raise ValueError("Invalid Bluetooth address")
supervisor.pair_device(address)
elif command == "pairing_response":
prompt, accepted = request.get('prompt_id'), request.get('accepted')
if not isinstance(prompt, str) or type(accepted) is not bool:
raise ValueError('Invalid pairing response')
supervisor.pairing_response(prompt, accepted, request.get('value', ''))
elif command == "cancel_pairing":
supervisor.cancel_pairing()
elif command == "devices":
return {"devices": supervisor.devices()}
else:
raise RuntimeError(f"Unknown Android Auto command: {command}")
return {}
class RequestHandler(socketserver.StreamRequestHandler):
def handle(self) -> None:
try:
request = json.loads(self.rfile.readline(64 * 1024))
response = {"ok": True, **handle(self.server.supervisor, request, self.server.source_verifier, parked=self.server.parked)}
except Exception as error:
response = {"ok": False, "error": str(error)}
self.wfile.write(json.dumps(response, separators=(",", ":"), default=str).encode() + b"\n")
class Server(socketserver.ThreadingUnixStreamServer):
daemon_threads = True
def __init__(self, path: str, supervisor: Supervisor, source_verifier: GalaxySourceVerifier, parked=_offroad):
self.supervisor = supervisor
self.source_verifier = source_verifier
self.parked = parked
super().__init__(path, RequestHandler)
def run_once(duration: float, synthetic: bool) -> int:
"""Foreground diagnostic: start, print state changes, stop after ``duration`` seconds or Ctrl-C."""
supervisor = Supervisor(synthetic=synthetic)
stop = threading.Event()
signal.signal(signal.SIGINT, lambda *_: stop.set())
signal.signal(signal.SIGTERM, lambda *_: stop.set())
supervisor.start()
deadline = time.monotonic() + duration
last = None
try:
while not stop.is_set() and time.monotonic() < deadline:
status = supervisor.status()
summary = (status["state"], status["detail"], status["error"])
if summary != last:
print(json.dumps({key: status[key] for key in ("state", "label", "detail", "error", "mode", "head_unit")}, default=str), flush=True)
last = summary
if status["state"] == "streaming" and int(time.monotonic()) % 5 == 0:
print(json.dumps({"stats": status["stats"]}), flush=True)
stop.wait(1.0)
finally:
supervisor.close()
return 0 if supervisor.status()["error"] == "" else 1
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--once", action="store_true", help="run one foreground session for diagnostics")
parser.add_argument("--duration", type=float, default=900.0)
parser.add_argument("--synthetic", action="store_true", help="with --once: send a moving test pattern instead of the UI")
args = parser.parse_args()
if args.once:
return run_once(args.duration, args.synthetic)
# Core 6 next to the renderer, before any thread starts so all inherit it
# (see placement.py); re-pinned by housekeeping after power saving.
from openpilot.common.hardware import COMMA_HARDWARE
placement = None
if COMMA_HARDWARE:
from openpilot.starpilot.system.android_auto.placement import RendererPlacement
placement = RendererPlacement(idle=False, report=lambda event: print(json.dumps(event), flush=True))
placement.start()
try:
os.unlink(ANDROID_AUTO_SOCKET_PATH)
except FileNotFoundError:
pass
from openpilot.starpilot.system.android_auto.bluetooth_bridge import SharedBluetoothOwner
source_verifier = GalaxySourceVerifier()
from openpilot.common.params import Params
from openpilot.starpilot.galaxy.evidence import EvidenceSource
from openpilot.starpilot.galaxy.settings import LiveContextSource
with ExitStack() as cleanup:
evidence = EvidenceSource().start()
cleanup.callback(evidence.close)
authority = LiveContextSource(Params(), messages=evidence, evidence_wait_ms=0)
cleanup.callback(authority.close)
bluetooth_owner = SharedBluetoothOwner(authority.configuration_allowed,
session_valid=lambda session: len(session) == 2 and session[0] == 'galaxy' and source_verifier.valid(session[1]))
cleanup.callback(bluetooth_owner.close)
bluetooth_owner.recover_phone_role()
supervisor = Supervisor(shared_bluetooth_owner=bluetooth_owner, projection_enabled=_enabled)
supervisor.recover_usb()
cleanup.callback(supervisor.close)
exit_event = threading.Event()
cleanup.callback(exit_event.set)
def housekeeping():
while not exit_event.wait(2.0):
try:
supervisor.maintain()
except Exception:
pass
if placement is not None:
try:
placement.maintain(time.monotonic())
except Exception:
pass
threading.Thread(target=housekeeping, daemon=True).start()
from openpilot.starpilot.system.android_auto.bugsink_report import Reporter
cleanup.callback(Reporter(parked=lambda: _offroad() and not supervisor.status()["running"]).start().close)
server = Server(ANDROID_AUTO_SOCKET_PATH, supervisor, source_verifier, authority.configuration_allowed)
cleanup.callback(server.server_close)
def shutdown(*_):
exit_event.set()
threading.Thread(target=server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
try:
os.chmod(ANDROID_AUTO_SOCKET_PATH, 0o660)
server.serve_forever(poll_interval=0.5)
finally:
exit_event.set()
try:
os.unlink(ANDROID_AUTO_SOCKET_PATH)
except FileNotFoundError:
pass
return 0
if __name__ == "__main__":
sys.exit(main())