mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
280 lines
13 KiB
Python
280 lines
13 KiB
Python
"""starportd: local control socket for direct StarPort 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 ``STARPORT_SOCKET_PATH``, mirroring bluetooth_managerd.
|
|
|
|
python -m openpilot.starpilot.system.starport.daemon # service
|
|
python -m openpilot.starpilot.system.starport.daemon --once # foreground diagnostic run
|
|
python -m openpilot.starpilot.system.starport.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.starport.protocol import STARPORT_SOCKET_PATH
|
|
from openpilot.starpilot.system.starport.source_verifier import StarPortSourceVerifier
|
|
from openpilot.starpilot.system.starport.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", "set_car_type", "set_car_profile",
|
|
"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
|
|
from openpilot.starpilot.system.starport.settings import enabled
|
|
return enabled(Params())
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def handle(supervisor: Supervisor, request: dict[str, Any], verifier: StarPortSourceVerifier | 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
|
|
from openpilot.starpilot.system.starport.settings import enabled
|
|
if not enabled(Params()):
|
|
raise RuntimeError("Enable StarPort in Tools 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 StarPort 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 == "set_car_type":
|
|
supervisor.set_car_type(str(request.get("car_type", "")))
|
|
elif command == "set_car_profile":
|
|
supervisor.set_car_profile(str(request.get("profile", "")))
|
|
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: StarPortSourceVerifier, 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.starport.placement import RendererPlacement
|
|
placement = RendererPlacement(idle=False, report=lambda event: print(json.dumps(event), flush=True))
|
|
placement.start()
|
|
|
|
try:
|
|
os.unlink(STARPORT_SOCKET_PATH)
|
|
except FileNotFoundError:
|
|
pass
|
|
from openpilot.starpilot.system.starport.bluetooth_bridge import SharedBluetoothOwner
|
|
source_verifier = StarPortSourceVerifier()
|
|
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.starport.bugsink_report import Reporter
|
|
cleanup.callback(Reporter(parked=lambda: _offroad() and not supervisor.status()["running"]).start().close)
|
|
server = Server(STARPORT_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(STARPORT_SOCKET_PATH, 0o660)
|
|
server.serve_forever(poll_interval=0.5)
|
|
finally:
|
|
exit_event.set()
|
|
try:
|
|
os.unlink(STARPORT_SOCKET_PATH)
|
|
except FileNotFoundError:
|
|
pass
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|