mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-10-04 20:53:53 +08:00
Add local Sunnydrive discovery and pairing
This commit is contained in:
@@ -163,6 +163,9 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"DevUIInfo", {PERSISTENT | BACKUP, INT, "0"}},
|
||||
{"EnableCopyparty", {PERSISTENT | BACKUP, BOOL}},
|
||||
{"SunnydriveEnabled", {PERSISTENT | BACKUP, BOOL}},
|
||||
{"SunnydrivePairedClients", {PERSISTENT, JSON}},
|
||||
{"SunnydrivePairingRequests", {CLEAR_ON_MANAGER_START, JSON}},
|
||||
{"SunnydrivePairingWindow", {CLEAR_ON_MANAGER_START, JSON}},
|
||||
{"EnableGithubRunner", {PERSISTENT | BACKUP, BOOL}},
|
||||
{"GreenLightAlert", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"GithubRunnerSufficientVoltage", {CLEAR_ON_MANAGER_START , BOOL}},
|
||||
|
||||
@@ -7,10 +7,11 @@ See the LICENSE.md file in the root directory for more details.
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings import settings as OP
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsBigButton
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton, BigParamControl
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnydrive import SunnydriveLayoutMici
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
@@ -50,6 +51,10 @@ class SettingsLayoutSP(OP.SettingsLayout):
|
||||
models_btn = SettingsBigButton(tr("models"), "", gui_app.texture("../../sunnypilot/selfdrive/assets/offroad/icon_models.png", ICON_SIZE, ICON_SIZE))
|
||||
models_btn.set_click_callback(lambda: gui_app.push_widget(models_panel))
|
||||
|
||||
sunnydrive_panel = SunnydriveLayoutMici()
|
||||
sunnydrive_btn = SettingsBigButton(tr("sunnydrive"), "", gui_app.texture("icons_mici/settings/device_icon.png", 72, 58))
|
||||
sunnydrive_btn.set_click_callback(lambda: gui_app.push_widget(sunnydrive_panel))
|
||||
|
||||
# onroad: enable button sits at the front (left of toggles)
|
||||
self._enable_offroad_btn_onroad = BigCircleButton(self.icon_offroad_enable, red=True)
|
||||
self._enable_offroad_btn_onroad.set_click_callback(lambda: self._handle_always_offroad(True))
|
||||
@@ -72,7 +77,7 @@ class SettingsLayoutSP(OP.SettingsLayout):
|
||||
# front slots (only one ever visible at a time): exit-always-offroad, then enable-onroad
|
||||
items.insert(0, self._enable_offroad_btn_onroad)
|
||||
items.insert(0, self._disable_offroad_btn)
|
||||
items.insert(0, BigParamControl("sunnydrive", "SunnydriveEnabled"))
|
||||
items.insert(0, sunnydrive_btn)
|
||||
# end slot: enable-offroad (right of developer)
|
||||
items.append(self._enable_offroad_btn_offroad)
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import urllib.parse
|
||||
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigParamControl
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog
|
||||
from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog
|
||||
from openpilot.sunnypilot.sunnydrive import pairing
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
|
||||
|
||||
class SunnydrivePairingDialog(PairingDialog):
|
||||
"""One screen supplies both the QR secret and the six digit fallback code."""
|
||||
|
||||
def __init__(self):
|
||||
self._state = pairing.open_window()
|
||||
super().__init__()
|
||||
code = self._state["code"]
|
||||
self._pair_label.set_text(f"scan with sunnydrive\nor enter {code[:3]} {code[3:]}")
|
||||
|
||||
def _get_pairing_url(self):
|
||||
query = urllib.parse.urlencode({"device": pairing.device_id(), "secret": self._state["secret"]})
|
||||
return f"sunnydrive://pair?{query}"
|
||||
|
||||
|
||||
class SunnydriveLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._pending_id = ""
|
||||
self._enable = BigParamControl("enabled", "SunnydriveEnabled")
|
||||
self._pair = BigButton("pair phone", "QR code or 6 digit code")
|
||||
self._pair.set_click_callback(lambda: gui_app.push_widget(SunnydrivePairingDialog()))
|
||||
self._approve = BigButton("approve phone", "waiting for a request")
|
||||
self._approve.set_click_callback(self._confirm_pending)
|
||||
self._unpair = BigButton("paired phones", "none")
|
||||
self._unpair.set_click_callback(self._confirm_unpair_all)
|
||||
self._scroller.add_widgets([self._enable, self._pair, self._approve, self._unpair])
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._enable.refresh()
|
||||
requests = pairing.pairing_requests()
|
||||
pending = next(((request_id, request) for request_id, request in reversed(list(requests.items())) if request.get("status") == "pending"), None)
|
||||
self._pending_id = pending[0] if pending else ""
|
||||
self._approve.set_value(pending[1].get("name", "Sunnydrive phone") if pending else "waiting for a request")
|
||||
self._approve.set_visible(bool(pending))
|
||||
count = len(pairing.paired_clients())
|
||||
self._unpair.set_value(f"{count} paired" if count else "none")
|
||||
self._unpair.set_visible(count > 0)
|
||||
|
||||
def _confirm_pending(self):
|
||||
if not self._pending_id:
|
||||
return
|
||||
request_id = self._pending_id
|
||||
icon = gui_app.texture("icons_mici/settings/device/pair.png", 64, 64)
|
||||
gui_app.push_widget(BigConfirmationDialog("slide to pair phone", icon, lambda: pairing.approve_request(request_id)))
|
||||
|
||||
def _confirm_unpair_all(self):
|
||||
icon = gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64)
|
||||
gui_app.push_widget(BigConfirmationDialog("slide to unpair all phones", icon, lambda: pairing.unpair(), red=True))
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Local Sunnydrive pairing shared by sunnydrived and the comma UI."""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from openpilot.common.params import Params
|
||||
|
||||
|
||||
PAIRED_KEY = "SunnydrivePairedClients"
|
||||
WINDOW_KEY = "SunnydrivePairingWindow"
|
||||
REQUESTS_KEY = "SunnydrivePairingRequests"
|
||||
PAIRING_SECONDS = 5 * 60
|
||||
CLIENT_ID_RE = re.compile(r"[A-Za-z0-9_-]{16,128}")
|
||||
|
||||
|
||||
def _params(params=None):
|
||||
return params or Params()
|
||||
|
||||
|
||||
def _dict(params, key):
|
||||
value = params.get(key)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def device_id(params=None):
|
||||
params = _params(params)
|
||||
return params.get("DongleId") or params.get("HardwareSerial") or "unregistered"
|
||||
|
||||
|
||||
def device_name(params=None):
|
||||
identity = device_id(params)
|
||||
return f"comma {identity[-8:]}" if identity != "unregistered" else "comma"
|
||||
|
||||
|
||||
def paired_clients(params=None):
|
||||
return _dict(_params(params), PAIRED_KEY)
|
||||
|
||||
|
||||
def is_paired(client_id, params=None):
|
||||
return client_id in paired_clients(params)
|
||||
|
||||
|
||||
def authorized(token, params=None):
|
||||
if not token:
|
||||
return False
|
||||
digest = hashlib.sha256(token.encode()).hexdigest()
|
||||
return any(secrets.compare_digest(digest, str(client.get("token_hash", ""))) for client in paired_clients(params).values())
|
||||
|
||||
|
||||
def _pair(client_id, name, params):
|
||||
if not CLIENT_ID_RE.fullmatch(client_id):
|
||||
raise ValueError("invalid client id")
|
||||
token = secrets.token_urlsafe(32)
|
||||
clients = paired_clients(params)
|
||||
clients[client_id] = {"name": str(name or "Sunnydrive phone")[:80], "token_hash": hashlib.sha256(token.encode()).hexdigest(),
|
||||
"paired_at": int(datetime.now(UTC).timestamp())}
|
||||
params.put(PAIRED_KEY, clients, block=True)
|
||||
return token
|
||||
|
||||
|
||||
def open_window(params=None):
|
||||
params = _params(params)
|
||||
state = {"code": f"{secrets.randbelow(1_000_000):06d}", "secret": secrets.token_urlsafe(32),
|
||||
"expires": int(time.monotonic()) + PAIRING_SECONDS, "attempts": 0}
|
||||
params.put(WINDOW_KEY, state, block=True)
|
||||
return state
|
||||
|
||||
|
||||
def pairing_window(params=None):
|
||||
params = _params(params)
|
||||
state = _dict(params, WINDOW_KEY)
|
||||
if state and int(state.get("expires", 0)) > time.monotonic():
|
||||
return state
|
||||
if state:
|
||||
params.remove(WINDOW_KEY)
|
||||
return {}
|
||||
|
||||
|
||||
def complete_pairing(client_id, name, proof, params=None):
|
||||
params = _params(params)
|
||||
state = pairing_window(params)
|
||||
valid = state and (secrets.compare_digest(str(proof), str(state.get("code", ""))) or secrets.compare_digest(str(proof), str(state.get("secret", ""))))
|
||||
if not valid:
|
||||
if state:
|
||||
state["attempts"] = int(state.get("attempts", 0)) + 1
|
||||
if state["attempts"] >= 5:
|
||||
params.remove(WINDOW_KEY)
|
||||
else:
|
||||
params.put(WINDOW_KEY, state, block=True)
|
||||
raise PermissionError("pairing code expired or incorrect")
|
||||
token = _pair(client_id, name, params)
|
||||
params.remove(WINDOW_KEY)
|
||||
return token
|
||||
|
||||
|
||||
def request_pairing(client_id, name, params=None):
|
||||
if not CLIENT_ID_RE.fullmatch(client_id):
|
||||
raise ValueError("invalid client id")
|
||||
params = _params(params)
|
||||
now = int(time.monotonic())
|
||||
requests = {key: value for key, value in _dict(params, REQUESTS_KEY).items() if int(value.get("expires", 0)) > now}
|
||||
request_id = secrets.token_urlsafe(18)
|
||||
requests[request_id] = {"client_id": client_id, "name": str(name or "Sunnydrive phone")[:80], "status": "pending", "expires": now + PAIRING_SECONDS}
|
||||
params.put(REQUESTS_KEY, requests, block=True)
|
||||
return request_id
|
||||
|
||||
|
||||
def pairing_requests(params=None):
|
||||
params = _params(params)
|
||||
now = int(time.monotonic())
|
||||
requests = _dict(params, REQUESTS_KEY)
|
||||
fresh = {key: value for key, value in requests.items() if int(value.get("expires", 0)) > now}
|
||||
if fresh != requests:
|
||||
params.put(REQUESTS_KEY, fresh, block=True) if fresh else params.remove(REQUESTS_KEY)
|
||||
return fresh
|
||||
|
||||
|
||||
def approve_request(request_id, params=None):
|
||||
params = _params(params)
|
||||
requests = pairing_requests(params)
|
||||
request = requests.get(request_id)
|
||||
if not request or request.get("status") != "pending":
|
||||
return False
|
||||
request["token"] = _pair(request["client_id"], request.get("name"), params)
|
||||
request["status"] = "approved"
|
||||
requests[request_id] = request
|
||||
params.put(REQUESTS_KEY, requests, block=True)
|
||||
return True
|
||||
|
||||
|
||||
def consume_request(request_id, params=None):
|
||||
params = _params(params)
|
||||
requests = pairing_requests(params)
|
||||
request = requests.get(request_id)
|
||||
if not request:
|
||||
return {"status": "expired"}
|
||||
result = {"status": request.get("status", "pending")}
|
||||
if request.get("status") == "approved":
|
||||
result["token"] = request.get("token", "")
|
||||
requests.pop(request_id, None)
|
||||
params.put(REQUESTS_KEY, requests, block=True) if requests else params.remove(REQUESTS_KEY)
|
||||
return result
|
||||
|
||||
|
||||
def unpair(client_id=None, params=None):
|
||||
params = _params(params)
|
||||
clients = paired_clients(params)
|
||||
if client_id is None:
|
||||
params.remove(PAIRED_KEY)
|
||||
return bool(clients)
|
||||
removed = clients.pop(client_id, None) is not None
|
||||
if removed:
|
||||
params.put(PAIRED_KEY, clients, block=True) if clients else params.remove(PAIRED_KEY)
|
||||
return removed
|
||||
@@ -1,31 +1,33 @@
|
||||
"""Serve Sunnydrive telemetry and comma-side APIs to the phone app."""
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import functools
|
||||
import json
|
||||
import zlib
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from urllib.parse import parse_qs, quote, urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from openpilot.cereal import messaging # noqa: E402
|
||||
from openpilot.sunnypilot.sunnydrive import pairing # noqa: E402
|
||||
|
||||
SERVICES = ["carState", "selfdriveState", "selfdriveStateSP", "carControl", "gpsLocationExternal", "deviceState", "carParams", "modelV2", "driverMonitoringState"]
|
||||
LLM_GET = {"/v1/models", "/api/v0/models"}
|
||||
LLM_POST = {"/v1/chat/completions"}
|
||||
SUNNYLINK_WIDGETS = {"toggle", "option", "multiple_button"} # the setting kinds the app can change
|
||||
DISCOVERY_PORT = 53134
|
||||
DISCOVERY_PREFIX = b"SUNNYDRIVE1 "
|
||||
|
||||
|
||||
def fresh(sm, service, seconds=3):
|
||||
@@ -493,46 +495,6 @@ threading.Thread(target=_summary_worker, daemon=True, name="connect-summaries").
|
||||
_segment_files, _summaries = {}, {} # per-folder file list/size, and per-minute summaries, kept once read
|
||||
|
||||
|
||||
def tailscale_peers():
|
||||
"""Devices on this machine's tailnet, for the phone setup screen's server pickers. Empty when tailscale isn't here."""
|
||||
import glob, shutil
|
||||
cli = shutil.which("tailscale") or next(iter(sorted(glob.glob("/data/tailscale_*/tailscale"))), None) or next((p for p in ("/Applications/Tailscale.app/Contents/MacOS/Tailscale",) if os.path.exists(p)), None)
|
||||
if not cli:
|
||||
return []
|
||||
sock = "/data/tailscale/socket/tailscaled.sock" # the comma runs its own tailscaled
|
||||
try:
|
||||
out = subprocess.run([cli, *([f"--socket={sock}"] if os.path.exists(sock) else []), "status", "--json"], capture_output=True, timeout=5, check=True).stdout
|
||||
status = json.loads(out)
|
||||
except (OSError, subprocess.SubprocessError, ValueError):
|
||||
return []
|
||||
peers = [dict(node, self=True) for node in [status.get("Self") or {}]] + list((status.get("Peer") or {}).values())
|
||||
found = [{"name": n.get("HostName", ""), "dns": n.get("DNSName", "").rstrip("."), "os": n.get("OS", ""), "online": bool(n.get("Online") or n.get("self")), "self": bool(n.get("self"))} for n in peers if n.get("DNSName")]
|
||||
return sorted(found, key=lambda n: (not n["online"], n["dns"]))
|
||||
|
||||
|
||||
_llm_found = {"url": "", "at": 0.0}
|
||||
|
||||
|
||||
def find_llm():
|
||||
"""The first online tailnet device with LM Studio on its usual port. A hit is kept; a miss is retried after a minute."""
|
||||
if _llm_found["url"] or time.monotonic() - _llm_found["at"] < 60:
|
||||
return _llm_found["url"]
|
||||
_llm_found["at"] = time.monotonic()
|
||||
|
||||
def probe(peer):
|
||||
url = f"http://{peer['dns']}:1234"
|
||||
try:
|
||||
with urlopen(url + "/v1/models", timeout=2) as response:
|
||||
return url if response.status == 200 else ""
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
peers = [p for p in tailscale_peers() if p["online"] and not p["self"]]
|
||||
with concurrent.futures.ThreadPoolExecutor(8) as pool:
|
||||
_llm_found["url"] = next((url for url in pool.map(probe, peers) if url), "")
|
||||
return _llm_found["url"]
|
||||
|
||||
|
||||
def phone_alerts():
|
||||
"""What the phone app notifies about: each drive once it's fully summarised, and critical alerts during it. Newest first; ids are stable."""
|
||||
events = []
|
||||
@@ -593,7 +555,7 @@ def _connect_routes(_tick):
|
||||
return ordered
|
||||
|
||||
|
||||
def connect_playlist(route, cam="q"):
|
||||
def connect_playlist(route, cam="q", auth=""):
|
||||
"""HLS playlist of one camera's segments for a drive; missing minutes become discontinuities."""
|
||||
name = CONNECT_CAMERAS[cam]
|
||||
segments = sorted(int(p.name.rpartition("--")[2]) for p in REALDATA.glob(f"{route}--*") if p.name.rpartition("--")[2].isdigit() and (p / name).is_file())
|
||||
@@ -606,12 +568,13 @@ def connect_playlist(route, cam="q"):
|
||||
lines.append("#EXT-X-DISCONTINUITY")
|
||||
if cam == "q": # low-res is already small: one piece per minute
|
||||
seconds = connect_segment_duration(route, seg) if seg == segments[-1] else 60
|
||||
lines += [f"#EXTINF:{seconds:.2f},", f"qcamera?route={route}&seg={seg}&cam={cam}"]
|
||||
lines += [f"#EXTINF:{seconds:.2f},", f"qcamera?route={route}&seg={seg}&cam={cam}" + (f"&auth={quote(auth)}" if auth else "")]
|
||||
else: # full-res: 2 s pieces; only the drive's last (possibly short) minute is counted exactly
|
||||
frames = hevc_frame_count(REALDATA / f"{route}--{seg}" / name) if seg == segments[-1] else 1200
|
||||
for part in range((frames + CONNECT_PART_FRAMES - 1) // CONNECT_PART_FRAMES):
|
||||
seconds = min(CONNECT_PART_FRAMES, frames - part * CONNECT_PART_FRAMES) / 20
|
||||
lines += [f"#EXTINF:{seconds:.2f},", f"qcamera?route={route}&seg={seg}&cam={cam}&part={part}" + ("&last=1" if seg == segments[-1] else "")]
|
||||
path = f"qcamera?route={route}&seg={seg}&cam={cam}&part={part}" + ("&last=1" if seg == segments[-1] else "")
|
||||
lines += [f"#EXTINF:{seconds:.2f},", path + (f"&auth={quote(auth)}" if auth else "")]
|
||||
previous = seg
|
||||
return "\n".join(lines + ["#EXT-X-ENDLIST", ""])
|
||||
|
||||
@@ -675,11 +638,12 @@ def sunnylink_set(key, value):
|
||||
class SunnydriveServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, address, allowed_origin=None, llm_upstream=""):
|
||||
def __init__(self, address, allowed_origin=None, llm_upstream="", allow_loopback=True):
|
||||
super().__init__(address, SunnydriveHandler)
|
||||
# The Android apps bundle the WUI and request these APIs across origins.
|
||||
self.allowed_origins = {"https://ai.sunnypilot.sunnydrive", "https://ai.sunnypilot.sunnydrive.parked", "http://localhost:8766", "http://127.0.0.1:8766"} | ({allowed_origin} if allowed_origin else set())
|
||||
self.llm_upstream = llm_upstream.rstrip("/")
|
||||
self.allow_loopback = allow_loopback
|
||||
self.telemetry_changed = threading.Condition()
|
||||
self.telemetry_version = 0
|
||||
self.publish_telemetry({"timestampMs": 0, "car": None, "selfdrive": None, "mads": None, "lateral": None, "gps": None, "device": None, "vehicle": None, "model": None, "driverMonitoring": None})
|
||||
@@ -694,6 +658,9 @@ class SunnydriveServer(ThreadingHTTPServer):
|
||||
|
||||
|
||||
class SunnydriveHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, _format, *_args):
|
||||
pass # authenticated media URLs carry a token in the query; never put it in logs
|
||||
|
||||
def send_header(self, keyword, value):
|
||||
if keyword.lower() == "access-control-allow-origin":
|
||||
self._cors_sent = True
|
||||
@@ -710,6 +677,14 @@ class SunnydriveHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parsed = urlsplit(self.path)
|
||||
path = parsed.path
|
||||
query = parse_qs(parsed.query)
|
||||
if path == "/pair/info":
|
||||
client_id = query.get("client_id", [""])[0]
|
||||
return self.send_json({"deviceId": pairing.device_id(), "name": pairing.device_name(), "apiVersion": 1, "paired": pairing.is_paired(client_id)})
|
||||
if path == "/pair/status":
|
||||
return self.send_json(pairing.consume_request(query.get("request", [""])[0]))
|
||||
if not self.authorized(query):
|
||||
return self.send_error(401, "Pair this phone first")
|
||||
if path == "/connect/routes":
|
||||
try:
|
||||
from openpilot.common.params import Params
|
||||
@@ -719,10 +694,7 @@ class SunnydriveHandler(BaseHTTPRequestHandler):
|
||||
return self.send_json({"routes": connect_routes(), "offroad": offroad})
|
||||
if path == "/alerts":
|
||||
return self.send_json({"alerts": phone_alerts()})
|
||||
if path == "/tailscale/peers":
|
||||
return self.send_json({"peers": tailscale_peers()})
|
||||
if path in ("/connect/playlist.m3u8", "/connect/qcamera"):
|
||||
query = parse_qs(parsed.query)
|
||||
route = query.get("route", [""])[0]
|
||||
cam = query.get("cam", ["q"])[0]
|
||||
if not CONNECT_ROUTE.fullmatch(route) or cam not in CONNECT_CAMERAS:
|
||||
@@ -749,7 +721,7 @@ class SunnydriveHandler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
return
|
||||
try:
|
||||
body = connect_playlist(route, cam).encode()
|
||||
body = connect_playlist(route, cam, query.get("auth", [""])[0]).encode()
|
||||
except FileNotFoundError:
|
||||
return self.send_error(404, "No video for that route")
|
||||
self.send_response(200)
|
||||
@@ -857,18 +829,35 @@ class SunnydriveHandler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
def do_OPTIONS(self):
|
||||
# CORS preflight for the assistant's JSON POST from the app origins
|
||||
if self.headers.get("Origin") not in self.server.allowed_origins or urlsplit(self.path).path not in LLM_GET | LLM_POST | {"/sunnylink/param", "/sunnylink/vehicle"}:
|
||||
if self.headers.get("Origin") not in self.server.allowed_origins:
|
||||
return self.send_error(403)
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", self.headers.get("Origin"))
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
self.send_header("Access-Control-Max-Age", "600")
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
path = urlsplit(self.path).path
|
||||
if path in ("/pair/request", "/pair/complete"):
|
||||
try:
|
||||
body = self.read_json()
|
||||
if path == "/pair/request":
|
||||
return self.send_json({"request": pairing.request_pairing(str(body["clientId"]), body.get("name"))})
|
||||
return self.send_json({"token": pairing.complete_pairing(str(body["clientId"]), body.get("name"), str(body["proof"]))})
|
||||
except PermissionError as error:
|
||||
return self.send_error(403, str(error))
|
||||
except (ValueError, KeyError, TypeError) as error:
|
||||
return self.send_error(400, str(error))
|
||||
if not self.authorized(parse_qs(urlsplit(self.path).query)):
|
||||
return self.send_error(401, "Pair this phone first")
|
||||
if path == "/pair/unpair":
|
||||
try:
|
||||
body = self.read_json()
|
||||
return self.send_json({"removed": pairing.unpair(str(body["clientId"]))})
|
||||
except (ValueError, KeyError, TypeError) as error:
|
||||
return self.send_error(400, str(error))
|
||||
if path in ("/sunnylink/param", "/sunnylink/vehicle"):
|
||||
origin = self.headers.get("Origin")
|
||||
if origin not in self.server.allowed_origins and urlsplit(origin or "").netloc != self.headers.get("Host"): # browser origin filter; non-browser clients can forge Origin
|
||||
@@ -893,9 +882,9 @@ class SunnydriveHandler(BaseHTTPRequestHandler):
|
||||
self.proxy_llm(path, self.rfile.read(length))
|
||||
|
||||
def proxy_llm(self, path, body=None):
|
||||
upstream = self.server.llm_upstream or find_llm()
|
||||
upstream = self.server.llm_upstream
|
||||
if not upstream:
|
||||
return self.send_error(502, "No LM Studio found on the tailnet")
|
||||
return self.send_error(502, "No model server configured")
|
||||
request = Request(upstream + path, data=body,
|
||||
headers={"Content-Type": "application/json"} if body is not None else {},
|
||||
method="POST" if body is not None else "GET")
|
||||
@@ -915,6 +904,19 @@ class SunnydriveHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(result)
|
||||
|
||||
def read_json(self):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length <= 0 or length > 65536:
|
||||
raise ValueError("invalid request size")
|
||||
return json.loads(self.rfile.read(length))
|
||||
|
||||
def authorized(self, query):
|
||||
if self.server.allow_loopback and self.client_address[0] in ("127.0.0.1", "::1"):
|
||||
return True
|
||||
header = self.headers.get("Authorization", "")
|
||||
token = header[7:] if header.startswith("Bearer ") else query.get("auth", [""])[0]
|
||||
return pairing.authorized(token)
|
||||
|
||||
|
||||
def compact(sample):
|
||||
"""Road model numbers to centimetres for the stream (17-digit floats were ~80% of every sample); GPS stays exact."""
|
||||
@@ -923,6 +925,36 @@ def compact(sample):
|
||||
return {**sample, "model": r(sample["model"])} if isinstance(sample, dict) and sample.get("model") else sample
|
||||
|
||||
|
||||
def discovery_response(packet, http_port=8766):
|
||||
"""Return a small discovery reply, or None for malformed/foreign datagrams."""
|
||||
if not packet.startswith(DISCOVERY_PREFIX) or len(packet) > 1024:
|
||||
return None
|
||||
try:
|
||||
request = json.loads(packet[len(DISCOVERY_PREFIX):])
|
||||
nonce, client_id = str(request["nonce"]), str(request.get("clientId", ""))
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return None
|
||||
if request.get("v") != 1 or not 8 <= len(nonce) <= 128:
|
||||
return None
|
||||
body = {"v": 1, "nonce": nonce, "deviceId": pairing.device_id(), "name": pairing.device_name(),
|
||||
"httpPort": http_port, "apiVersion": 1, "paired": pairing.is_paired(client_id)}
|
||||
return DISCOVERY_PREFIX + json.dumps(body, separators=(",", ":")).encode()
|
||||
|
||||
|
||||
def discovery_loop(http_port=8766, udp_port=DISCOVERY_PORT):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("0.0.0.0", udp_port))
|
||||
while True:
|
||||
try:
|
||||
packet, sender = sock.recvfrom(1024)
|
||||
reply = discovery_response(packet, http_port)
|
||||
if reply:
|
||||
sock.sendto(reply, sender)
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def sample_loop(server):
|
||||
sm = messaging.SubMaster(SERVICES)
|
||||
while True:
|
||||
@@ -937,11 +969,12 @@ def main():
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Bind address (default: all interfaces for iPad access)")
|
||||
parser.add_argument("--port", type=int, default=8766)
|
||||
parser.add_argument("--allow-origin", help="Optional exact origin for a separately hosted WUI")
|
||||
parser.add_argument("--llm-upstream", default=os.environ.get("SUNNYDRIVE_LLM", ""), help="LM Studio URL reachable from the comma (default: the first tailnet device answering on :1234)")
|
||||
parser.add_argument("--llm-upstream", default=os.environ.get("SUNNYDRIVE_LLM", ""), help="Optional OpenAI-compatible model server URL")
|
||||
args = parser.parse_args()
|
||||
os.nice(10)
|
||||
server = SunnydriveServer((args.host, args.port), args.allow_origin, args.llm_upstream)
|
||||
threading.Thread(target=sample_loop, args=(server,), daemon=True).start()
|
||||
threading.Thread(target=discovery_loop, args=(args.port,), daemon=True, name="sunnydrive-discovery").start()
|
||||
print(f"Sunnydrive API: http://{args.host}:{args.port}/", flush=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
@@ -8,10 +8,56 @@ from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from openpilot.sunnypilot.sunnydrive import sunnydrived
|
||||
from openpilot.sunnypilot.sunnydrive import pairing
|
||||
from openpilot.sunnypilot.sunnydrive.sunnydrived import SunnydriveServer
|
||||
|
||||
|
||||
class SunnydriveApiTest(unittest.TestCase):
|
||||
class FakeParams:
|
||||
def __init__(self):
|
||||
self.values = {"DongleId": "comma-test-12345678"}
|
||||
|
||||
def get(self, key):
|
||||
return self.values.get(key)
|
||||
|
||||
def put(self, key, value, block=False):
|
||||
self.values[key] = value
|
||||
|
||||
def remove(self, key):
|
||||
self.values.pop(key, None)
|
||||
|
||||
def test_pairing_methods_share_one_credential(self):
|
||||
params = self.FakeParams()
|
||||
client = "phone_1234567890123456"
|
||||
window = pairing.open_window(params)
|
||||
token = pairing.complete_pairing(client, "Phone", window["code"], params)
|
||||
self.assertTrue(pairing.authorized(token, params))
|
||||
self.assertFalse(pairing.authorized(token + "x", params))
|
||||
|
||||
request = pairing.request_pairing("phone_abcdefghijklmnop", "Second phone", params)
|
||||
self.assertTrue(pairing.approve_request(request, params))
|
||||
approved = pairing.consume_request(request, params)
|
||||
self.assertEqual(approved["status"], "approved")
|
||||
self.assertTrue(pairing.authorized(approved["token"], params))
|
||||
self.assertEqual(pairing.consume_request(request, params)["status"], "expired")
|
||||
|
||||
locked = pairing.open_window(params)
|
||||
for _ in range(5):
|
||||
with self.assertRaises(PermissionError):
|
||||
pairing.complete_pairing("phone_locked_123456789", "Locked", "wrong", params)
|
||||
with self.assertRaises(PermissionError):
|
||||
pairing.complete_pairing("phone_locked_123456789", "Locked", locked["code"], params)
|
||||
|
||||
def test_discovery_is_metadata_only(self):
|
||||
packet = sunnydrived.DISCOVERY_PREFIX + b'{"v":1,"nonce":"12345678","clientId":"phone_1234567890123456"}'
|
||||
with patch.object(pairing, "device_id", return_value="comma-id"), \
|
||||
patch.object(pairing, "device_name", return_value="comma test"), \
|
||||
patch.object(pairing, "is_paired", return_value=False):
|
||||
reply = sunnydrived.discovery_response(packet)
|
||||
body = json.loads(reply[len(sunnydrived.DISCOVERY_PREFIX):])
|
||||
self.assertEqual(set(body), {"v", "nonce", "deviceId", "name", "httpPort", "apiVersion", "paired"})
|
||||
self.assertNotIn("token", body)
|
||||
|
||||
def test_settings_follow_sunnylink_safety_rules(self):
|
||||
with patch("openpilot.common.params.Params") as params_class, patch.object(sunnydrived, "_setting_engaged", return_value=False) as engaged:
|
||||
params_class.return_value.get_bool.return_value = False
|
||||
@@ -68,6 +114,29 @@ class SunnydriveApiTest(unittest.TestCase):
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_unpaired_phone_cannot_read_api(self):
|
||||
server = SunnydriveServer(("127.0.0.1", 0), allow_loopback=False)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
base = f"http://127.0.0.1:{server.server_address[1]}"
|
||||
with patch.object(pairing, "authorized", return_value=False):
|
||||
with self.assertRaises(HTTPError) as error:
|
||||
urlopen(base + "/telemetry", timeout=2)
|
||||
self.assertEqual(error.exception.code, 401)
|
||||
with patch.object(pairing, "device_id", return_value="comma-id"), \
|
||||
patch.object(pairing, "device_name", return_value="comma test"), \
|
||||
patch.object(pairing, "is_paired", return_value=False):
|
||||
with urlopen(base + "/pair/info?client_id=phone_1234567890123456", timeout=2) as response:
|
||||
self.assertEqual(json.load(response)["deviceId"], "comma-id")
|
||||
with patch.object(pairing, "authorized", return_value=True):
|
||||
with urlopen(base + "/telemetry?auth=paired", timeout=2) as response:
|
||||
self.assertIn("timestampMs", json.load(response))
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user