Add local Sunnydrive discovery and pairing

This commit is contained in:
royjr
2026-09-29 13:42:37 -04:00
parent fd303f8130
commit c0ddec0862
6 changed files with 385 additions and 59 deletions
+3
View File
@@ -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))
+157
View File
@@ -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
+90 -57
View File
@@ -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()