diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index daeb928a84..5f935dd815 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -227,6 +227,11 @@ inline static std::unordered_map keys = { {"SunnylinkEnabled", {PERSISTENT, BOOL, "1"}}, {"SunnylinkTempFault", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL, "0"}}, + {"SunnylinkLocalApps", {PERSISTENT, JSON}}, + {"SunnylinkLocalPairingCode", {CLEAR_ON_MANAGER_START, JSON}}, + {"SunnylinkLocalDiscoveredApp", {CLEAR_ON_MANAGER_START, JSON}}, + {"SunnylinkLocalPairingRequest", {CLEAR_ON_MANAGER_START, BOOL}}, + // Backup Manager params {"BackupManager_CreateBackup", {PERSISTENT, BOOL}}, {"BackupManager_RestoreVersion", {PERSISTENT, STRING}}, diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py index 5664ab8286..c94e3ed2d2 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py @@ -5,22 +5,42 @@ This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ import pyray as rl +from functools import partial from openpilot.cereal import custom +from openpilot.common.version import sunnylink_consent_version from openpilot.selfdrive.ui.sunnypilot.layouts.onboarding import SunnylinkConsentPage from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID +from openpilot.sunnypilot.sunnylink.athena.local_discovery import latest_discovered_app +from openpilot.sunnypilot.sunnylink.athena.local_pairing import ( + LocalApp, + arm_pairing, + clear_pairing_request, + get_local_apps, + local_app_display_name, + pairing_requested, + read_pairing_code, + remove_local_app, +) from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp -from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP, button_item_sp, toggle_item_sp from openpilot.system.ui.sunnypilot.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog from openpilot.system.ui.widgets import Widget, DialogResult -from openpilot.system.ui.widgets.button import ButtonStyle, Button +from openpilot.system.ui.widgets.button import ButtonStyle, Button, IconButton from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.list_view import dual_button_item +from openpilot.system.ui.widgets.network import NavButton from openpilot.system.ui.widgets.scroller_tici import Scroller, LineSeparator -from openpilot.common.version import sunnylink_consent_version + +MAX_LOCAL_APPS = 4 + +# Read-only value colors used by the local-mode rows. +_LOCAL_DISCOVERED_COLOR = rl.Color(170, 170, 170, 255) # grey: no app in sight +_LOCAL_ACTIVE_COLOR = rl.Color(0, 255, 0, 255) # green: discovered / pairing code class SunnylinkHeader(Widget): @@ -192,6 +212,15 @@ class SunnylinkLayout(Widget): self._backup_btn.set_button_style(ButtonStyle.NORMAL) self._restore_btn.set_button_style(ButtonStyle.PRIMARY) + self._mobile_app_btn = button_item_sp( + title=tr("Sunnylink Local Connections"), + button_text=tr("CONFIGURE"), + description=tr("Manage the mobile app(s) connected over Wi-Fi: pair a new app ") + + tr("or unpair existing ones."), + callback=self._open_local_apps, + ) + self._mobile_app_btn.set_visible(lambda: self._sunnylink_enabled) + items = [ SunnylinkHeader(), LineSeparator(), @@ -202,9 +231,11 @@ class SunnylinkLayout(Widget): LineSeparator(), self._pair_btn, LineSeparator(), + self._mobile_app_btn, + LineSeparator(), self._sunnylink_uploader_toggle, LineSeparator(), - self._sunnylink_backup_restore_buttons + self._sunnylink_backup_restore_buttons, ] return items @@ -317,6 +348,8 @@ class SunnylinkLayout(Widget): gui_app.push_widget(sl_terms_dlg) else: ui_state.params.put_bool("SunnylinkEnabled", state) + if not state: + clear_pairing_request() self._update_description(state) def _update_description(self, state: bool): @@ -352,6 +385,9 @@ class SunnylinkLayout(Widget): self._pair_btn.action_item.set_text(pair_btn_text) self._pair_btn.action_item.set_enabled(self._sunnylink_enabled) + def _open_local_apps(self): + gui_app.push_widget(SunnylinkLocalAppLayout()) + def _render(self, rect): self._scroller.render(rect) @@ -364,3 +400,157 @@ class SunnylinkLayout(Widget): def hide_event(self): super().hide_event() ui_state.sunnylink_state.set_settings_open(False) + + +class SunnylinkLocalAppLayout(Widget): + + def __init__(self): + super().__init__() + self._local_apps_cache: list[LocalApp] = [] + + self._back_button = NavButton(tr("Back")) + self._back_button.set_click_callback(gui_app.pop_widget) + + self._pair_app_btn = button_item_sp( + title=tr("Pair App"), + button_text=tr("PAIR"), + description=tr("Open a 5-minute pairing window and show the code to ") + + tr("type into the app. Closing the dialog cancels pairing."), + callback=self._show_pairing_code_dialog, + ) + + self._local_app_rows: list[ListItemSP] = [] + self._local_app_seps: list[LineSeparator] = [] + for i in range(MAX_LOCAL_APPS): + row = button_item_sp( + title=lambda i=i: self._local_app_title(i), + button_text=tr("UNPAIR"), + description=lambda i=i: self._local_app_endpoint(i), + callback=partial(self._unpair_local_app, i), + ) + sep = LineSeparator() + row.set_visible(lambda i=i: self._local_row_visible(i)) + sep.set_visible(lambda i=i: self._local_row_visible(i)) + self._local_app_rows.append(row) + self._local_app_seps.append(sep) + + items = [self._pair_app_btn, LineSeparator()] + for row, sep in zip(self._local_app_rows, self._local_app_seps, strict=True): + items.extend((row, sep)) + self._scroller = Scroller(items, line_separator=False, spacing=0) + + def _local_row_visible(self, i: int) -> bool: + return i < len(self._local_apps_cache) + + def _local_app_title(self, i: int) -> str: + if i >= len(self._local_apps_cache): + return "" + return local_app_display_name(self._local_apps_cache[i]) + + def _local_app_endpoint(self, i: int) -> str: + if i >= len(self._local_apps_cache): + return "" + return self._local_apps_cache[i].endpoint + + def _show_pairing_code_dialog(self): + gui_app.push_widget(SunnylinkLocalPairingDialog()) + + def _unpair_local_app(self, index: int): + apps = self._local_apps_cache + if index >= len(apps): + return + app = apps[index] + name = local_app_display_name(app) + + def on_confirm(_dialog_result: int): + remove_local_app(app.app_id) + + dialog = ConfirmDialog( + text=tr("Unpair") + f" {name}? " + tr("You will need the pairing code again to reconnect it."), + confirm_text=tr("Unpair"), + callback=on_confirm, + ) + gui_app.push_widget(dialog) + + def _update_state(self): + super()._update_state() + self._local_apps_cache = get_local_apps() + + def _render(self, rect): + self._back_button.set_position(self._rect.x, self._rect.y + 20) + self._back_button.render() + content_rect = rl.Rectangle(rect.x, rect.y + self._back_button.rect.height + 40, + rect.width, rect.height - self._back_button.rect.height - 40) + self._scroller.render(content_rect) + + def show_event(self): + super().show_event() + self._scroller.show_event() + + def hide_event(self): + super().hide_event() + self._scroller.hide_event() + + +class SunnylinkLocalPairingDialog(Widget): + + def __init__(self): + super().__init__() + self._apps_before = len(get_local_apps()) + arm_pairing() + self._close_btn = IconButton(gui_app.texture("icons/close.png", 80, 80)) + self._close_btn.set_click_callback(self._cancel) + + def _cancel(self): + clear_pairing_request() + gui_app.pop_widget() + + def _update_state(self): + if len(get_local_apps()) > self._apps_before: + gui_app.pop_widget() # paired — window already cleared + elif not pairing_requested(): + gui_app.pop_widget() # window expired + + def _render(self, rect) -> int: + rl.clear_background(rl.Color(224, 224, 224, 255)) + + margin = 70 + content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, + rect.width - 2 * margin, rect.height - 2 * margin) + y = content_rect.y + + close_size = 80 + pad = 20 + close_rect = rl.Rectangle(content_rect.x - pad, y - pad, close_size + pad * 2, close_size + pad * 2) + self._close_btn.render(close_rect) + y += close_size + 40 + + title_font = gui_app.font(FontWeight.NORMAL) + title_wrapped = wrap_text(title_font, tr("Pair with mobile app"), 75, int(content_rect.width)) + rl.draw_text_ex(title_font, "\n".join(title_wrapped), rl.Vector2(content_rect.x, y), 75, 0.0, rl.BLACK) + y += len(title_wrapped) * 75 + 40 + + code = read_pairing_code() or "—" + code_font = gui_app.font(FontWeight.BOLD) + code_size = measure_text_cached(code_font, code, 110) + rl.draw_text_ex(code_font, code, rl.Vector2(content_rect.x + (content_rect.width - code_size.x) / 2, y), + 110, 0.0, rl.BLACK) + y += 170 + + hint_font = gui_app.font(FontWeight.NORMAL) + hint_wrapped = wrap_text(hint_font, tr("Enter this code in the sunnylink app on your phone."), 45, + int(content_rect.width)) + rl.draw_text_ex(hint_font, "\n".join(hint_wrapped), rl.Vector2(content_rect.x, y), 45, 0.0, rl.BLACK) + y += len(hint_wrapped) * 45 + 30 + + discovered = latest_discovered_app() + if discovered is not None: + endpoint, age = discovered + status = endpoint if age < 2 else f"{endpoint} ({age}s)" + color = _LOCAL_ACTIVE_COLOR + else: + status = tr("Waiting for the app…") + color = _LOCAL_DISCOVERED_COLOR + status_font = gui_app.font(FontWeight.NORMAL) + rl.draw_text_ex(status_font, status, rl.Vector2(content_rect.x, y), 40, 0.0, color) + return -1 diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py index 7c42f99f83..c7663d835a 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py @@ -5,21 +5,34 @@ This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ import pyray as rl - +from functools import partial from openpilot.cereal import custom +from openpilot.common.version import sunnylink_consent_version, sunnylink_consent_declined from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle -from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigDialogBase from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage from openpilot.selfdrive.ui.sunnypilot.mici.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID +from openpilot.sunnypilot.sunnylink.athena.local_discovery import latest_discovered_app +from openpilot.sunnypilot.sunnylink.athena.local_pairing import ( + LocalApp, + arm_pairing, + clear_pairing_request, + get_local_apps, + local_app_display_name, + pairing_requested, + read_pairing_code, + remove_local_app, +) from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.common.version import sunnylink_consent_version, sunnylink_consent_declined + +MAX_LOCAL_APPS = 4 class SunnylinkInfo(Widget): def __init__(self): @@ -73,11 +86,15 @@ class SunnylinkLayoutMici(NavScroller): self._sunnylink_uploader_toggle = BigToggle(text=tr("sunnylink uploader"), initial_state=False, toggle_callback=self._sunnylink_uploader_callback) + self._mobile_app_btn = BigButton(tr("sunnylink local"), "") + self._mobile_app_btn.set_click_callback(lambda: gui_app.push_widget(LocalAppsPanelMici())) + self._scroller.add_widgets([ self._sunnylink_info, self._sunnylink_toggle, self._sunnylink_sponsor_button, self._sunnylink_pair_button, + self._mobile_app_btn, self._backup_btn, self._restore_btn, self._sunnylink_uploader_toggle @@ -110,6 +127,7 @@ class SunnylinkLayoutMici(NavScroller): self._sunnylink_pair_button.set_text(tr("paired")) else: self._sunnylink_pair_button.set_text(tr("pair")) + self._mobile_app_btn.set_visible(self._sunnylink_enabled) def show_event(self): super().show_event() @@ -140,6 +158,8 @@ class SunnylinkLayoutMici(NavScroller): gui_app.push_widget(sl_terms_dlg) else: ui_state.params.put_bool("SunnylinkEnabled", state) + if not state: + clear_pairing_request() ui_state.update_params() @@ -252,3 +272,108 @@ class SunnylinkPairBigButton(BigButton): dlg = SunnylinkPairingDialog(sponsor_pairing=False) if dlg: gui_app.push_widget(dlg) + + +class LocalAppsPanelMici(NavScroller): + + def __init__(self): + super().__init__() + self._local_apps_cache: list[LocalApp] = [] + + self._pair_app_btn = BigButton(tr("pair app"), "") + self._pair_app_btn.set_click_callback(lambda: gui_app.push_widget(LocalPairingCodeDialogMici())) + + self._local_app_btns: list[BigButton] = [] + for i in range(MAX_LOCAL_APPS): + btn = BigButton("", "") + btn.set_click_callback(partial(self._confirm_unpair_local_app, i)) + self._local_app_btns.append(btn) + + self._scroller.add_widgets([self._pair_app_btn, *self._local_app_btns]) + + def _update_state(self): + super()._update_state() + self._local_apps_cache = get_local_apps() + for i, btn in enumerate(self._local_app_btns): + btn.set_visible(i < len(self._local_apps_cache)) + if i < len(self._local_apps_cache): + app = self._local_apps_cache[i] + btn.set_text(local_app_display_name(app)) + btn.set_value(app.endpoint) + + def _confirm_unpair_local_app(self, index: int): + apps = self._local_apps_cache + if index >= len(apps): + return + app = apps[index] + + def unpair(): + remove_local_app(app.app_id) + + icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64) + dlg = BigConfirmationDialog( + tr("slide to unpair"), + icon, + confirm_callback=unpair, + red=True, + ) + gui_app.push_widget(dlg) + + +class LocalPairingCodeDialogMici(BigDialogBase): + + def __init__(self): + super().__init__() + self._apps_before = len(get_local_apps()) + arm_pairing() + self.set_back_callback(clear_pairing_request) + + header_color = rl.Color(255, 255, 255, int(255 * 0.9)) + subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + self._title = UnifiedLabel(tr("pair with mobile app"), font_size=48, font_weight=FontWeight.BOLD, + text_color=header_color, line_height=0.8) + self._code_label = UnifiedLabel("", font_size=110, font_weight=FontWeight.DISPLAY, + text_color=rl.Color(0, 255, 0, 255)) + self._hint = UnifiedLabel(tr("enter this code in the sunnylink app"), font_size=32, + text_color=subheader_color, line_height=0.9) + self._status = UnifiedLabel("", font_size=28, + text_color=rl.Color(255, 255, 255, int(255 * 0.45)), line_height=0.9) + + def _update_state(self): + super()._update_state() + if self.is_dismissing: + return + if len(get_local_apps()) > self._apps_before: + self.dismiss() # paired — window already cleared + elif not pairing_requested(): + self.dismiss() # window expired + + def _render(self, _): + self._code_label.set_text(read_pairing_code() or "—") + + discovered = latest_discovered_app() + if discovered is not None: + endpoint, age = discovered + self._status.set_text(endpoint if age < 2 else f"{endpoint} ({age}s)") + self._status.set_text_color(rl.Color(0, 255, 0, 255)) + else: + self._status.set_text(tr("waiting for the app…")) + self._status.set_text_color(rl.Color(255, 255, 255, int(255 * 0.45))) + + x = self._rect.x + 20 + width = int(self._rect.width - 40) + self._title.set_max_width(width) + self._title.set_position(x, self._rect.y + 40) + self._title.render() + + self._code_label.set_max_width(width) + self._code_label.set_position(x, self._rect.y + 130) + self._code_label.render() + + self._hint.set_max_width(width) + self._hint.set_position(x, self._rect.y + 290) + self._hint.render() + + self._status.set_max_width(width) + self._status.set_position(x, self._rect.y + 360) + self._status.render() diff --git a/openpilot/sunnypilot/sunnylink/athena/local_discovery.py b/openpilot/sunnypilot/sunnylink/athena/local_discovery.py new file mode 100644 index 0000000000..691e5316ce --- /dev/null +++ b/openpilot/sunnypilot/sunnylink/athena/local_discovery.py @@ -0,0 +1,261 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from __future__ import annotations + +import json +import socket +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass + +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog + +from openpilot.sunnypilot.sunnylink.athena.local_pairing import ( + BEACON_PREFIX, + DISCOVERED_APP_KEY, + SUNNYLINK_LOCAL_UDP_PORT, + format_endpoint, + get_local_apps, + pairing_requested, + update_local_app_endpoint, +) + +LOCAL_BEACON_FRESH_S = 30 + +@dataclass +class AppBeacon: + """A parsed app beacon — the app announcing it is acting as the local backend.""" + app_id: str + ws_port: int + source_ip: str + + @property + def endpoint(self) -> str: + return format_endpoint(self.source_ip, self.ws_port) + + +def parse_beacon(raw: str | bytes, source_ip: str = "") -> AppBeacon | None: + """ + Parse one UDP beacon line from the app. + + Wire format: `SUNNYLINK1 {"v":1,"role":"app","app_id":"","ws_port":8443}` + Returns None for anything else. Beacons carry ids + addresses only — no secrets. + """ + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + raw = raw.strip() + if not raw.startswith(BEACON_PREFIX + " "): + return None + try: + data = json.loads(raw[len(BEACON_PREFIX) + 1:]) + except ValueError: + return None + if not isinstance(data, dict): + return None + if data.get("role") != "app" or data.get("v") != 1: + return None + app_id = data.get("app_id") + ws_port = data.get("ws_port") + if not isinstance(app_id, str) or not app_id: + return None + if not isinstance(ws_port, int) or not (0 < ws_port <= 65535): + return None + return AppBeacon(app_id=app_id, ws_port=ws_port, source_ip=source_ip) + + +class LocalDiscovery(threading.Thread): + """ + Passive UDP listener + + - While a pairing window is armed: track the freshest app beacon so the + daemon can offer pairing to a NEW app, and mirror it into a status param + for the settings UI. + - Independently of any window: a beacon from an app ALREADY in the paired + registry refreshes its cached endpoint — IPs are not identity, the app can + move between networks. + """ + + def __init__(self, params: Params | None = None, port: int = SUNNYLINK_LOCAL_UDP_PORT, + sock: socket.socket | None = None, write_interval_s: float = 5.0, + paired_refresh_cb: Callable[[AppBeacon], None] | None = None): + super().__init__(name="local_discovery_listener", daemon=True) + self.params = params or Params() + self.port = port + self._sock = sock + self.paired_refresh_cb = paired_refresh_cb + self._latest_endpoint: str | None = None + self._latest_app_id: str | None = None + self._last_seen_monotonic: float = 0.0 + self._latest_paired_endpoint: str | None = None + self._latest_paired_app_id: str | None = None + self._last_paired_seen_monotonic: float = 0.0 + self._lock = threading.Lock() + self._stop_event = threading.Event() + self.write_interval_s = write_interval_s + self._last_write_monotonic = 0.0 + self._last_written_endpoint: str | None = None + self._last_written_app_id: str | None = None + self._discovered_cleared = False + + def stop(self) -> None: + self._stop_event.set() + if self._sock is not None: + try: + self._sock.close() + except OSError: + pass + + def latest_endpoint(self) -> str | None: + """The most recently announced app endpoint (None outside a pairing window).""" + with self._lock: + return self._latest_endpoint + + def latest_app_id(self) -> str | None: + """The app_id of the most recently announced beacon (None outside a window).""" + with self._lock: + return self._latest_app_id + + def last_seen_ago(self) -> float | None: + """Seconds since the last app beacon was heard (None when none heard yet).""" + with self._lock: + if self._last_seen_monotonic == 0.0: + return None + return time.monotonic() - self._last_seen_monotonic + + def latest_paired_endpoint(self) -> str | None: + """The freshest beacon endpoint announced by an ALREADY-PAIRED.""" + with self._lock: + return self._latest_paired_endpoint + + def latest_paired_app_id(self) -> str | None: + """The app_id of the freshest paired-app beacon (None until one is heard).""" + with self._lock: + return self._latest_paired_app_id + + def latest_paired_seen_ago(self) -> float | None: + """Seconds since the freshest paired-app beacon was heard (None when none).""" + with self._lock: + if self._last_paired_seen_monotonic == 0.0: + return None + return time.monotonic() - self._last_paired_seen_monotonic + + def _handle(self, raw: bytes, source_ip: str) -> None: + beacon = parse_beacon(raw, source_ip) + if beacon is None: + return + if pairing_requested(self.params): + with self._lock: + self._latest_endpoint = beacon.endpoint + self._latest_app_id = beacon.app_id + self._last_seen_monotonic = time.monotonic() + self._write_discovered_param(beacon) + cloudlog.debug(f"local_discovery.app_found {beacon.app_id} at {beacon.endpoint}") + else: + with self._lock: + self._latest_endpoint = None + self._latest_app_id = None + self._last_seen_monotonic = 0.0 + self._clear_discovered_param() + self._maybe_refresh_paired_app(beacon) + + def _maybe_refresh_paired_app(self, beacon: AppBeacon) -> None: + """Refresh a paired app's registry endpoint from its beacon.""" + + if not any(app.app_id == beacon.app_id for app in get_local_apps(self.params)): + return + with self._lock: + self._latest_paired_endpoint = beacon.endpoint + self._latest_paired_app_id = beacon.app_id + self._last_paired_seen_monotonic = time.monotonic() + if update_local_app_endpoint(beacon.app_id, beacon.endpoint, self.params): + cloudlog.debug(f"local_discovery.paired_refresh {beacon.app_id} -> {beacon.endpoint}") + if self.paired_refresh_cb is not None: + try: + self.paired_refresh_cb(beacon) + except Exception: + cloudlog.exception("local_discovery.paired_refresh_cb.exception") + + def _clear_discovered_param(self) -> None: + if self._discovered_cleared: + return + self._discovered_cleared = True + try: + self.params.remove(DISCOVERED_APP_KEY) + except Exception: + cloudlog.exception("local_discovery.param_clear.exception") + + def _write_discovered_param(self, beacon: AppBeacon) -> None: + """Mirror the freshest beacon into a param the settings UI can read.""" + now = time.monotonic() + changed = beacon.endpoint != self._last_written_endpoint or beacon.app_id != self._last_written_app_id + if not changed and now - self._last_write_monotonic < self.write_interval_s: + return + self._last_write_monotonic = now + self._last_written_endpoint = beacon.endpoint + self._last_written_app_id = beacon.app_id + self._discovered_cleared = False + payload = { + "endpoint": beacon.endpoint, + "app_id": beacon.app_id, + "ts": int(time.monotonic()), + } + try: + self.params.put(DISCOVERED_APP_KEY, payload, block=True) + except Exception: + cloudlog.exception("local_discovery.param_write.exception") + + def _bind(self) -> socket.socket: + if self._sock is not None: + return self._sock + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("0.0.0.0", self.port)) + sock.settimeout(0.5) + return sock + + def run(self) -> None: + sock = self._bind() + try: + while not self._stop_event.is_set(): + try: + data, addr = sock.recvfrom(4096) + self._handle(data, addr[0] if len(addr) > 0 else "") + except TimeoutError: + continue + except OSError: + # Socket closed by stop() — exit quietly. + if self._stop_event.is_set(): + break + cloudlog.exception("local_discovery.recv.exception") + break + finally: + try: + sock.close() + except OSError: + pass + + +def latest_discovered_app(params: Params | None = None, + fresh_s: float = LOCAL_BEACON_FRESH_S) -> tuple[str, int] | None: + params = params or Params() + data = params.get(DISCOVERED_APP_KEY) + if not isinstance(data, dict): + return None + endpoint = str(data.get("endpoint", "")) + try: + ts = int(data.get("ts") or 0) + except (ValueError, TypeError): + return None + if not endpoint or ts <= 0: + return None + age = time.monotonic() - ts + # Negative age = written before the last reboot (monotonic restarts at boot). + if age < 0 or age > fresh_s: + return None + return endpoint, max(0, int(age)) diff --git a/openpilot/sunnypilot/sunnylink/athena/local_pairing.py b/openpilot/sunnypilot/sunnylink/athena/local_pairing.py new file mode 100644 index 0000000000..d5d74a3e42 --- /dev/null +++ b/openpilot/sunnypilot/sunnylink/athena/local_pairing.py @@ -0,0 +1,257 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from __future__ import annotations + +import secrets +import threading +import time +from dataclasses import asdict, dataclass +from datetime import datetime, UTC +from typing import Any +from collections.abc import Callable + +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog + +SUNNYLINK_LOCAL_UDP_PORT = 53133 +SUNNYLINK_LOCAL_WS_PORT = 8443 + +LOCAL_APPS_KEY = "SunnylinkLocalApps" +PAIRING_CODE_KEY = "SunnylinkLocalPairingCode" +PAIRING_REQUEST_KEY = "SunnylinkLocalPairingRequest" +DISCOVERED_APP_KEY = "SunnylinkLocalDiscoveredApp" + +PAIRING_CODE_LENGTH = 6 +PAIRING_CODE_ALPHABET = "0123456789" +DEFAULT_CODE_ROTATION_S = 10 * 60 # re-roll the displayed code every 10 min +PAIRING_WINDOW_S = 5 * 60 + +BEACON_PREFIX = "SUNNYLINK1" + + +@dataclass +class LocalApp: + """One app paired with this device (the app runs the local "backend").""" + app_id: str + endpoint: str + app_name: str = "" + alias: str = "" + paired_at: int = 0 # epoch seconds + + @staticmethod + def from_dict(data: dict[str, Any]) -> LocalApp: + return LocalApp( + app_id=str(data.get("app_id", "")), + endpoint=str(data.get("endpoint", "")), + app_name=str(data.get("app_name", "")), + alias=str(data.get("alias", "")), + paired_at=int(data.get("paired_at") or 0), + ) + + +def local_app_display_name(app: LocalApp) -> str: + return app.alias or app.app_name or app.app_id + + +def is_locally_paired(params: Params | None = None) -> bool: + return len(get_local_apps(params)) > 0 + + +def get_local_apps(params: Params | None = None) -> list[LocalApp]: + """The paired-app registry (a JSON list persisted in `SunnylinkLocalApps`).""" + params = params or Params() + data = params.get(LOCAL_APPS_KEY) + if not isinstance(data, list): + return [] + return [LocalApp.from_dict(item) for item in data if isinstance(item, dict) and item.get("app_id")] + + +def _save_local_apps(apps: list[LocalApp], params: Params | None = None) -> None: + params = params or Params() + if apps: + params.put(LOCAL_APPS_KEY, [asdict(app) for app in apps], block=True) + else: + params.remove(LOCAL_APPS_KEY) + + +def add_local_app(app: LocalApp, params: Params | None = None) -> None: + """Append (or update by app_id) and persist.""" + if not app.paired_at: + app.paired_at = int(datetime.now(UTC).replace(tzinfo=None).timestamp()) + apps = [existing for existing in get_local_apps(params) if existing.app_id != app.app_id] + apps.append(app) + _save_local_apps(apps, params) + cloudlog.event("local_pairing.app_paired", app_id=app.app_id, endpoint=app.endpoint) + + +def update_local_app_endpoint(app_id: str, endpoint: str, params: Params | None = None) -> bool: + """Refresh a PAIRED app's cached endpoint from its beacon.""" + apps = get_local_apps(params) + for i, app in enumerate(apps): + if app.app_id != app_id or app.endpoint == endpoint: + continue + apps[i] = LocalApp(app_id=app.app_id, endpoint=endpoint, + app_name=app.app_name, alias=app.alias, paired_at=app.paired_at) + _save_local_apps(apps, params) + cloudlog.event("local_pairing.app_endpoint_refreshed", app_id=app_id, endpoint=endpoint) + return True + return False + + +def set_local_app_alias(app_id: str, alias: str, params: Params | None = None) -> bool: + apps = get_local_apps(params) + for i, app in enumerate(apps): + if app.app_id != app_id: + continue + if app.alias == alias: + return False + apps[i] = LocalApp(app_id=app.app_id, endpoint=app.endpoint, + app_name=app.app_name, alias=alias, paired_at=app.paired_at) + _save_local_apps(apps, params) + cloudlog.event("local_pairing.app_alias_updated", app_id=app_id, alias=alias) + return True + return False + + +def remove_local_app(app_id: str, params: Params | None = None) -> bool: + """Unpair an app by id. Returns True when an app was removed.""" + apps = get_local_apps(params) + remaining = [app for app in apps if app.app_id != app_id] + if len(remaining) == len(apps): + return False + _save_local_apps(remaining, params) + cloudlog.event("local_pairing.app_unpaired", app_id=app_id) + return True + + +def remove_all_local_apps(params: Params | None = None) -> None: + """Unpair every app.""" + _save_local_apps([], params) + cloudlog.event("local_pairing.all_apps_unpaired") + + +def generate_pairing_code() -> str: + """A 6-digit numeric pairing code.""" + return "".join(secrets.choice(PAIRING_CODE_ALPHABET) for _ in range(PAIRING_CODE_LENGTH)) + + +def _write_pairing_code(code: str, params: Params) -> None: + """Persist the code with its armed-at monotonic timestamp — the window is derived from it.""" + params.put(PAIRING_CODE_KEY, {"code": code, "ts": int(time.monotonic())}, block=True) + + +def read_pairing_code(params: Params | None = None) -> str | None: + """The stored pairing code, or None when cleared / not yet generated.""" + params = params or Params() + data = params.get(PAIRING_CODE_KEY) + if not isinstance(data, dict): + return None + code = data.get("code") + return str(code) if code else None + + +def get_pairing_code(params: Params | None = None) -> str: + """The displayed pairing code, generating and persisting one on first use.""" + params = params or Params() + code = read_pairing_code(params) + if code is None: + code = generate_pairing_code() + _write_pairing_code(code, params) + return code + + +def pairing_requested(params: Params | None = None) -> bool: + """True while the pairing window is armed and fresh. + + Self-expiring: if the code (which carries the armed-at timestamp) is missing + or older than PAIRING_WINDOW_S, the flag is dropped here.""" + params = params or Params() + if not params.get_bool(PAIRING_REQUEST_KEY): + return False + data = params.get(PAIRING_CODE_KEY) + ts = data.get("ts") if isinstance(data, dict) else None + if not isinstance(ts, (int, float)): + clear_pairing_request(params) + return False + age = time.monotonic() - ts + # Negative age = armed before the last reboot (monotonic restarts at boot). + if age < 0 or age > PAIRING_WINDOW_S: + clear_pairing_request(params) + return False + return True + + +def arm_pairing(params: Params | None = None) -> str: + """Arm a pairing window and return the code for the app. + + Rolls a fresh code first, then sets the flag, so pairing_requested never + sees an armed flag without a valid code.""" + params = params or Params() + code = generate_pairing_code() + _write_pairing_code(code, params) + params.put_bool(PAIRING_REQUEST_KEY, True, block=True) + return code + + +def clear_pairing_request(params: Params | None = None) -> None: + """Close the pairing window: drop the request flag and the code together.""" + params = params or Params() + params.remove(PAIRING_REQUEST_KEY) + params.remove(PAIRING_CODE_KEY) + + +def verify_pairing_code(code: str, params: Params | None = None) -> bool: + """Constant-time check of a code typed into the app against the displayed one.""" + params = params or Params() + current = read_pairing_code(params) + if current is None: + return False + return secrets.compare_digest(str(code).strip().upper(), current) + + +class PairingCodeRotator(threading.Thread): + """Re-roll the displayed code while a pairing window is armed; clear it + otherwise — the code is never generated outside a window.""" + + def __init__(self, params: Params | None = None, rotation_s: float = DEFAULT_CODE_ROTATION_S, + stop_event: threading.Event | None = None, tick_cb: Callable[[], None] | None = None): + super().__init__(name="local_pairing_code_rotator", daemon=True) + self.params = params or Params() + self.rotation_s = rotation_s + self.stop_event = stop_event or threading.Event() + # Test seam: invoked once per loop iteration after state is updated. + self.tick_cb = tick_cb + + def rotate(self) -> None: + """Re-roll the code while the window is armed, clear it otherwise.""" + if pairing_requested(self.params): + _write_pairing_code(generate_pairing_code(), self.params) + else: + self.params.remove(PAIRING_CODE_KEY) + + def run(self) -> None: + self.rotate() + while not self.stop_event.wait(self.rotation_s): + try: + self.rotate() + if self.tick_cb is not None: + self.tick_cb() + except Exception: + cloudlog.exception("local_pairing.code_rotator.exception") + + +def format_endpoint(host: str, ws_port: int = SUNNYLINK_LOCAL_WS_PORT) -> str: + return f"ws://{host}:{ws_port}" + + +def local_identity(params: Params | None = None) -> str: + """Identity claim on local connections. DongleId always exists on comma + hardware (SunnylinkDongleId is "UnregisteredDevice" until cloud + registration) and is what the app matches against the backend device list + to dedupe cloud + local entries.""" + params = params or Params() + return params.get("DongleId") or params.get("HardwareSerial") or "" diff --git a/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py b/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py index c31e4ac711..ea50610b8f 100755 --- a/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py +++ b/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -30,10 +30,26 @@ from websocket import (ABNF, WebSocket, WebSocketException, WebSocketTimeoutExce import openpilot.cereal.messaging as messaging from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL, DEFAULT_BIG_MODEL from openpilot.sunnypilot.selfdrive.car.sync_sunnylink_params import update_car_list_param +from openpilot.system.athena import rpc as rpc_module from openpilot.sunnypilot.sunnylink.api import SunnylinkApi from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, get_param_as_byte, save_param_from_base64_encoded_string from openpilot.sunnypilot.sunnylink.capabilities import generate_capabilities, CAPABILITY_LABELS from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import generate_schema +from openpilot.sunnypilot.sunnylink.athena.local_discovery import LOCAL_BEACON_FRESH_S, AppBeacon, LocalDiscovery +from openpilot.sunnypilot.sunnylink.athena.local_pairing import ( + PAIRING_WINDOW_S, + LocalApp, + PairingCodeRotator, + add_local_app, + clear_pairing_request, + get_local_apps, + is_locally_paired, + local_identity, + pairing_requested, + remove_local_app, + set_local_app_alias, + verify_pairing_code, +) SUNNYLINK_ATHENA_HOST = os.getenv('SUNNYLINK_ATHENA_HOST', 'wss://athena.sunnylink.ai') HANDLER_THREADS = int(os.getenv('HANDLER_THREADS', "4")) @@ -42,6 +58,15 @@ SUNNYLINK_LOG_ATTR_NAME = "user.sunny.upload" SUNNYLINK_RECONNECT_TIMEOUT_S = 70 # FYI changing this will also would require a change on sidebar.cc DISALLOW_LOG_UPLOAD = threading.Event() +LOCAL_PAIRING_SESSION_TIMEOUT_S = PAIRING_WINDOW_S +LOCAL_PROBE_INTERVAL_S = 60 +LOCAL_ENDPOINT_BACKOFF_S = 300 +PAIRING_WATCHDOG_INTERVAL_S = 2.0 + +_active_local_endpoint: str | None = None +_active_ws: WebSocket | None = None +_pairing_in_progress = threading.Event() + params = Params() # Parameters that should never be remotely modified @@ -266,44 +291,302 @@ def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local return start_local_proxy_shim(global_end_event, local_port, ws) +@dispatcher.add_method +def pairLocalApp(code: str, app_id: str = "", app_name: str = "", alias: str = "") -> dict[str, bool | str]: + """Complete pairing with the app on the CURRENT local connection.""" + if _active_local_endpoint is None: + return {"success": False, "error": "not connected to a local app"} + if not verify_pairing_code(code): + cloudlog.warning("sunnylinkd.pairLocalApp.invalid_code") + return {"success": False, "error": "invalid code"} + add_local_app(LocalApp(app_id=app_id or f"app@{_active_local_endpoint}", + endpoint=_active_local_endpoint, app_name=app_name, alias=alias)) + clear_pairing_request() + return {"success": True} + + +@dispatcher.add_method +def updateLocalAppAlias(app_id: str, alias: str) -> dict[str, bool | str]: + if _active_local_endpoint is None: + return {"success": False, "error": "not connected to a local app"} + updated = set_local_app_alias(app_id, alias) + return {"success": True, "updated": updated} + + +@dispatcher.add_method +def unpairLocalApp(app_id: str) -> dict[str, bool | str]: + if _active_local_endpoint is None: + return {"success": False, "error": "not connected to a local app"} + removed = remove_local_app(app_id) + return {"success": True, "removed": removed} + + +def _auth_header(is_local: bool) -> dict[str, str]: + """Bearer header for a dial.""" + api = SunnylinkApi(params.get("SunnylinkDongleId")) + payload = {"identity": local_identity()} if is_local else None + return {"Authorization": f"Bearer {api.get_token(payload_extra=payload)}"} + + +def _pairing_session(ws: WebSocket, timeout_s: float = LOCAL_PAIRING_SESSION_TIMEOUT_S) -> bool: + """Serve only the pairing RPCs to an app that isn't in the registry yet — + everything else is refused. Returns True if pairing completed (the connection may then + serve normally).""" + cloudlog.info("sunnylinkd.pairing_session.started") + ws.settimeout(10) + deadline = time.monotonic() + timeout_s + try: + while time.monotonic() < deadline and pairing_requested(): + try: + raw = ws.recv() # auto-pongs pings; blocks up to the socket timeout + except WebSocketTimeoutException: + continue + except Exception as e: + cloudlog.warning(f"sunnylinkd.pairing_session.{type(e).__name__}") + return is_locally_paired() + try: + msg = rpc_module.loads(raw) + except Exception: + continue + if not rpc_module.is_call(msg): + continue + if msg.get("method") not in ("pairLocalApp", "unpairLocalApp"): + continue # refuse anything but pairing until paired + try: + ws.send(rpc_module.handle(msg, dispatcher)) + except Exception as e: + cloudlog.warning(f"sunnylinkd.pairing_session.{type(e).__name__}") + return is_locally_paired() + return is_locally_paired() + finally: + ws.settimeout(SUNNYLINK_RECONNECT_TIMEOUT_S) + + +def _pick_ws_uri(discovery: LocalDiscovery, backoffs: dict[str, float]) -> tuple[str, str]: + now = time.monotonic() + apps = get_local_apps() + app_ids = {app.app_id for app in apps} + if pairing_requested(): + latest = discovery.latest_endpoint() + seen = discovery.last_seen_ago() + app_id = discovery.latest_app_id() + if latest is not None and seen is not None and seen <= LOCAL_BEACON_FRESH_S \ + and app_id is not None and app_id not in app_ids \ + and backoffs.get(latest, 0.0) <= now: + return latest, "pairing_offer" + return SUNNYLINK_ATHENA_HOST, "cloud" + fresh_endpoint = discovery.latest_paired_endpoint() + fresh_seen = discovery.latest_paired_seen_ago() + fresh_app_id = discovery.latest_paired_app_id() + fresh_ok = (fresh_endpoint is not None and fresh_seen is not None + and fresh_seen <= LOCAL_BEACON_FRESH_S and fresh_app_id is not None + and fresh_app_id in app_ids) + if fresh_ok and backoffs.get(fresh_endpoint, 0.0) <= now: + return fresh_endpoint, "paired_local" + for app in reversed(apps): + if fresh_ok and app.app_id == fresh_app_id: + continue + if backoffs.get(app.endpoint, 0.0) <= now: + return app.endpoint, "paired_local" + return SUNNYLINK_ATHENA_HOST, "cloud" + + +def _probe_local_apps(active_ws: WebSocket, discovery: LocalDiscovery, + backoffs: dict[str, float], stop_event: threading.Event) -> None: + while not stop_event.wait(LOCAL_PROBE_INTERVAL_S): + if pairing_requested(): + # The pairing watchdog owns an armed window; don't migrate mid-window. + continue + now = time.monotonic() + candidate: str | None = None + for app in reversed(get_local_apps()): + if backoffs.get(app.endpoint, 0.0) <= now: + candidate = app.endpoint + break + if candidate is None: + continue + try: + probe = create_connection(candidate, header=_auth_header(is_local=True), timeout=10) + probe.close() + except Exception: + backoffs[candidate] = now + LOCAL_ENDPOINT_BACKOFF_S + continue + cloudlog.event("sunnylinkd.local_probe.reachable", endpoint=candidate) + try: + active_ws.close() + except Exception: + pass + break + + +def _handle_paired_refresh(backoffs: dict[str, float], force_attempts: dict[str, float], + beacon: AppBeacon) -> None: + if not any(app.app_id == beacon.app_id for app in get_local_apps()): + return + for app in get_local_apps(): + if app.app_id == beacon.app_id: + backoffs.pop(app.endpoint, None) + backoffs.pop(beacon.endpoint, None) + if _pairing_in_progress.is_set(): + return + if _active_local_endpoint == beacon.endpoint: + return + if _active_local_endpoint is not None: + return + now = time.monotonic() + if force_attempts.get(beacon.endpoint, 0.0) + LOCAL_BEACON_FRESH_S > now: + return + force_attempts[beacon.endpoint] = now + ws = _active_ws + if ws is not None: + cloudlog.event("sunnylinkd.paired_refresh.reconnect", + app_id=beacon.app_id, endpoint=beacon.endpoint) + try: + ws.close() + except Exception: + pass + + +def _pairing_watchdog(active_ws: WebSocket, backoffs: dict[str, float], + stop_event: threading.Event, + interval_s: float = PAIRING_WATCHDOG_INTERVAL_S) -> None: + """Watch for the pairing window being armed mid-session and force a + re-selection to the newly-discovered app.""" + while not stop_event.wait(interval_s): + if not pairing_requested(): + continue + cloudlog.event("sunnylinkd.pairing_watchdog.arm_detected") + for key in list(backoffs): + backoffs.pop(key, None) + try: + active_ws.close() + except Exception: + pass + break + + def main(exit_event: threading.Event | None = None): try: set_core_affinity([0, 1, 2, 3]) except Exception: cloudlog.exception("failed to set core affinity") - while sunnylink_need_register(params): - cloudlog.info("Waiting for sunnylink registration to complete") - time.sleep(10) + discovery = LocalDiscovery() + code_rotator = PairingCodeRotator() + discovery.start() + code_rotator.start() + + try: + _connection_loop(exit_event, discovery) + finally: + discovery.stop() + code_rotator.stop_event.set() + + +def _serviceable(params: Params) -> bool: + """sunnylinkd should run when sunnylink is enabled and not on a temporary + fault. This deliberately includes the unregistered/unpaired state so a + never-registered device can still be discovered and paired over the LAN (the + actual session gates — registration/local pairing — are handled per + connection inside the loop).""" + return params.get_bool("SunnylinkEnabled") and not params.get_bool("SunnylinkTempFault") + + +def _connection_loop(exit_event: threading.Event | None, discovery: LocalDiscovery) -> None: + """Local-first, cloud-fallback connection loop: a paired local endpoint + first, cloud when unreachable, and a pairing session to a freshly-discovered + app when a window is armed.""" + global _active_local_endpoint, _active_ws - sunnylink_dongle_id = params.get("SunnylinkDongleId") - sunnylink_api = SunnylinkApi(sunnylink_dongle_id) UploadQueueCache.initialize(upload_queue) - update_car_list_param() - ws_uri = f"{SUNNYLINK_ATHENA_HOST}" conn_start = None conn_retries = 0 - while (exit_event is None or not exit_event.is_set()) and sunnylink_ready(params): - try: - if conn_start is None: - conn_start = time.monotonic() + backoffs: dict[str, float] = {} + force_attempts: dict[str, float] = {} + discovery.paired_refresh_cb = partial(_handle_paired_refresh, backoffs, force_attempts) - cloudlog.event("sunnylinkd.main.connecting_ws", ws_uri=ws_uri, retries=conn_retries) + while (exit_event is None or not exit_event.is_set()) and _serviceable(params): + ws_uri, kind = _pick_ws_uri(discovery, backoffs) + + if kind == "cloud" and pairing_requested(): + cloudlog.debug("sunnylinkd.main.pairing_waiting_for_beacon") + time.sleep(3) + continue + + if kind == "cloud" and sunnylink_need_register(params): + cloudlog.info("Waiting for sunnylink registration or local pairing to complete") + time.sleep(10) + continue + + if conn_start is None: + conn_start = time.monotonic() + + cloudlog.event("sunnylinkd.main.connecting_ws", ws_uri=ws_uri, kind=kind, retries=conn_retries) + try: ws = create_connection( ws_uri, - header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, + header=_auth_header(is_local=kind != "cloud"), enable_multithread=True, sslopt={"cert_reqs": ssl.CERT_NONE if "localhost" in ws_uri else ssl.CERT_REQUIRED}, timeout=SUNNYLINK_RECONNECT_TIMEOUT_S, ) - cloudlog.event("sunnylinkd.main.connected_ws", ws_uri=ws_uri, retries=conn_retries, - duration=time.monotonic() - conn_start) - conn_start = None + except Exception as e: + if kind != "cloud": + backoffs[ws_uri] = time.monotonic() + LOCAL_ENDPOINT_BACKOFF_S + conn_retries += 1 + params.remove("LastSunnylinkPingTime") + _log_connection_error(e) + time.sleep(backoff(conn_retries)) + continue - conn_retries = 0 - cur_upload_items.clear() + cloudlog.event("sunnylinkd.main.connected_ws", ws_uri=ws_uri, kind=kind, retries=conn_retries, + duration=time.monotonic() - conn_start) + conn_start = None + conn_retries = 0 + cur_upload_items.clear() + _active_ws = ws + + probe_stop: threading.Event | None = None + watch_stop: threading.Event | None = None + session_endpoint: str | None = ws_uri if kind != "cloud" else None + try: + if kind == "pairing_offer": + _active_local_endpoint = ws_uri + _pairing_in_progress.set() + try: + paired_ok = _pairing_session(ws) + finally: + _pairing_in_progress.clear() + if not paired_ok: + backoffs[ws_uri] = time.monotonic() + LOCAL_ENDPOINT_BACKOFF_S + conn_retries += 1 + params.remove("LastSunnylinkPingTime") + try: + ws.close() + except Exception: + pass + time.sleep(backoff(conn_retries)) + continue + # Paired during the session — this connection may now serve normally. + kind = "paired_local" + + if kind == "paired_local": + _active_local_endpoint = ws_uri + else: + _active_local_endpoint = None + # While on the cloud link, watch for the local app and migrate back. + probe_stop = threading.Event() + threading.Thread(target=_probe_local_apps, + args=(ws, discovery, backoffs, probe_stop), + name="sunnylinkd_local_probe", daemon=True).start() + + # Started after any pairing session on this connection, so it can never + # close the connection the code is typed over. + watch_stop = threading.Event() + threading.Thread(target=_pairing_watchdog, args=(ws, backoffs, watch_stop), + name="sunnylinkd_pairing_watchdog", daemon=True).start() handle_long_poll(ws, exit_event) except (KeyboardInterrupt, SystemExit): @@ -311,23 +594,37 @@ def main(exit_event: threading.Event | None = None): except Exception as e: conn_retries += 1 params.remove("LastSunnylinkPingTime") - - if isinstance(e, (ConnectionError, TimeoutError, WebSocketException)): - cloudlog.warning(f"sunnylinkd.main.{type(e).__name__}") - elif isinstance(e, OSError): - name = errno.errorcode.get(e.errno or -1, "UNKNOWN") - msg = f"sunnylinkd.main.OSError.{name} ({e.errno})" - is_expected_error = e.errno in (errno.ENETDOWN, errno.ENETRESET, errno.ENETUNREACH) - cloudlog.warning(msg) if is_expected_error else cloudlog.exception(msg) - else: - cloudlog.exception("sunnylinkd.main.exception") + _log_connection_error(e) + finally: + if probe_stop is not None: + probe_stop.set() + if watch_stop is not None: + watch_stop.set() + if session_endpoint is not None and kind == "paired_local": + backoffs.pop(session_endpoint, None) + if _active_local_endpoint == session_endpoint: + _active_local_endpoint = None + if _active_ws is ws: + _active_ws = None time.sleep(backoff(conn_retries)) - if not sunnylink_ready(params): - cloudlog.debug("Reached end of sunnylinkd.main while sunnylink is not ready. Waiting 60s before retrying") + if not _serviceable(params): + cloudlog.debug("Reached end of sunnylinkd.main while sunnylink is not serviceable. Waiting 60s before retrying") time.sleep(60) +def _log_connection_error(e: Exception) -> None: + if isinstance(e, (ConnectionError, TimeoutError, WebSocketException)): + cloudlog.warning(f"sunnylinkd.main.{type(e).__name__}") + elif isinstance(e, OSError): + name = errno.errorcode.get(e.errno or -1, "UNKNOWN") + msg = f"sunnylinkd.main.OSError.{name} ({e.errno})" + is_expected_error = e.errno in (errno.ENETDOWN, errno.ENETRESET, errno.ENETUNREACH) + cloudlog.warning(msg) if is_expected_error else cloudlog.exception(msg) + else: + cloudlog.exception("sunnylinkd.main.exception") + + if __name__ == "__main__": main() diff --git a/openpilot/sunnypilot/sunnylink/utils.py b/openpilot/sunnypilot/sunnylink/utils.py index 59b2d15c12..eb0f3496b0 100644 --- a/openpilot/sunnypilot/sunnylink/utils.py +++ b/openpilot/sunnypilot/sunnylink/utils.py @@ -2,6 +2,7 @@ import base64 import gzip import json from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID +from openpilot.sunnypilot.sunnylink.athena.local_pairing import is_locally_paired from openpilot.common.params import Params, ParamKeyType from openpilot.common.version import is_prebuilt @@ -16,10 +17,11 @@ def get_sunnylink_status(params=None) -> tuple[bool, bool, bool]: def sunnylink_ready(params=None) -> bool: - """Check if the device is ready to communicate with Sunnylink. That means it is enabled and registered.""" + """Enabled and (cloud-registered or locally paired), and not on a temporary + fault. Local pairing makes never-registered devices usable over the LAN.""" params = params or Params() is_sunnylink_enabled, is_registered, is_on_temporary_fault = get_sunnylink_status(params) - return is_sunnylink_enabled and is_registered and not is_on_temporary_fault + return is_sunnylink_enabled and (is_registered or is_locally_paired(params)) and not is_on_temporary_fault def use_sunnylink_uploader(params) -> bool: @@ -28,10 +30,11 @@ def use_sunnylink_uploader(params) -> bool: def sunnylink_need_register(params=None) -> bool: - """Check if the device needs to be registered with Sunnylink.""" + """Enabled, unregistered, and not locally paired — a locally paired device + works without cloud registration and must not be blocked.""" params = params or Params() is_sunnylink_enabled, is_registered, is_on_temporary_fault = get_sunnylink_status(params) - return is_sunnylink_enabled and not is_registered and not is_on_temporary_fault + return is_sunnylink_enabled and not is_registered and not is_locally_paired(params) and not is_on_temporary_fault def register_sunnylink():