From c0ddec0862c22e7c2d7789adbc830412cd75939d Mon Sep 17 00:00:00 2001 From: royjr Date: Tue, 29 Sep 2026 13:42:37 -0400 Subject: [PATCH] Add local Sunnydrive discovery and pairing --- openpilot/common/params_keys.h | 3 + .../ui/sunnypilot/mici/layouts/settings.py | 9 +- .../ui/sunnypilot/mici/layouts/sunnydrive.py | 59 +++++++ openpilot/sunnypilot/sunnydrive/pairing.py | 157 ++++++++++++++++++ .../sunnypilot/sunnydrive/sunnydrived.py | 147 +++++++++------- .../sunnypilot/sunnydrive/test_sunnydrived.py | 69 ++++++++ 6 files changed, 385 insertions(+), 59 deletions(-) create mode 100644 openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnydrive.py create mode 100644 openpilot/sunnypilot/sunnydrive/pairing.py diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 0bc0916610..9775b5bc2c 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -163,6 +163,9 @@ inline static std::unordered_map 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}}, diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py index 60a6f82abc..d6e97b5788 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py @@ -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) diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnydrive.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnydrive.py new file mode 100644 index 0000000000..271a837571 --- /dev/null +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnydrive.py @@ -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)) diff --git a/openpilot/sunnypilot/sunnydrive/pairing.py b/openpilot/sunnypilot/sunnydrive/pairing.py new file mode 100644 index 0000000000..0c6a4b7fb0 --- /dev/null +++ b/openpilot/sunnypilot/sunnydrive/pairing.py @@ -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 diff --git a/openpilot/sunnypilot/sunnydrive/sunnydrived.py b/openpilot/sunnypilot/sunnydrive/sunnydrived.py index 3b86f78c7f..6153bbd2f0 100644 --- a/openpilot/sunnypilot/sunnydrive/sunnydrived.py +++ b/openpilot/sunnypilot/sunnydrive/sunnydrived.py @@ -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() diff --git a/openpilot/sunnypilot/sunnydrive/test_sunnydrived.py b/openpilot/sunnypilot/sunnydrive/test_sunnydrived.py index dcd5d886b1..643c9acf53 100644 --- a/openpilot/sunnypilot/sunnydrive/test_sunnydrived.py +++ b/openpilot/sunnypilot/sunnydrive/test_sunnydrived.py @@ -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()