mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-10 14:33:42 +08:00
a531afd02a
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.
274 lines
12 KiB
Python
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())
|