mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-11-04 06:04:15 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 048727fb9f | |||
| 0b68b8be9d | |||
| 88b3ed1a20 | |||
| 8b3a19eb04 | |||
| 367ff603d4 |
@@ -149,6 +149,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"Version", {PERSISTENT, STRING}},
|
||||
|
||||
// --- sunnypilot params --- //
|
||||
{"AccelBoostSP", {PERSISTENT | BACKUP, BOOL, "1"}},
|
||||
{"ApiCache_DriveStats", {PERSISTENT, JSON}},
|
||||
{"AutoLaneChangeBsmDelay", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"AutoLaneChangeTimer", {PERSISTENT | BACKUP, INT, "0"}},
|
||||
@@ -234,13 +235,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"SunnylinkEnabled", {PERSISTENT, BOOL, "1"}},
|
||||
{"SunnylinkTempFault", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL, "0"}},
|
||||
|
||||
{"SunnylinkLocalApps", {PERSISTENT, JSON}},
|
||||
{"SunnylinkLocalAppsMeta", {PERSISTENT, JSON}},
|
||||
{"SunnylinkLocalEnabled", {PERSISTENT, BOOL, "1"}},
|
||||
{"SunnylinkLocalRevoke", {CLEAR_ON_MANAGER_START, STRING}},
|
||||
{"SunnylinkLocalEnroll", {CLEAR_ON_MANAGER_START, STRING}},
|
||||
{"SunnylinkLocalCloudRevoke", {CLEAR_ON_MANAGER_START, STRING}},
|
||||
|
||||
// Backup Manager params
|
||||
{"BackupManager_CreateBackup", {PERSISTENT, BOOL}},
|
||||
{"BackupManager_RestoreVersion", {PERSISTENT, STRING}},
|
||||
|
||||
@@ -7,7 +7,6 @@ from openpilot.common.constants import CV
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.selfdrive.modeld.constants import ModelConstants
|
||||
from openpilot.selfdrive.controls.lib.accel_boost import AccelBoost
|
||||
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalMpc, LongitudinalPlanSource
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import T_IDXS as T_IDXS_MPC
|
||||
@@ -38,7 +37,6 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
LongitudinalPlannerSP.__init__(self, self.CP, CP_SP, self.mpc)
|
||||
self.fcw = False
|
||||
self.dt = dt
|
||||
self.accel_boost = AccelBoost()
|
||||
|
||||
self.v_desired_filter = FirstOrderFilter(init_v, 2.0, self.dt)
|
||||
self.a_cruise = init_a
|
||||
|
||||
@@ -22,6 +22,7 @@ from openpilot.common.transformations.orientation import rot_from_euler
|
||||
from enum import IntEnum
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.accel_boost_indicator import AccelBoostIndicator
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.hud_renderer import HudRendererSP as HudRenderer
|
||||
from openpilot.selfdrive.ui.sunnypilot.ui_state import OnroadTimerStatus
|
||||
|
||||
@@ -181,6 +182,7 @@ class AugmentedRoadView(CameraView):
|
||||
self._alert_renderer = AlertRenderer()
|
||||
self._driver_state_renderer = DriverStateRenderer()
|
||||
self._confidence_ball = ConfidenceBall()
|
||||
self._accel_boost_indicator = AccelBoostIndicator() if gui_app.sunnypilot_ui() else None
|
||||
self._offroad_label = UnifiedLabel("start the car to\nuse sunnypilot", 54, FontWeight.DISPLAY,
|
||||
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
|
||||
alignment=TextAlignment.CENTER,
|
||||
@@ -274,6 +276,11 @@ class AugmentedRoadView(CameraView):
|
||||
# Use self._content_rect for positioning within camera bounds
|
||||
if show_navigation:
|
||||
self._navigation.render(rl.Rectangle(self._content_rect.x + self._content_rect.width, self.rect.y, PANEL_WIDTH, self.rect.height))
|
||||
elif self._accel_boost_indicator is not None:
|
||||
# The boost bar owns the side panel while the feature is on; the ball only shows otherwise
|
||||
self._accel_boost_indicator.render(self.rect)
|
||||
if not self._accel_boost_indicator.panel_in_use:
|
||||
self._confidence_ball.render(self.rect)
|
||||
else:
|
||||
self._confidence_ball.render(self.rect)
|
||||
|
||||
|
||||
@@ -4,33 +4,24 @@ 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.
|
||||
"""
|
||||
import time
|
||||
|
||||
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_app_rows import MAX_LOCAL_APP_ROWS, LocalAppRow
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_auth_daemon import (
|
||||
app_rows,
|
||||
pending_enrollment,
|
||||
request_revoke,
|
||||
request_revoke_all,
|
||||
)
|
||||
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 ListItemSP, button_item_sp, toggle_item_sp
|
||||
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.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.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
|
||||
|
||||
|
||||
class SunnylinkHeader(Widget):
|
||||
def __init__(self):
|
||||
@@ -201,15 +192,6 @@ 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(),
|
||||
@@ -220,11 +202,9 @@ 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
|
||||
|
||||
@@ -372,9 +352,6 @@ 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)
|
||||
|
||||
@@ -387,138 +364,3 @@ class SunnylinkLayout(Widget):
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
ui_state.sunnylink_state.set_settings_open(False)
|
||||
|
||||
|
||||
class SunnylinkLocalAppLayout(Widget):
|
||||
|
||||
# How long a requested unpair may stay visible before the UI calls it stuck: the local
|
||||
# daemon's tick services requests about once a second, so anything older was never claimed.
|
||||
_REVOKE_STUCK_S = 4.0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._rows_cache: list[LocalAppRow] = []
|
||||
self._pending_revokes: dict[str, float] = {}
|
||||
self._pending_revoke_all: float | None = None
|
||||
|
||||
self._back_button = NavButton(tr("Back"))
|
||||
self._back_button.set_click_callback(gui_app.pop_widget)
|
||||
|
||||
self._local_connections_toggle = toggle_item_sp(
|
||||
title=tr("Enable local connections"),
|
||||
description=tr("Let the sunnylink mobile app connect to this device over Wi-Fi. ") +
|
||||
tr("Turn this off to stop the local daemon and keep sunnylink itself running."),
|
||||
param="SunnylinkLocalEnabled",
|
||||
)
|
||||
|
||||
self._revoke_all_btn = button_item_sp(
|
||||
title=tr("Unpair all phones"),
|
||||
button_text=tr("REVOKE ALL"),
|
||||
description=tr("Revoke every enrolled phone at once. Each phone must pair again ") +
|
||||
tr("from the sunnylink app to reconnect."),
|
||||
callback=self._revoke_all,
|
||||
)
|
||||
self._revoke_all_btn.set_visible(lambda: bool(self._rows_cache))
|
||||
|
||||
self._revoke_status = SunnylinkDescriptionItem()
|
||||
self._revoke_status.set_visible(False)
|
||||
|
||||
self._local_app_rows: list[ListItemSP] = []
|
||||
self._local_app_seps: list[LineSeparator] = []
|
||||
for i in range(MAX_LOCAL_APP_ROWS):
|
||||
row = button_item_sp(
|
||||
title=lambda i=i: self._row_title(i),
|
||||
button_text=tr("UNPAIR"),
|
||||
callback=partial(self._unpair_row, 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._local_connections_toggle, LineSeparator(),
|
||||
self._revoke_status, LineSeparator(), self._revoke_all_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._rows_cache)
|
||||
|
||||
def _row_title(self, i: int) -> str:
|
||||
return self._rows_cache[i].title if i < len(self._rows_cache) else ""
|
||||
|
||||
def _revoke_all(self):
|
||||
dialog = ConfirmDialog(
|
||||
text=tr("Revoke all phones?") + " " + tr("They will need to pair again from the app."),
|
||||
confirm_text=tr("Revoke all"),
|
||||
callback=self._on_revoke_all_confirmed,
|
||||
)
|
||||
gui_app.push_widget(dialog)
|
||||
|
||||
def _on_revoke_all_confirmed(self, result: int):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
self._pending_revoke_all = time.monotonic()
|
||||
request_revoke_all()
|
||||
|
||||
def _unpair_row(self, index: int):
|
||||
if index >= len(self._rows_cache):
|
||||
return
|
||||
row = self._rows_cache[index]
|
||||
dialog = ConfirmDialog(
|
||||
text=tr("Unpair") + f" {row.title}? " + tr("You will need to enroll this phone again from the app."),
|
||||
confirm_text=tr("Unpair"),
|
||||
callback=partial(self._on_unpair_confirmed, row.app_id),
|
||||
)
|
||||
gui_app.push_widget(dialog)
|
||||
|
||||
def _on_unpair_confirmed(self, app_id: str, result: int):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
self._pending_revokes[app_id] = time.monotonic()
|
||||
request_revoke(app_id)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._rows_cache = app_rows()
|
||||
self._update_revoke_status()
|
||||
|
||||
def _update_revoke_status(self) -> None:
|
||||
now = time.monotonic()
|
||||
present = {row.app_id for row in self._rows_cache}
|
||||
stuck = [app_id for app_id, asked in self._pending_revokes.items()
|
||||
if app_id in present and now - asked >= self._REVOKE_STUCK_S]
|
||||
self._pending_revokes = {a: t for a, t in self._pending_revokes.items() if a in present}
|
||||
revoke_all_stuck = (self._pending_revoke_all is not None and bool(present)
|
||||
and now - self._pending_revoke_all >= self._REVOKE_STUCK_S)
|
||||
if not present:
|
||||
self._pending_revoke_all = None
|
||||
pending = pending_enrollment()
|
||||
if stuck or revoke_all_stuck:
|
||||
self._revoke_status.set_color(rl.Color(255, 165, 0, 255))
|
||||
self._revoke_status.set_text(tr("Still paired — the local daemon may not be running."))
|
||||
self._revoke_status.set_visible(True)
|
||||
elif pending is not None:
|
||||
self._revoke_status.set_color(rl.WHITE)
|
||||
self._revoke_status.set_text(tr("Enrollment pending — waiting for the phone to connect…"))
|
||||
self._revoke_status.set_visible(True)
|
||||
else:
|
||||
self._revoke_status.set_visible(False)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -4,30 +4,22 @@ 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.
|
||||
"""
|
||||
import time
|
||||
|
||||
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.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_app_rows import MAX_LOCAL_APP_ROWS, LocalAppRow
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_auth_daemon import (
|
||||
app_rows,
|
||||
request_revoke,
|
||||
request_revoke_all,
|
||||
)
|
||||
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
|
||||
|
||||
class SunnylinkInfo(Widget):
|
||||
def __init__(self):
|
||||
@@ -81,15 +73,11 @@ 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
|
||||
@@ -123,8 +111,6 @@ class SunnylinkLayoutMici(NavScroller):
|
||||
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()
|
||||
ui_state.update_params()
|
||||
@@ -266,94 +252,3 @@ class SunnylinkPairBigButton(BigButton):
|
||||
dlg = SunnylinkPairingDialog(sponsor_pairing=False)
|
||||
if dlg:
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
|
||||
class LocalAppsPanelMici(NavScroller):
|
||||
|
||||
# How long a requested unpair may stay visible before the UI calls it stuck: the local
|
||||
# daemon's tick services requests about once a second, so anything older was never claimed.
|
||||
_REVOKE_STUCK_S = 4.0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._rows_cache: list[LocalAppRow] = []
|
||||
self._pending_revokes: dict[str, float] = {}
|
||||
self._pending_revoke_all: float | None = None
|
||||
|
||||
self._local_toggle = BigToggle(text=tr("local connections"),
|
||||
initial_state=ui_state.params.get_bool("SunnylinkLocalEnabled"),
|
||||
toggle_callback=self._local_toggle_callback)
|
||||
|
||||
self._revoke_all_btn = BigButton(tr("revoke all phones"), "")
|
||||
self._revoke_all_btn.set_click_callback(self._confirm_revoke_all)
|
||||
|
||||
self._local_app_btns: list[BigButton] = []
|
||||
for i in range(MAX_LOCAL_APP_ROWS):
|
||||
btn = BigButton("", "")
|
||||
btn.set_click_callback(partial(self._confirm_unpair_row, i))
|
||||
self._local_app_btns.append(btn)
|
||||
|
||||
self._scroller.add_widgets([self._local_toggle, self._revoke_all_btn, *self._local_app_btns])
|
||||
|
||||
@staticmethod
|
||||
def _local_toggle_callback(state: bool):
|
||||
ui_state.params.put_bool("SunnylinkLocalEnabled", state)
|
||||
ui_state.update_params()
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._local_toggle.set_checked(ui_state.params.get_bool("SunnylinkLocalEnabled"))
|
||||
self._rows_cache = app_rows()
|
||||
self._revoke_all_btn.set_visible(bool(self._rows_cache))
|
||||
now = time.monotonic()
|
||||
present = {row.app_id for row in self._rows_cache}
|
||||
self._pending_revokes = {a: t for a, t in self._pending_revokes.items() if a in present}
|
||||
if not present:
|
||||
self._pending_revoke_all = None
|
||||
stuck_all = (self._pending_revoke_all is not None and bool(present)
|
||||
and now - self._pending_revoke_all >= self._REVOKE_STUCK_S)
|
||||
for i, btn in enumerate(self._local_app_btns):
|
||||
btn.set_visible(i < len(self._rows_cache))
|
||||
if i < len(self._rows_cache):
|
||||
row = self._rows_cache[i]
|
||||
btn.set_text(row.title)
|
||||
asked = self._pending_revokes.get(row.app_id)
|
||||
if (asked is not None and now - asked >= self._REVOKE_STUCK_S) or stuck_all:
|
||||
btn.set_value(tr("still paired — retry?"))
|
||||
else:
|
||||
btn.set_value("")
|
||||
if stuck_all:
|
||||
self._revoke_all_btn.set_value(tr("still paired — retry?"))
|
||||
else:
|
||||
self._revoke_all_btn.set_value("")
|
||||
|
||||
def _confirm_revoke_all(self):
|
||||
icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64)
|
||||
dlg = BigConfirmationDialog(
|
||||
tr("revoke all phones"),
|
||||
icon,
|
||||
confirm_callback=self._on_revoke_all_confirmed,
|
||||
red=True,
|
||||
)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
def _on_revoke_all_confirmed(self):
|
||||
self._pending_revoke_all = time.monotonic()
|
||||
request_revoke_all()
|
||||
|
||||
def _confirm_unpair_row(self, index: int):
|
||||
if index >= len(self._rows_cache):
|
||||
return
|
||||
row = self._rows_cache[index]
|
||||
dlg = BigConfirmationDialog(
|
||||
tr("slide to unpair"),
|
||||
gui_app.texture("icons_mici/settings/device/update.png", 64, 64),
|
||||
confirm_callback=partial(self._on_unpair_confirmed, row.app_id),
|
||||
red=True,
|
||||
)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
def _on_unpair_confirmed(self, app_id: str):
|
||||
self._pending_revokes[app_id] = time.monotonic()
|
||||
request_revoke(app_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_boost import ACCEL_BOOST_MAX
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
BAR_WIDTH = 26
|
||||
BAR_RADIUS = 0.5
|
||||
TRACK_TOP_RATIO = 0.08
|
||||
TRACK_BOTTOM_RATIO = 0.92
|
||||
# Fades to white only near the profile cap, like the stock torque bar fading toward orange at high torque
|
||||
CAP_BLEND_START = 0.70
|
||||
MAX_BOOST_BLEND = 0.55
|
||||
|
||||
COLOR_TRACK = rl.Color(255, 255, 255, 32)
|
||||
COLOR_TRACK_EDGE = rl.Color(255, 255, 255, 70)
|
||||
COLOR_HIGHLIGHT = rl.Color(255, 255, 255, 45)
|
||||
|
||||
AGGRESSIVE_COLOR = rl.Color(255, 80, 60, 255)
|
||||
STANDARD_COLOR = rl.Color(0, 230, 120, 255)
|
||||
RELAXED_COLOR = rl.Color(70, 150, 255, 255)
|
||||
|
||||
# The personality enum is ordered aggressive, standard, relaxed, matching ACCEL_BOOST_MAX
|
||||
PROFILE_STYLES = {
|
||||
0: (AGGRESSIVE_COLOR, ACCEL_BOOST_MAX[0]),
|
||||
1: (STANDARD_COLOR, ACCEL_BOOST_MAX[1]),
|
||||
2: (RELAXED_COLOR, ACCEL_BOOST_MAX[2]),
|
||||
}
|
||||
|
||||
FADE_THRESHOLD = 0.01
|
||||
|
||||
|
||||
def get_boost_style(personality) -> tuple[rl.Color, float]:
|
||||
"""Color and full-scale boost for the active personality."""
|
||||
raw = getattr(personality, "raw", personality)
|
||||
return PROFILE_STYLES.get(int(raw), PROFILE_STYLES[1])
|
||||
|
||||
|
||||
def get_boost_fraction(boost: float, max_boost: float) -> float:
|
||||
"""How much of the active profile's boost budget is in use, clamped to a full bar."""
|
||||
if max_boost <= 0.0:
|
||||
return 0.0
|
||||
return min(max(boost / max_boost, 0.0), 1.0)
|
||||
|
||||
|
||||
def lighten(color: rl.Color, amount: float) -> rl.Color:
|
||||
"""Lerp towards white in RGB, so the hue stays put the way HSV blending would not."""
|
||||
amount = min(max(amount, 0.0), 1.0)
|
||||
return rl.Color(int(color.r + (255 - color.r) * amount),
|
||||
int(color.g + (255 - color.g) * amount),
|
||||
int(color.b + (255 - color.b) * amount), color.a)
|
||||
|
||||
|
||||
class AccelBoostIndicator(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
fps = max(gui_app.target_fps, 1)
|
||||
self._boost_filter = FirstOrderFilter(0.0, 0.15, 1 / fps)
|
||||
self._alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / fps)
|
||||
# Smoothly rescale the bar when the follow distance profile changes mid-drive
|
||||
self._max_boost_filter = FirstOrderFilter(0.0, 0.25, 1 / fps)
|
||||
self._profile_color, self._max_boost = PROFILE_STYLES[1]
|
||||
|
||||
self.panel_in_use = False
|
||||
|
||||
def _update_state(self):
|
||||
sm = ui_state.sm
|
||||
visible = (ui_state.has_longitudinal_control and sm.alive['selfdriveState'] and
|
||||
sm.alive['longitudinalPlan'] and sm['selfdriveState'].enabled)
|
||||
self._alpha_filter.update(1.0 if visible else 0.0)
|
||||
self._boost_filter.update(float(sm['longitudinalPlan'].accelBoost) if visible else 0.0)
|
||||
|
||||
if sm.alive['selfdriveState']:
|
||||
self._profile_color, self._max_boost = get_boost_style(sm['selfdriveState'].personality)
|
||||
self._max_boost_filter.update(self._max_boost)
|
||||
|
||||
# The bar owns the panel while it is drawn; the confidence ball returns when it fades out
|
||||
self.panel_in_use = self._alpha_filter.x > FADE_THRESHOLD
|
||||
|
||||
def _render(self, _):
|
||||
alpha = self._alpha_filter.x
|
||||
if alpha < FADE_THRESHOLD or self._max_boost <= 0.0:
|
||||
return
|
||||
|
||||
panel_x = self.rect.x + self.rect.width - SIDE_PANEL_WIDTH
|
||||
cx = panel_x + SIDE_PANEL_WIDTH / 2
|
||||
bar_x = cx - BAR_WIDTH / 2
|
||||
track_top = self.rect.y + self.rect.height * TRACK_TOP_RATIO
|
||||
track_bottom = self.rect.y + self.rect.height * TRACK_BOTTOM_RATIO
|
||||
track_h = max(1.0, track_bottom - track_top)
|
||||
track_rect = rl.Rectangle(bar_x, track_top, BAR_WIDTH, track_h)
|
||||
|
||||
# soft well behind the track so the bar reads on the black side panel
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(bar_x - 3, track_top - 3, BAR_WIDTH + 6, track_h + 6), BAR_RADIUS, 12,
|
||||
rl.Color(0, 0, 0, int(80 * alpha)))
|
||||
|
||||
# track, light enough to read on the black side panel
|
||||
rl.draw_rectangle_rounded(track_rect, BAR_RADIUS, 12,
|
||||
rl.Color(COLOR_TRACK.r, COLOR_TRACK.g, COLOR_TRACK.b, int(COLOR_TRACK.a * alpha)))
|
||||
rl.draw_rectangle_rounded_lines_ex(track_rect, BAR_RADIUS, 12, 1,
|
||||
rl.Color(COLOR_TRACK_EDGE.r, COLOR_TRACK_EDGE.g, COLOR_TRACK_EDGE.b,
|
||||
int(COLOR_TRACK_EDGE.a * alpha)))
|
||||
|
||||
# Scale to the current profile's cap, so a maxed boost fills the bar and a profile change rescales it
|
||||
boost = max(self._boost_filter.x, 0.0)
|
||||
ratio = get_boost_fraction(boost, self._max_boost_filter.x)
|
||||
fill_h = ratio * track_h
|
||||
if fill_h <= 0.0:
|
||||
return
|
||||
|
||||
cap_blend = max(0.0, (ratio - CAP_BLEND_START) / (1.0 - CAP_BLEND_START))
|
||||
fill_color = lighten(self._profile_color, MAX_BOOST_BLEND * cap_blend)
|
||||
tip_y = track_bottom - fill_h
|
||||
|
||||
# soft glow around the tip, like the confidence ball's gradient
|
||||
tip_center_y = tip_y + min(fill_h, BAR_WIDTH) / 2
|
||||
glow = rl.Color(fill_color.r, fill_color.g, fill_color.b, int(85 * alpha))
|
||||
rl.draw_circle_gradient(rl.Vector2(cx, tip_center_y), int(BAR_WIDTH * 0.85), glow, rl.BLANK)
|
||||
|
||||
fill = rl.Color(fill_color.r, fill_color.g, fill_color.b, int(255 * alpha))
|
||||
if fill_h < BAR_WIDTH:
|
||||
# Keep the cap inside the track: a fixed radius would paint below the track near zero boost
|
||||
radius = max(1, int(fill_h / 2))
|
||||
rl.draw_circle(int(cx), int(track_bottom - radius), radius, fill)
|
||||
else:
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(bar_x, tip_y, BAR_WIDTH, fill_h), BAR_RADIUS, 12, fill)
|
||||
|
||||
# subtle inner highlight gives the fill a rounded, glassy edge
|
||||
if fill_h > 12:
|
||||
highlight_rect = rl.Rectangle(bar_x + 4, tip_y + 5, 3, fill_h - 10)
|
||||
rl.draw_rectangle_rounded(highlight_rect, 0.5, 8,
|
||||
rl.Color(COLOR_HIGHLIGHT.r, COLOR_HIGHLIGHT.g, COLOR_HIGHLIGHT.b,
|
||||
int(COLOR_HIGHLIGHT.a * alpha)))
|
||||
|
||||
# bright cap at the tip
|
||||
rl.draw_circle(int(cx), int(tip_center_y), 2,
|
||||
rl.Color(255, 255, 255, int(150 * alpha)))
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
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 openpilot.cereal import log
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.accel_boost_indicator import (
|
||||
get_boost_style, get_boost_fraction, lighten,
|
||||
AGGRESSIVE_COLOR, RELAXED_COLOR, STANDARD_COLOR,
|
||||
)
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_boost import ACCEL_BOOST_MAX
|
||||
|
||||
|
||||
class TestAccelBoostIndicator(OpenpilotTestCase):
|
||||
def test_profile_styles_match_controller_caps(self):
|
||||
for personality, color, max_boost in (
|
||||
(log.LongitudinalPersonality.aggressive, AGGRESSIVE_COLOR, ACCEL_BOOST_MAX[0]),
|
||||
(log.LongitudinalPersonality.standard, STANDARD_COLOR, ACCEL_BOOST_MAX[1]),
|
||||
(log.LongitudinalPersonality.relaxed, RELAXED_COLOR, ACCEL_BOOST_MAX[2])):
|
||||
style_color, style_max = get_boost_style(personality)
|
||||
assert style_color == color
|
||||
assert style_max == max_boost
|
||||
|
||||
def test_wire_personality_picks_the_right_profile(self):
|
||||
msg = log.SelfdriveState.new_message()
|
||||
msg.personality = log.LongitudinalPersonality.aggressive
|
||||
color, max_boost = get_boost_style(msg.personality)
|
||||
assert color == AGGRESSIVE_COLOR
|
||||
assert max_boost == ACCEL_BOOST_MAX[0]
|
||||
|
||||
def test_bar_fills_at_the_active_profile_cap(self):
|
||||
for cap in ACCEL_BOOST_MAX:
|
||||
assert get_boost_fraction(0.0, cap) == 0.0
|
||||
assert get_boost_fraction(cap * 0.5, cap) == 0.5
|
||||
assert get_boost_fraction(cap, cap) == 1.0
|
||||
|
||||
# A boost beyond the current cap (e.g. after switching to a shorter profile) still fills the bar
|
||||
assert get_boost_fraction(max(ACCEL_BOOST_MAX), min(ACCEL_BOOST_MAX)) == 1.0
|
||||
assert get_boost_fraction(0.0, 0.0) == 0.0
|
||||
|
||||
def test_cap_lighten_keeps_the_profile_hue(self):
|
||||
# An HSV blend towards white sweeps relaxed blue through pink, so the fill lightens in RGB instead
|
||||
relaxed = lighten(RELAXED_COLOR, 0.45)
|
||||
aggressive = lighten(AGGRESSIVE_COLOR, 0.45)
|
||||
assert relaxed.b > relaxed.r
|
||||
assert relaxed.b >= relaxed.g
|
||||
assert aggressive.r > aggressive.b
|
||||
# lighter in RGB means the dimmest channel came up towards white
|
||||
assert relaxed.r > RELAXED_COLOR.r
|
||||
assert aggressive.g > AGGRESSIVE_COLOR.g
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from openpilot.cereal import log
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
|
||||
|
||||
ACCEL_BOOST_MIN_SPEED = 1.0
|
||||
ACCEL_BOOST_MAX = (0.4, 0.25, 0.1) # aggressive, standard, relaxed, m/s^2
|
||||
ACCEL_BOOST_BUILD, ACCEL_BOOST_RELEASE = 0.2, 1.0 # m/s^3
|
||||
BRAKE_PROB = (0.1, 0.3) # the model's brake press in 2 s: it wants to slow
|
||||
GAS_PROB = (0.6, 0.8) # predicts a human gas press at this hypothesis at about 23%
|
||||
CURVE_LAT_ACCEL = (1.5, 2.5)
|
||||
|
||||
|
||||
def get_boost_max(personality=log.LongitudinalPersonality.standard):
|
||||
if personality == log.LongitudinalPersonality.relaxed:
|
||||
return ACCEL_BOOST_MAX[2]
|
||||
elif personality == log.LongitudinalPersonality.standard:
|
||||
return ACCEL_BOOST_MAX[1]
|
||||
elif personality == log.LongitudinalPersonality.aggressive:
|
||||
return ACCEL_BOOST_MAX[0]
|
||||
else:
|
||||
raise NotImplementedError("Longitudinal personality not supported")
|
||||
|
||||
|
||||
class AccelBoostSP:
|
||||
def __init__(self):
|
||||
self.params = Params()
|
||||
self.frame = -1
|
||||
self.enabled = False
|
||||
self.boost = 0.0
|
||||
self.total_boost = 0.0
|
||||
|
||||
def update_params(self):
|
||||
self.frame += 1
|
||||
if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) != 0:
|
||||
return False
|
||||
enabled = self.params.get_bool("AccelBoostSP")
|
||||
if enabled == self.enabled:
|
||||
return False
|
||||
self.enabled = enabled
|
||||
self.boost = self.total_boost = 0.0
|
||||
return True
|
||||
|
||||
def update(self, sm, output_a_target_e2e, output_a_target_mpc, a_cruise):
|
||||
model = sm['modelV2']
|
||||
brake_probs = model.meta.disengagePredictions.brakePressProbs
|
||||
gas_probs = model.meta.disengagePredictions.gasPressProbs
|
||||
if not sm['selfdriveState'].enabled or len(brake_probs) < 2 or len(gas_probs) < 2:
|
||||
self.boost = self.total_boost = 0.0
|
||||
return output_a_target_e2e
|
||||
|
||||
want = max(float(np.interp(brake_probs[1], BRAKE_PROB, [1.0, 0.0])), float(np.interp(gas_probs[1], GAS_PROB, [0.0, 1.0])))
|
||||
want = 0.0 if model.action.shouldStop else want
|
||||
want *= float(np.interp(abs(model.action.desiredCurvature) * sm['carState'].vEgo ** 2, CURVE_LAT_ACCEL, [1.0, 0.0]))
|
||||
target = want * get_boost_max(sm['selfdriveState'].personality)
|
||||
self.boost = float(np.clip(target, self.boost - ACCEL_BOOST_RELEASE * DT_MDL, self.boost + ACCEL_BOOST_BUILD * DT_MDL))
|
||||
speed_scale = np.interp(sm['carState'].vEgo, [ACCEL_BOOST_MIN_SPEED, 2 * ACCEL_BOOST_MIN_SPEED], [0.0, 1.0])
|
||||
self.total_boost = self.boost * speed_scale * np.interp(output_a_target_e2e, [-0.7, -0.2, 5.0], [0.0, 1.0, 1.0], right=0.0)
|
||||
return output_a_target_e2e + self.total_boost
|
||||
@@ -9,6 +9,8 @@ from openpilot.cereal import messaging, custom
|
||||
from opendbc.car import structs
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.selfdrive.car.cruise import V_CRUISE_MAX
|
||||
from openpilot.selfdrive.controls.lib.accel_boost import AccelBoost
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_boost import AccelBoostSP
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.e2e_alerts_helper import E2EAlertsHelper
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.smart_cruise_control import SmartCruiseControl
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist import SpeedLimitAssist
|
||||
@@ -29,10 +31,16 @@ class LongitudinalPlannerSP:
|
||||
self.generation = int(model_bundle.generation) if (model_bundle := get_active_bundle()) else None
|
||||
self.source = LongitudinalPlanSource.cruise
|
||||
self.e2e_alerts_helper = E2EAlertsHelper()
|
||||
self.accel_boost_stock = AccelBoost()
|
||||
self.accel_boost_sp = AccelBoostSP()
|
||||
|
||||
self.output_v_target = 0.
|
||||
self.output_a_target = 0.
|
||||
|
||||
@property
|
||||
def accel_boost(self):
|
||||
return self.accel_boost_sp if self.accel_boost_sp.enabled else self.accel_boost_stock
|
||||
|
||||
def update_targets(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> tuple[float, float]:
|
||||
CS = sm['carState']
|
||||
v_cruise_cluster_kph = min(CS.vCruiseCluster, V_CRUISE_MAX)
|
||||
@@ -64,6 +72,9 @@ class LongitudinalPlannerSP:
|
||||
return self.output_v_target, self.output_a_target
|
||||
|
||||
def update(self, sm: messaging.SubMaster) -> None:
|
||||
if self.accel_boost_sp.update_params():
|
||||
self.accel_boost_stock = AccelBoost()
|
||||
|
||||
self.events_sp.clear()
|
||||
self.e2e_alerts_helper.update(sm, self.events_sp)
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
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 types import SimpleNamespace
|
||||
|
||||
from openpilot.common.parameterized import parameterized
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
|
||||
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_boost import AccelBoostSP, ACCEL_BOOST_MAX
|
||||
|
||||
|
||||
def make_sm(brake_prob=0.05, gas_prob=0.3, lat_accel=0.0, v_ego=30.0, personality=0, enabled=True, should_stop=False):
|
||||
meta = SimpleNamespace(disengagePredictions=SimpleNamespace(brakePressProbs=[0.0, brake_prob, 0.0, 0.0, 0.0, 0.0],
|
||||
gasPressProbs=[0.0, gas_prob, 0.0, 0.0, 0.0, 0.0]))
|
||||
action = SimpleNamespace(shouldStop=should_stop, desiredCurvature=lat_accel / v_ego ** 2)
|
||||
return {'modelV2': SimpleNamespace(meta=meta, action=action), 'carState': SimpleNamespace(vEgo=v_ego),
|
||||
'selfdriveState': SimpleNamespace(enabled=enabled, personality=personality)}
|
||||
|
||||
|
||||
def settle(sm, frames=100):
|
||||
boost = AccelBoostSP()
|
||||
for _ in range(frames):
|
||||
boost.update(sm, 0.0, 9.0, 9.0)
|
||||
return boost.total_boost
|
||||
|
||||
|
||||
class TestAccelBoostSP(OpenpilotTestCase):
|
||||
@parameterized.expand([(0,), (1,), (2,)])
|
||||
def test_personality_max(self, personality):
|
||||
assert abs(settle(make_sm(personality=personality)) - ACCEL_BOOST_MAX[personality]) < 1e-6
|
||||
|
||||
def test_brake_gate_closes(self):
|
||||
assert settle(make_sm(brake_prob=0.5)) == 0.0
|
||||
|
||||
def test_gas_reopens(self):
|
||||
assert abs(settle(make_sm(brake_prob=0.5, gas_prob=0.9)) - ACCEL_BOOST_MAX[0]) < 1e-6
|
||||
|
||||
def test_should_stop(self):
|
||||
assert settle(make_sm(gas_prob=0.9, should_stop=True)) == 0.0
|
||||
|
||||
@parameterized.expand([(1.5, 1.0), (2.0, 0.5), (2.5, 0.0), (-2.0, 0.5)])
|
||||
def test_curve_fade(self, lat_accel, scale):
|
||||
assert abs(settle(make_sm(lat_accel=lat_accel)) - scale * ACCEL_BOOST_MAX[0]) < 1e-6
|
||||
|
||||
def test_low_speed(self):
|
||||
assert settle(make_sm(v_ego=0.5)) == 0.0
|
||||
|
||||
def test_disabled_resets(self):
|
||||
boost = AccelBoostSP()
|
||||
for _ in range(100):
|
||||
boost.update(make_sm(), 0.0, 9.0, 9.0)
|
||||
assert boost.update(make_sm(enabled=False), 0.0, 9.0, 9.0) == 0.0
|
||||
assert boost.boost == 0.0 and boost.total_boost == 0.0
|
||||
|
||||
def test_build_and_release_rates(self):
|
||||
boost = AccelBoostSP()
|
||||
boost.update(make_sm(), 0.0, 9.0, 9.0)
|
||||
assert abs(boost.total_boost - 0.01) < 1e-6
|
||||
for _ in range(100):
|
||||
boost.update(make_sm(), 0.0, 9.0, 9.0)
|
||||
boost.update(make_sm(brake_prob=0.5), 0.0, 9.0, 9.0)
|
||||
assert abs(boost.total_boost - (ACCEL_BOOST_MAX[0] - 0.05)) < 1e-6
|
||||
|
||||
def test_param_switch(self):
|
||||
params = Params()
|
||||
boost = AccelBoostSP()
|
||||
params.put_bool("AccelBoostSP", True, block=True)
|
||||
assert boost.update_params() and boost.enabled
|
||||
for _ in range(100):
|
||||
boost.update(make_sm(), 0.0, 9.0, 9.0)
|
||||
params.put_bool("AccelBoostSP", False, block=True)
|
||||
for _ in range(int(PARAMS_UPDATE_PERIOD / DT_MDL) - 1):
|
||||
assert not boost.update_params()
|
||||
assert boost.update_params() and not boost.enabled
|
||||
assert boost.boost == 0.0 and boost.total_boost == 0.0
|
||||
@@ -1,64 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_auth import MAX_GRANTS
|
||||
|
||||
MAX_LOCAL_APP_ROWS = MAX_GRANTS
|
||||
DEFAULT_APP_NAME = "sunnylink mobile"
|
||||
NEVER_CONNECTED = "not connected yet"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalAppRow:
|
||||
app_id: str
|
||||
title: str
|
||||
subtitle: str
|
||||
|
||||
|
||||
def short_key(app_id: str, length: int = 10) -> str:
|
||||
return app_id if len(app_id) <= length else app_id[:length] + "…"
|
||||
|
||||
|
||||
def format_age(seconds: float) -> str:
|
||||
if seconds < 60:
|
||||
return "just now"
|
||||
if seconds < 3600:
|
||||
return f"{int(seconds // 60)}m ago"
|
||||
if seconds < 86_400:
|
||||
return f"{int(seconds // 3600)}h ago"
|
||||
return f"{int(seconds // 86_400)}d ago"
|
||||
|
||||
|
||||
def last_seen_text(entry: Mapping[str, object] | None, now: float) -> str:
|
||||
seen = entry.get("last_seen") if isinstance(entry, Mapping) else None
|
||||
if not isinstance(seen, (int, float)) or isinstance(seen, bool) or now - float(seen) < 0:
|
||||
return NEVER_CONNECTED
|
||||
return format_age(now - float(seen))
|
||||
|
||||
|
||||
def _title(app_id: str, grant: dict, entry: Mapping[str, object] | None) -> str:
|
||||
alias = entry.get("alias") if isinstance(entry, Mapping) else None
|
||||
if isinstance(alias, str) and alias.strip():
|
||||
return alias.strip()
|
||||
name = grant.get("app_name")
|
||||
return name if isinstance(name, str) and name else DEFAULT_APP_NAME
|
||||
|
||||
|
||||
def build_local_app_rows(grants: list[dict[str, str]],
|
||||
meta: Mapping[str, Mapping[str, object]] | None = None,
|
||||
now: float = 0.0) -> list[LocalAppRow]:
|
||||
entries = meta or {}
|
||||
rows = []
|
||||
for grant in grants:
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
app_id = grant.get("app_id")
|
||||
if not isinstance(app_id, str) or not app_id:
|
||||
continue
|
||||
entry = entries.get(app_id)
|
||||
entry = entry if isinstance(entry, Mapping) else None
|
||||
rows.append(LocalAppRow(app_id, _title(app_id, grant, entry),
|
||||
f"{last_seen_text(entry, now)}"))
|
||||
return rows[:MAX_LOCAL_APP_ROWS]
|
||||
@@ -1,351 +0,0 @@
|
||||
"""Local trust: keys, grants, cloud enrollment, pinned TLS dial.
|
||||
|
||||
Pairing is automatic: the app pushes a self-signed enrollment for this phone's
|
||||
key through the existing authenticated cloud settings-write transport (the same
|
||||
channel all settings writes already trust). The device stages it and commits
|
||||
the grant only after the phone's key proves itself over pinned TLS on the LAN.
|
||||
There is no code, window, or presence check: account write access authorizes
|
||||
enrollment, and the paired-devices list on this screen is the audit trail.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import ipaddress
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from websocket import WebSocket, create_connection
|
||||
from cryptography import x509
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
|
||||
|
||||
VERSION = 3
|
||||
AUTHENTICATE_PURPOSE = "sunnylink-local-authenticate"
|
||||
MAX_ENROLL_BYTES = 4096
|
||||
MAX_GRANTS = 8
|
||||
|
||||
LOCAL_METHODS = frozenset({"getParams", "getParamsAllKeys", "getParamsMetadata", "getMessage", "saveParams",
|
||||
"updateLocalAppAlias", "unpairLocalApp"})
|
||||
_TOKEN = re.compile(r"[A-Za-z0-9_-]+\Z")
|
||||
|
||||
|
||||
class AuthorizationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class Origin(Enum):
|
||||
CLOUD = "cloud"
|
||||
LOCAL = "local"
|
||||
|
||||
|
||||
def encode64(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def decode64(value: Any, *, max_bytes: int = MAX_ENROLL_BYTES) -> bytes:
|
||||
if not isinstance(value, str) or not value or len(value) > (max_bytes * 4 + 2) // 3 or not _TOKEN.fullmatch(value):
|
||||
raise AuthorizationError("invalid encoding")
|
||||
try:
|
||||
raw = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
||||
except ValueError as e:
|
||||
raise AuthorizationError("invalid encoding") from e
|
||||
if len(raw) > max_bytes or encode64(raw) != value:
|
||||
raise AuthorizationError("noncanonical encoding")
|
||||
return raw
|
||||
|
||||
|
||||
def load_app_key(value: str) -> ec.EllipticCurvePublicKey:
|
||||
raw = decode64(value, max_bytes=128)
|
||||
try:
|
||||
key = serialization.load_der_public_key(raw)
|
||||
except ValueError as e:
|
||||
raise AuthorizationError("invalid key") from e
|
||||
if not isinstance(key, ec.EllipticCurvePublicKey) or not isinstance(key.curve, ec.SECP256R1):
|
||||
raise AuthorizationError("unsupported key")
|
||||
if key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo) != raw:
|
||||
raise AuthorizationError("noncanonical key")
|
||||
return key
|
||||
|
||||
|
||||
def key_id(key: ec.EllipticCurvePublicKey | rsa.RSAPublicKey) -> str:
|
||||
"""App id is the hash of its key."""
|
||||
raw = key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
return encode64(hashlib.sha256(raw).digest())
|
||||
|
||||
|
||||
def text_field(value: Any, maximum: int = 128) -> str:
|
||||
if not isinstance(value, str) or not value or len(value.encode("utf-8")) > maximum or any(ord(c) < 32 or ord(c) == 127 for c in value):
|
||||
raise AuthorizationError("invalid field")
|
||||
return value
|
||||
|
||||
|
||||
def statement(purpose: str, *fields: str) -> bytes:
|
||||
"""Length-bounded fields joined by LF."""
|
||||
return ("sunnylink-local\n" + purpose + "\n" + "\n".join(text_field(f, 1024) for f in fields) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise AuthorizationError("duplicate field")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def parse_object(raw: str) -> dict[str, Any]:
|
||||
if not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_ENROLL_BYTES:
|
||||
raise AuthorizationError("message too large")
|
||||
try:
|
||||
obj = json.loads(raw, object_pairs_hook=_unique_object)
|
||||
except (ValueError, UnicodeError) as e:
|
||||
raise AuthorizationError("invalid message") from e
|
||||
if not isinstance(obj, dict):
|
||||
raise AuthorizationError("invalid message")
|
||||
return obj
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Grant:
|
||||
app_id: str
|
||||
public_key: str
|
||||
app_name: str
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: Mapping[str, Any]) -> Grant:
|
||||
if not isinstance(data, Mapping) or set(data) != {"app_id", "public_key", "app_name"}:
|
||||
raise AuthorizationError("invalid grant")
|
||||
public = text_field(data["public_key"], 256)
|
||||
if data["app_id"] != key_id(load_app_key(public)):
|
||||
raise AuthorizationError("key mismatch")
|
||||
return Grant(data["app_id"], public, text_field(data["app_name"], 80))
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {"app_id": self.app_id, "public_key": self.public_key, "app_name": self.app_name}
|
||||
|
||||
|
||||
class LocalAuthority:
|
||||
"""All trust changes go through here."""
|
||||
|
||||
def __init__(self, cloud_device_id: str, comma_device_id: str, device_private_key: ec.EllipticCurvePrivateKey | rsa.RSAPrivateKey,
|
||||
persist: Callable[[dict[str, Any]], None], registry: Any = None):
|
||||
self.cloud_device_id = text_field(cloud_device_id)
|
||||
self.comma_device_id = text_field(comma_device_id)
|
||||
self.device_key = device_private_key
|
||||
self.device_key_id = key_id(device_private_key.public_key())
|
||||
self.persist = persist
|
||||
self.lock = threading.RLock()
|
||||
self.pending: Grant | None = None
|
||||
self.grants = _load_grants(registry)
|
||||
|
||||
def enroll(self, raw: str, origin: Origin) -> Grant:
|
||||
if origin is not Origin.CLOUD:
|
||||
raise AuthorizationError("cloud authorization required")
|
||||
data = parse_object(raw)
|
||||
required = {"v", "cloud_device_id", "comma_device_id", "device_key_id", "public_key", "key_id", "app_name", "signature"}
|
||||
if set(data) != required or type(data["v"]) is not int or data["v"] != VERSION:
|
||||
raise AuthorizationError("invalid enrollment")
|
||||
if (data["cloud_device_id"], data["comma_device_id"], data["device_key_id"]) != \
|
||||
(self.cloud_device_id, self.comma_device_id, self.device_key_id):
|
||||
raise AuthorizationError("wrong device")
|
||||
public = text_field(data["public_key"], 256)
|
||||
key = load_app_key(public)
|
||||
app_id = key_id(key)
|
||||
if data["key_id"] != app_id:
|
||||
raise AuthorizationError("wrong key")
|
||||
name = text_field(data["app_name"], 80)
|
||||
signed = statement("enroll", self.cloud_device_id, self.comma_device_id, self.device_key_id, public, app_id, name)
|
||||
try:
|
||||
key.verify(decode64(data["signature"], max_bytes=72), signed, ec.ECDSA(hashes.SHA256()))
|
||||
except InvalidSignature as e:
|
||||
raise AuthorizationError("invalid proof") from e
|
||||
with self.lock:
|
||||
stored = self.grants.get(app_id)
|
||||
if stored is not None:
|
||||
if stored.public_key != public:
|
||||
raise AuthorizationError("key mismatch")
|
||||
return stored
|
||||
if self.pending is not None:
|
||||
if (self.pending.public_key, self.pending.app_name) != (public, name):
|
||||
raise AuthorizationError("enrollment in progress")
|
||||
return self.pending
|
||||
if len(self.grants) >= MAX_GRANTS:
|
||||
raise AuthorizationError("grant limit")
|
||||
self.pending = Grant(app_id, public, name)
|
||||
return self.pending
|
||||
|
||||
def confirm(self, grant: Grant, tls_peer_key_id: str) -> Grant:
|
||||
with self.lock:
|
||||
if not secrets.compare_digest(grant.app_id, tls_peer_key_id):
|
||||
raise AuthorizationError("wrong TLS peer")
|
||||
if self.pending != grant:
|
||||
raise AuthorizationError("no pending enrollment")
|
||||
updated = self.grants | {grant.app_id: grant}
|
||||
self.persist(_registry(updated))
|
||||
self.grants = updated
|
||||
self.pending = None
|
||||
return grant
|
||||
|
||||
def authenticate_device(self, phone_key_id: str, challenge: str) -> dict[str, str]:
|
||||
if len(decode64(challenge, max_bytes=32)) != 32:
|
||||
raise AuthorizationError("invalid challenge")
|
||||
fields = (self.cloud_device_id, self.comma_device_id, self.device_key_id,
|
||||
text_field(phone_key_id, 128), text_field(challenge, 128))
|
||||
proof = statement("authenticate-device", *fields)
|
||||
if isinstance(self.device_key, ec.EllipticCurvePrivateKey):
|
||||
signature = self.device_key.sign(proof, ec.ECDSA(hashes.SHA256()))
|
||||
else:
|
||||
signature = self.device_key.sign(proof, padding.PKCS1v15(), hashes.SHA256())
|
||||
return {"cloud_device_id": self.cloud_device_id, "comma_device_id": self.comma_device_id,
|
||||
"device_key_id": self.device_key_id, "phone_key_id": phone_key_id,
|
||||
"challenge": challenge, "signature": encode64(signature)}
|
||||
|
||||
def revoke_by_self_proof(self, raw: str, origin: Origin) -> str:
|
||||
if origin is not Origin.CLOUD:
|
||||
raise AuthorizationError("cloud authorization required")
|
||||
data = parse_object(raw)
|
||||
if set(data) != {"v", "cloud_device_id", "comma_device_id", "key_id", "signature"} \
|
||||
or type(data["v"]) is not int or data["v"] != VERSION:
|
||||
raise AuthorizationError("invalid revocation")
|
||||
if (data["cloud_device_id"], data["comma_device_id"]) != (self.cloud_device_id, self.comma_device_id):
|
||||
raise AuthorizationError("wrong device")
|
||||
app_id = text_field(data["key_id"], 128)
|
||||
if len(decode64(app_id, max_bytes=32)) != 32:
|
||||
raise AuthorizationError("invalid key id")
|
||||
|
||||
with self.lock:
|
||||
grant = self.grants.get(app_id)
|
||||
if grant is None:
|
||||
self.revoke(app_id)
|
||||
return app_id
|
||||
|
||||
proof = statement("revoke", self.cloud_device_id, self.comma_device_id, app_id)
|
||||
try:
|
||||
load_app_key(grant.public_key).verify(decode64(data["signature"], max_bytes=72), proof, ec.ECDSA(hashes.SHA256()))
|
||||
except InvalidSignature as e:
|
||||
raise AuthorizationError("invalid proof") from e
|
||||
self.revoke(app_id)
|
||||
return app_id
|
||||
|
||||
def revoke_all(self) -> int:
|
||||
with self.lock:
|
||||
removed = len(self.grants)
|
||||
if removed == 0:
|
||||
return 0
|
||||
self.persist(_registry({}))
|
||||
self.grants = {}
|
||||
self.pending = None
|
||||
return removed
|
||||
|
||||
def revoke(self, app_id: str) -> bool:
|
||||
with self.lock:
|
||||
if self.pending is not None and self.pending.app_id == app_id:
|
||||
self.pending = None
|
||||
if app_id not in self.grants:
|
||||
return False
|
||||
updated = {k: g for k, g in self.grants.items() if k != app_id}
|
||||
self.persist(_registry(updated))
|
||||
self.grants = updated
|
||||
return True
|
||||
|
||||
def session(self, grant: Grant, tls_peer_key_id: str) -> AuthorizedSession:
|
||||
with self.lock:
|
||||
if self.grants.get(grant.app_id) != grant or not secrets.compare_digest(grant.app_id, tls_peer_key_id):
|
||||
raise AuthorizationError("unauthorized peer")
|
||||
return AuthorizedSession(self, grant)
|
||||
|
||||
|
||||
def _registry(grants: Mapping[str, Grant]) -> dict[str, Any]:
|
||||
return {"v": VERSION, "grants": [g.to_dict() for g in grants.values()]}
|
||||
|
||||
|
||||
def _load_grants(registry: Any) -> dict[str, Grant]:
|
||||
"""Bad storage loads as no grants."""
|
||||
if not isinstance(registry, dict) or registry.get("v") != VERSION or set(registry) != {"v", "grants"}:
|
||||
return {}
|
||||
entries = registry["grants"]
|
||||
if not isinstance(entries, list) or len(entries) > MAX_GRANTS:
|
||||
return {}
|
||||
try:
|
||||
grants = [Grant.from_dict(entry) for entry in entries]
|
||||
except AuthorizationError:
|
||||
return {}
|
||||
if len({g.app_id for g in grants}) != len(grants):
|
||||
return {}
|
||||
return {g.app_id: g for g in grants}
|
||||
|
||||
|
||||
class AuthorizedSession:
|
||||
|
||||
def __init__(self, authority: LocalAuthority, grant: Grant):
|
||||
self.authority = authority
|
||||
self.grant = grant
|
||||
self.closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
with self.authority.lock:
|
||||
self.closed = True
|
||||
|
||||
def invoke(self, method: str, handler: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
with self.authority.lock:
|
||||
if self.closed or self.authority.grants.get(self.grant.app_id) != self.grant or method not in LOCAL_METHODS:
|
||||
raise AuthorizationError("request denied")
|
||||
return handler(*args, **kwargs)
|
||||
|
||||
|
||||
def connect_pinned(endpoint: str, expected_key_id: str, timeout_s: float = 10) -> WebSocket:
|
||||
"""Dial with an exact public-key pin. Local sockets only."""
|
||||
parsed = urlsplit(endpoint)
|
||||
if parsed.scheme != "wss" or parsed.username is not None or parsed.password is not None \
|
||||
or parsed.query or parsed.fragment or parsed.path not in ("", "/") or parsed.hostname is None:
|
||||
raise AuthorizationError("invalid local endpoint")
|
||||
try:
|
||||
address = ipaddress.ip_address(parsed.hostname)
|
||||
port = parsed.port
|
||||
except ValueError as e:
|
||||
raise AuthorizationError("invalid local endpoint") from e
|
||||
if port is None or address.is_unspecified or address.is_multicast:
|
||||
raise AuthorizationError("invalid local endpoint")
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||
context.maximum_version = ssl.TLSVersion.TLSv1_3
|
||||
# A new context and no supplied session prevent client resumption/early data.
|
||||
connection = socket.create_connection((str(address), port), timeout=timeout_s)
|
||||
tls: ssl.SSLSocket | None = None
|
||||
try:
|
||||
tls = context.wrap_socket(connection, server_hostname=None)
|
||||
certificate = tls.getpeercert(binary_form=True)
|
||||
if certificate is None:
|
||||
raise AuthorizationError("missing peer certificate")
|
||||
verify_tls_peer(certificate, expected_key_id)
|
||||
return create_connection(endpoint, socket=tls, enable_multithread=True, timeout=timeout_s,
|
||||
subprotocols=["sunnylink-local"])
|
||||
except Exception:
|
||||
(tls or connection).close()
|
||||
raise
|
||||
|
||||
|
||||
def verify_tls_peer(certificate_der: bytes, expected_key_id: str) -> str:
|
||||
try:
|
||||
key = x509.load_der_x509_certificate(certificate_der).public_key()
|
||||
except ValueError as e:
|
||||
raise AuthorizationError("invalid peer certificate") from e
|
||||
if not isinstance(key, ec.EllipticCurvePublicKey) or not isinstance(key.curve, ec.SECP256R1):
|
||||
raise AuthorizationError("unsupported peer key")
|
||||
actual = key_id(key)
|
||||
if not secrets.compare_digest(actual, expected_key_id):
|
||||
raise AuthorizationError("wrong TLS peer")
|
||||
return actual
|
||||
@@ -1,291 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.api.base import BaseApi
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_app_rows import LocalAppRow, build_local_app_rows
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
|
||||
VERSION,
|
||||
AuthorizationError,
|
||||
LocalAuthority,
|
||||
Origin,
|
||||
text_field,
|
||||
)
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_params import ENROLL as CLOUD_ENROLL_KEY, REVOKE as CLOUD_REVOKE_KEY
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_state import (
|
||||
LOCAL_APPS_KEY,
|
||||
LOCAL_APPS_META_KEY,
|
||||
REVOKE_ALL,
|
||||
REVOKE_KEY,
|
||||
)
|
||||
|
||||
MAX_ALIAS_BYTES = 80
|
||||
|
||||
_params = Params()
|
||||
_authority: LocalAuthority | None = None
|
||||
_authority_lock = threading.Lock()
|
||||
_meta_lock = threading.Lock()
|
||||
|
||||
|
||||
def _text(value: Any) -> Any:
|
||||
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value
|
||||
|
||||
|
||||
def _get(params: Params, key: str) -> Any:
|
||||
try:
|
||||
return _text(params.get(key))
|
||||
except Exception:
|
||||
cloudlog.exception(f"sunnylinkd.local_auth.{key}_unreadable")
|
||||
return None
|
||||
|
||||
|
||||
def _put(params: Params, key: str, value: Any) -> None:
|
||||
try:
|
||||
if value is None:
|
||||
params.remove(key)
|
||||
elif isinstance(value, bool):
|
||||
params.put_bool(key, value, block=True)
|
||||
else:
|
||||
params.put(key, value, block=True)
|
||||
except Exception:
|
||||
cloudlog.exception(f"sunnylinkd.local_auth.{key}_write_failed")
|
||||
|
||||
|
||||
def _run(name: str, action: Any) -> Any:
|
||||
try:
|
||||
return action()
|
||||
except Exception:
|
||||
cloudlog.exception(f"sunnylinkd.local_auth.{name}_failed")
|
||||
return None
|
||||
|
||||
|
||||
def _persist_registry(registry: dict[str, Any]) -> None:
|
||||
_params.put(LOCAL_APPS_KEY, registry, block=True)
|
||||
|
||||
|
||||
def _read_registry() -> dict[str, Any] | None:
|
||||
value = _get(_params, LOCAL_APPS_KEY)
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _build_authority() -> LocalAuthority | None:
|
||||
try:
|
||||
algorithm, private_pem, _ = BaseApi.get_key_pair()
|
||||
if algorithm is None or private_pem is None:
|
||||
return None
|
||||
pem = private_pem if isinstance(private_pem, bytes) else str(private_pem).encode()
|
||||
device_key = serialization.load_pem_private_key(pem, password=None)
|
||||
cloud_device_id, comma_device_id = (_get(_params, k) for k in ("SunnylinkDongleId", "DongleId"))
|
||||
if not isinstance(device_key, (ec.EllipticCurvePrivateKey, rsa.RSAPrivateKey)) or not cloud_device_id or not comma_device_id:
|
||||
return None
|
||||
return LocalAuthority(cloud_device_id=cloud_device_id, comma_device_id=comma_device_id,
|
||||
device_private_key=device_key, persist=_persist_registry, registry=_read_registry())
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.local_auth.identity_unavailable")
|
||||
return None
|
||||
|
||||
|
||||
def get_authority() -> LocalAuthority | None:
|
||||
global _authority
|
||||
with _authority_lock:
|
||||
if _authority is None:
|
||||
_authority = _build_authority()
|
||||
return _authority
|
||||
|
||||
|
||||
def reset_authority() -> None:
|
||||
global _authority
|
||||
with _authority_lock:
|
||||
_authority = None
|
||||
|
||||
|
||||
def _meta_write(apps: dict[str, Any]) -> None:
|
||||
_params.put(LOCAL_APPS_META_KEY, {"v": VERSION, "apps": apps}, block=True)
|
||||
|
||||
|
||||
def _meta_read() -> dict[str, Any]:
|
||||
try:
|
||||
raw = _params.get(LOCAL_APPS_META_KEY)
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.local_auth.meta_unreadable")
|
||||
return {}
|
||||
apps = raw.get("apps") if isinstance(raw, dict) else None
|
||||
return dict(apps) if isinstance(apps, dict) else {}
|
||||
|
||||
|
||||
def _meta_update(app_key_id: str, **fields: Any) -> None:
|
||||
with _meta_lock:
|
||||
apps = _meta_read()
|
||||
apps[app_key_id] = dict(apps.get(app_key_id) or {}) | fields
|
||||
_meta_write(apps)
|
||||
|
||||
|
||||
def read_grant_meta() -> dict[str, dict[str, Any]]:
|
||||
return {k: dict(v) for k, v in _meta_read().items() if isinstance(v, dict)}
|
||||
|
||||
|
||||
def set_grant_alias(app_key_id: str, alias: str) -> None:
|
||||
cleaned = alias.strip() if isinstance(alias, str) else ""
|
||||
if cleaned:
|
||||
text_field(cleaned, MAX_ALIAS_BYTES)
|
||||
_meta_update(app_key_id, alias=cleaned)
|
||||
|
||||
|
||||
def note_grant_seen(app_key_id: str) -> None:
|
||||
_run("meta_seen", lambda: _meta_update(app_key_id, last_seen=time.monotonic()))
|
||||
|
||||
|
||||
def clear_grant_meta(app_key_id: str) -> None:
|
||||
try:
|
||||
with _meta_lock:
|
||||
apps = _meta_read()
|
||||
if apps.pop(app_key_id, None) is not None:
|
||||
_meta_write(apps)
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.local_auth.meta_clear_failed")
|
||||
|
||||
|
||||
def clear_all_grant_meta() -> None:
|
||||
try:
|
||||
with _meta_lock:
|
||||
if _meta_read():
|
||||
_meta_write({})
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.local_auth.meta_clear_failed")
|
||||
|
||||
|
||||
def _require_authority(kind: str) -> LocalAuthority:
|
||||
authority = get_authority()
|
||||
if authority is None:
|
||||
raise AuthorizationError(f"{kind} unavailable")
|
||||
return authority
|
||||
|
||||
|
||||
def cloud_enroll(raw_enrollment: str) -> dict[str, Any]:
|
||||
grant = _require_authority("enrollment").enroll(raw_enrollment, Origin.CLOUD)
|
||||
return {"app_id": grant.app_id, "app_name": grant.app_name}
|
||||
|
||||
|
||||
def cloud_revoke(raw_revocation: str) -> dict[str, Any]:
|
||||
return {"app_id": _require_authority("revocation").revoke_by_self_proof(raw_revocation, Origin.CLOUD)}
|
||||
|
||||
|
||||
def _take(params: Params, key: str) -> str | None:
|
||||
try:
|
||||
value = params.get(key)
|
||||
params.remove(key)
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.local_auth.command_unreadable")
|
||||
return None
|
||||
value = _text(value)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def claim_cloud_commands(params: Params | None = None) -> None:
|
||||
params = params or _params
|
||||
for key, apply in ((CLOUD_ENROLL_KEY, cloud_enroll), (CLOUD_REVOKE_KEY, cloud_revoke)):
|
||||
value = _take(params, key)
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
summary = apply(value)
|
||||
except AuthorizationError as e:
|
||||
cloudlog.warning(f"sunnylinkd.local_auth.{key}_rejected: {e}")
|
||||
continue
|
||||
except Exception:
|
||||
cloudlog.exception(f"sunnylinkd.local_auth.{key}_failed")
|
||||
continue
|
||||
cloudlog.event(f"sunnylinkd.local_auth.{key}_accepted", app_name=summary.get("app_name"))
|
||||
|
||||
|
||||
def clear_pending_enrollment() -> None:
|
||||
authority = get_authority()
|
||||
if authority is not None:
|
||||
with authority.lock:
|
||||
authority.pending = None
|
||||
|
||||
|
||||
def confirm_enrollment_tls(grant_key_id: str) -> dict[str, Any]:
|
||||
authority = _require_authority("enrollment")
|
||||
pending = authority.pending
|
||||
if pending is None or pending.app_id != grant_key_id:
|
||||
raise AuthorizationError("no pending enrollment for this key")
|
||||
committed = authority.confirm(pending, grant_key_id)
|
||||
return {"app_id": committed.app_id, "app_name": committed.app_name}
|
||||
|
||||
|
||||
def revoke_local_app(app_key_id: str) -> bool:
|
||||
authority = get_authority()
|
||||
if authority is None:
|
||||
return False
|
||||
revoked = authority.revoke(app_key_id)
|
||||
if revoked:
|
||||
clear_grant_meta(app_key_id)
|
||||
return revoked
|
||||
|
||||
|
||||
def revoke_all() -> int:
|
||||
authority = get_authority()
|
||||
if authority is None:
|
||||
return 0
|
||||
removed = authority.revoke_all()
|
||||
if not authority.grants:
|
||||
clear_all_grant_meta()
|
||||
return removed
|
||||
|
||||
|
||||
def request_revoke(app_key_id: str, params: Params | None = None) -> None:
|
||||
if isinstance(app_key_id, str) and app_key_id:
|
||||
_put(params or _params, REVOKE_KEY, app_key_id)
|
||||
|
||||
|
||||
def request_revoke_all(params: Params | None = None) -> None:
|
||||
_put(params or _params, REVOKE_KEY, REVOKE_ALL)
|
||||
|
||||
|
||||
def service_revoke_requests(params: Params | None = None) -> bool:
|
||||
params = params or _params
|
||||
app_key_id = _take(params, REVOKE_KEY)
|
||||
if not app_key_id:
|
||||
return False
|
||||
if app_key_id == REVOKE_ALL:
|
||||
removed = _run("revoke_all", revoke_all)
|
||||
if removed:
|
||||
cloudlog.event("sunnylinkd.local_auth.grants_revoked", count=removed)
|
||||
return bool(removed)
|
||||
revoked = bool(_run("revoke", lambda: revoke_local_app(app_key_id)))
|
||||
if revoked:
|
||||
cloudlog.event("sunnylinkd.local_auth.grant_revoked", app_id=app_key_id)
|
||||
return revoked
|
||||
|
||||
|
||||
def grants() -> list[dict[str, str]]:
|
||||
authority = get_authority()
|
||||
return [g.to_dict() for g in authority.grants.values()] if authority is not None else []
|
||||
|
||||
|
||||
def read_granted_apps() -> list[dict[str, str]]:
|
||||
raw = _read_registry()
|
||||
entries = raw.get("grants") if raw is not None else None
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
return [{"app_id": e["app_id"], "app_name": e["app_name"]} for e in entries
|
||||
if isinstance(e, dict) and isinstance(e.get("app_id"), str) and e["app_id"]
|
||||
and isinstance(e.get("app_name"), str)]
|
||||
|
||||
|
||||
def app_rows() -> list[LocalAppRow]:
|
||||
return build_local_app_rows(read_granted_apps(), read_grant_meta(), time.monotonic())
|
||||
|
||||
|
||||
def pending_enrollment() -> dict[str, str] | None:
|
||||
authority = get_authority()
|
||||
pending = authority.pending if authority is not None else None
|
||||
return pending.to_dict() if pending is not None else None
|
||||
@@ -1,110 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_state import (
|
||||
BEACON_PREFIX,
|
||||
SUNNYLINK_LOCAL_UDP_PORT,
|
||||
format_endpoint,
|
||||
)
|
||||
|
||||
LOCAL_BEACON_FRESH_S = 30
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppBeacon:
|
||||
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:
|
||||
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) or data.get("role") != "app" or data.get("v") != 1:
|
||||
return None
|
||||
app_id, ws_port = data.get("app_id"), data.get("ws_port")
|
||||
if not isinstance(app_id, str) or not app_id or 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):
|
||||
def __init__(self, port: int = SUNNYLINK_LOCAL_UDP_PORT, sock: socket.socket | None = None):
|
||||
super().__init__(name="local_discovery_listener", daemon=True)
|
||||
self.port = port
|
||||
self._sock = sock
|
||||
self._recent: dict[str, tuple[float, str]] = {}
|
||||
self._recent_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
if self._sock is not None:
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def fresh_peers(self, max_age_s: float = LOCAL_BEACON_FRESH_S) -> list[tuple[str, str]]:
|
||||
now = time.monotonic()
|
||||
with self._recent_lock:
|
||||
self._recent = {e: v for e, v in self._recent.items() if now - v[0] <= max_age_s}
|
||||
return [(e, app_id) for e, (_, app_id) in sorted(self._recent.items(), key=lambda kv: kv[1][0], reverse=True)]
|
||||
|
||||
def fresh_endpoints(self, max_age_s: float = LOCAL_BEACON_FRESH_S) -> list[str]:
|
||||
return [endpoint for endpoint, _ in self.fresh_peers(max_age_s)]
|
||||
|
||||
def _handle(self, raw: bytes, source_ip: str) -> None:
|
||||
beacon = parse_beacon(raw, source_ip)
|
||||
if beacon is None:
|
||||
return
|
||||
with self._recent_lock:
|
||||
self._recent[beacon.endpoint] = (time.monotonic(), beacon.app_id)
|
||||
cloudlog.debug(f"local_discovery.app_found {beacon.app_id} at {beacon.endpoint}")
|
||||
|
||||
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:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
cloudlog.exception("local_discovery.recv.exception")
|
||||
break
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Params owned by the local daemon."""
|
||||
from __future__ import annotations
|
||||
|
||||
ENROLL = "SunnylinkLocalEnroll"
|
||||
REVOKE = "SunnylinkLocalCloudRevoke"
|
||||
|
||||
CLOUD_COMMANDS = frozenset({ENROLL, REVOKE})
|
||||
_PREFIXES = ("SunnylinkLocal", "_sec_")
|
||||
|
||||
|
||||
def is_local_param(key: str) -> bool:
|
||||
return key.startswith(_PREFIXES)
|
||||
|
||||
|
||||
def cloud_may_write(key: str) -> bool:
|
||||
return key in CLOUD_COMMANDS or not is_local_param(key)
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Local mode params and shared wire constants."""
|
||||
from __future__ import annotations
|
||||
|
||||
from openpilot.common.params import Params
|
||||
|
||||
SUNNYLINK_LOCAL_UDP_PORT = 53133
|
||||
SUNNYLINK_LOCAL_WS_PORT = 8443
|
||||
|
||||
BEACON_PREFIX = "SUNNYLINK1"
|
||||
|
||||
LOCAL_APPS_KEY = "SunnylinkLocalApps"
|
||||
LOCAL_APPS_META_KEY = "SunnylinkLocalAppsMeta"
|
||||
|
||||
REVOKE_KEY = "SunnylinkLocalRevoke"
|
||||
# Value of REVOKE_KEY that drops every grant instead of one.
|
||||
REVOKE_ALL = "ALL"
|
||||
|
||||
|
||||
def is_locally_paired(params: Params | None = None) -> bool:
|
||||
return bool((params or Params()).get(LOCAL_APPS_KEY))
|
||||
|
||||
|
||||
def format_endpoint(host: str, ws_port: int = SUNNYLINK_LOCAL_WS_PORT) -> str:
|
||||
return f"ws://{host}:{ws_port}"
|
||||
@@ -1,354 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
from collections.abc import Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from websocket import WebSocket, WebSocketTimeoutException
|
||||
|
||||
from openpilot.system.athena.rpc import dispatcher
|
||||
from openpilot.system.athena import rpc as rpc_module
|
||||
from openpilot.common.realtime import set_core_affinity
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.sunnypilot.sunnylink.athena import local_auth_daemon
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
|
||||
AUTHENTICATE_PURPOSE,
|
||||
LOCAL_METHODS,
|
||||
VERSION,
|
||||
AuthorizationError,
|
||||
AuthorizedSession,
|
||||
LocalAuthority,
|
||||
connect_pinned,
|
||||
parse_object,
|
||||
)
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_discovery import LocalDiscovery
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_params import is_local_param
|
||||
from openpilot.sunnypilot.sunnylink.athena.sunnylinkd import SUNNYLINK_RECONNECT_TIMEOUT_S, Sunnylinkd, log_connection_error
|
||||
|
||||
LOCAL_ENABLED_PARAM = "SunnylinkLocalEnabled"
|
||||
SESSION_READ_TIMEOUT_S = 5
|
||||
TICK_INTERVAL_S = 1.0
|
||||
ENDPOINT_BACKOFF_S = float(TICK_INTERVAL_S)
|
||||
ENDPOINT_BACKOFF_MAX_S = 60.0
|
||||
BACKOFF_FORGET_S = 12.0
|
||||
MAX_LOCAL_SESSIONS = 8
|
||||
|
||||
|
||||
class LocalSunnylinkd(Sunnylinkd):
|
||||
def __init__(self, params=None, discovery: LocalDiscovery | None = None):
|
||||
super().__init__(params)
|
||||
self.discovery = discovery if discovery is not None else LocalDiscovery()
|
||||
self.backoffs: dict[str, float] = {}
|
||||
self.failures: dict[str, int] = {}
|
||||
self._last_unknown_beacons: set[str] = set()
|
||||
self.stop_event = threading.Event()
|
||||
self._state_lock = threading.Lock()
|
||||
self._workers: dict[str, threading.Thread] = {}
|
||||
|
||||
def enrolled(self) -> bool:
|
||||
return bool(local_auth_daemon.grants()) or local_auth_daemon.pending_enrollment() is not None
|
||||
|
||||
def saveParams(self, params_to_update: dict[str, str], compression: bool = False) -> None:
|
||||
allowed = {k: v for k, v in params_to_update.items() if not is_local_param(k)}
|
||||
if len(allowed) != len(params_to_update):
|
||||
cloudlog.warning("sunnylinkd.local.saveParams.refused_local_params")
|
||||
if allowed:
|
||||
super().saveParams(allowed, compression)
|
||||
|
||||
def serviceable(self) -> bool:
|
||||
return self.params.get_bool("SunnylinkEnabled") and not self.params.get_bool("SunnylinkTempFault") \
|
||||
and self.params.get_bool(LOCAL_ENABLED_PARAM)
|
||||
|
||||
def run(self, exit_event: threading.Event | None = None) -> None:
|
||||
self.discovery.start()
|
||||
threading.Thread(target=self._tick_loop, name="localsunnylinkd_tick", daemon=True).start()
|
||||
|
||||
try:
|
||||
while (exit_event is None or not exit_event.is_set()) and self.serviceable():
|
||||
for endpoint in self.pick_targets():
|
||||
self.start_session(endpoint, exit_event)
|
||||
time.sleep(TICK_INTERVAL_S)
|
||||
finally:
|
||||
self.stop_event.set()
|
||||
self.discovery.stop()
|
||||
|
||||
def _tick_loop(self) -> None:
|
||||
while not self.stop_event.wait(TICK_INTERVAL_S) and self.serviceable():
|
||||
try:
|
||||
local_auth_daemon.service_revoke_requests()
|
||||
local_auth_daemon.claim_cloud_commands()
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.local.tick_failed")
|
||||
|
||||
def known_app_ids(self) -> set[str]:
|
||||
ids = {g["app_id"] for g in local_auth_daemon.grants()}
|
||||
pending = local_auth_daemon.pending_enrollment()
|
||||
if pending is not None:
|
||||
ids.add(pending["app_id"])
|
||||
return ids
|
||||
|
||||
def pick_targets(self) -> list[str]:
|
||||
"""Every endpoint worth dialing right now, a phone that is still PAIRING ahead of the rest.
|
||||
|
||||
A car knows more than one phone (an iPhone and an Android side by side is the normal case)
|
||||
and each of them is an independent session, so "the next target" is a list rather than one
|
||||
address. The pending enrollment goes first: that is the phone whose app is showing
|
||||
"registration sent", and it must not queue behind phones that are already paired.
|
||||
"""
|
||||
if not self.enrolled():
|
||||
return []
|
||||
known = self.known_app_ids()
|
||||
pending = local_auth_daemon.pending_enrollment()
|
||||
pending_app_id = pending["app_id"] if pending is not None else None
|
||||
self.forget_absent_endpoints(set(pinned_endpoints(self.discovery.fresh_endpoints(BACKOFF_FORGET_S))))
|
||||
now = time.monotonic()
|
||||
pairing: list[str] = []
|
||||
others: list[str] = []
|
||||
unknown: list[str] = []
|
||||
for endpoint, app_id in self.discovery.fresh_peers():
|
||||
if app_id not in known:
|
||||
unknown.append(app_id)
|
||||
continue
|
||||
pinned = pinned_endpoint(endpoint)
|
||||
if pinned is None or self.is_backed_off(pinned, now) or self.has_session(pinned):
|
||||
continue
|
||||
(pairing if app_id == pending_app_id else others).append(pinned)
|
||||
if unknown:
|
||||
seen = {a[:8] for a in unknown}
|
||||
if seen != self._last_unknown_beacons:
|
||||
self._last_unknown_beacons = seen
|
||||
cloudlog.debug(f"sunnylinkd.local.no_known_peer beacons={len(unknown)} unknown={sorted(seen)}")
|
||||
else:
|
||||
self._last_unknown_beacons = set()
|
||||
return pairing + others
|
||||
|
||||
def pick_target(self) -> str | None:
|
||||
targets = self.pick_targets()
|
||||
return targets[0] if targets else None
|
||||
|
||||
def is_backed_off(self, endpoint: str, now: float) -> bool:
|
||||
with self._state_lock:
|
||||
return self.backoffs.get(endpoint, 0.0) > now
|
||||
|
||||
def has_session(self, endpoint: str) -> bool:
|
||||
with self._state_lock:
|
||||
worker = self._workers.get(endpoint)
|
||||
return worker is not None and worker.is_alive()
|
||||
|
||||
def start_session(self, endpoint: str, exit_event: threading.Event | None) -> bool:
|
||||
"""Serve [endpoint] on its own thread, unless one is already serving it.
|
||||
|
||||
Sessions are long-lived: the phone keeps the connection for as long as its app is open so
|
||||
its reads can be answered over the LAN. They therefore cannot share the dial loop, or the
|
||||
first phone to connect is the only phone that ever will be.
|
||||
"""
|
||||
with self._state_lock:
|
||||
existing = self._workers.get(endpoint)
|
||||
if existing is not None and existing.is_alive():
|
||||
return False
|
||||
if len(self._workers) >= MAX_LOCAL_SESSIONS:
|
||||
cloudlog.warning("sunnylinkd.local.sessions.saturated")
|
||||
return False
|
||||
worker = threading.Thread(
|
||||
target=self._session_thread,
|
||||
args=(endpoint, exit_event),
|
||||
name=f"localsunnylinkd_session_{endpoint}",
|
||||
daemon=True,
|
||||
)
|
||||
self._workers[endpoint] = worker
|
||||
worker.start()
|
||||
return True
|
||||
|
||||
def _session_thread(self, endpoint: str, exit_event: threading.Event | None) -> None:
|
||||
try:
|
||||
self.serve(endpoint, exit_event)
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.local.session_thread_failed")
|
||||
finally:
|
||||
with self._state_lock:
|
||||
if self._workers.get(endpoint) is threading.current_thread():
|
||||
del self._workers[endpoint]
|
||||
|
||||
def forget_absent_endpoints(self, present: set[str]) -> None:
|
||||
with self._state_lock:
|
||||
for endpoint in [k for k in self.backoffs if k not in present]:
|
||||
self.backoffs.pop(endpoint, None)
|
||||
self.failures.pop(endpoint, None)
|
||||
|
||||
def _note_failure(self, endpoint: str) -> None:
|
||||
with self._state_lock:
|
||||
failures = self.failures.get(endpoint, 0) + 1
|
||||
self.failures[endpoint] = failures
|
||||
self.backoffs[endpoint] = time.monotonic() + min(ENDPOINT_BACKOFF_S * (2 ** (failures - 1)), ENDPOINT_BACKOFF_MAX_S)
|
||||
|
||||
def _note_success(self, endpoint: str) -> None:
|
||||
with self._state_lock:
|
||||
self.backoffs.pop(endpoint, None)
|
||||
self.failures.pop(endpoint, None)
|
||||
|
||||
def serve(self, endpoint: str, exit_event: threading.Event | None) -> None:
|
||||
try:
|
||||
self.dial(endpoint, exit_event)
|
||||
except Exception as e:
|
||||
self._note_failure(endpoint)
|
||||
# LastSunnylinkPingTime belongs to the CLOUD link: sunnylinkd's pong handler and SunnylinkApi
|
||||
# own it, and the sidebar reads it to decide ONLINE/ERROR/OFFLINE. Clearing it here on a
|
||||
# failed LOCAL dial made a perfectly healthy cloud connection read as offline on the device
|
||||
# for as long as the next cloud ping took — which is what switching local mode off on the
|
||||
# phone looked like from the car.
|
||||
log_connection_error(e)
|
||||
return
|
||||
self._note_success(endpoint)
|
||||
|
||||
def serve_rpc(self, ws: WebSocket, methods: dict[str, Callable[..., object]],
|
||||
exit_event: threading.Event | None = None) -> None:
|
||||
ws.settimeout(SESSION_READ_TIMEOUT_S)
|
||||
while exit_event is None or not exit_event.is_set():
|
||||
try:
|
||||
raw = ws.recv()
|
||||
except WebSocketTimeoutException:
|
||||
continue
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"sunnylinkd.session.{type(e).__name__}")
|
||||
return
|
||||
try:
|
||||
msg = rpc_module.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if not rpc_module.is_call(msg):
|
||||
continue
|
||||
try:
|
||||
response = rpc_module.handle(msg, methods)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
ws.send(response)
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"sunnylinkd.send.{type(e).__name__}")
|
||||
return
|
||||
|
||||
def dial(self, endpoint: str, exit_event: threading.Event | None) -> None:
|
||||
authority = local_auth_daemon.get_authority()
|
||||
if authority is None:
|
||||
raise AuthorizationError("enrollment unavailable")
|
||||
|
||||
with authority.lock:
|
||||
pending_key_id = authority.pending.app_id if authority.pending is not None else None
|
||||
candidates = list(dict.fromkeys(([pending_key_id] if pending_key_id is not None else []) + list(authority.grants)))
|
||||
|
||||
ws: WebSocket | None = None
|
||||
matched: str | None = None
|
||||
last_error: Exception | None = None
|
||||
cloudlog.debug(f"sunnylinkd.local.dial {endpoint} candidates={len(candidates)}")
|
||||
for key_id in candidates:
|
||||
try:
|
||||
ws = connect_pinned(endpoint, key_id)
|
||||
matched = key_id
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if ws is None or matched is None:
|
||||
raise last_error or AuthorizationError("no enrolled app at this endpoint")
|
||||
|
||||
session: AuthorizedSession | None = None
|
||||
try:
|
||||
self.authenticate_phone(ws, authority, matched)
|
||||
if pending_key_id is not None and matched == pending_key_id:
|
||||
try:
|
||||
local_auth_daemon.confirm_enrollment_tls(matched)
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.local.confirm_failed")
|
||||
local_auth_daemon.clear_pending_enrollment()
|
||||
raise
|
||||
with authority.lock:
|
||||
grant = authority.grants.get(matched)
|
||||
if grant is None:
|
||||
raise AuthorizationError("peer is not enrolled")
|
||||
session = authority.session(grant, matched)
|
||||
local_auth_daemon.note_grant_seen(grant.app_id)
|
||||
cloudlog.event("sunnylinkd.local.session_opened", endpoint=endpoint, app_id=grant.app_id)
|
||||
self.serve_rpc(ws, self.session_methods(session), exit_event)
|
||||
finally:
|
||||
if session is not None:
|
||||
session.close()
|
||||
if ws is not None:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def authenticate_phone(self, ws: WebSocket, authority: LocalAuthority, phone_key_id: str) -> None:
|
||||
ws.settimeout(SUNNYLINK_RECONNECT_TIMEOUT_S)
|
||||
raw = ws.recv()
|
||||
if not isinstance(raw, str):
|
||||
raise AuthorizationError("invalid challenge")
|
||||
data = parse_object(raw)
|
||||
if set(data) != {"v", "purpose", "phone_key_id", "challenge"} \
|
||||
or type(data["v"]) is not int or data["v"] != VERSION \
|
||||
or data["purpose"] != AUTHENTICATE_PURPOSE \
|
||||
or data["phone_key_id"] != phone_key_id:
|
||||
raise AuthorizationError("invalid challenge")
|
||||
ws.send(json.dumps(authority.authenticate_device(phone_key_id, data["challenge"]), separators=(",", ":")))
|
||||
|
||||
def session_methods(self, session: AuthorizedSession) -> dict[str, Callable[..., object]]:
|
||||
def gated(name: str, handler: Callable[..., object]) -> Callable[..., object]:
|
||||
def call(*args: object, **kwargs: object) -> object:
|
||||
return session.invoke(name, handler, *args, **kwargs)
|
||||
return call
|
||||
|
||||
def unpair_local_app(app_id: str = "") -> dict[str, bool]:
|
||||
removed = local_auth_daemon.revoke_local_app(session.grant.app_id)
|
||||
return {"success": bool(removed), "removed": bool(removed)}
|
||||
|
||||
def update_local_app_alias(app_id: str = "", alias: str = "") -> dict[str, bool | str]:
|
||||
try:
|
||||
local_auth_daemon.set_grant_alias(session.grant.app_id, alias)
|
||||
except AuthorizationError:
|
||||
return {"success": False, "error": "invalid alias"}
|
||||
return {"success": True, "updated": True}
|
||||
|
||||
local = {"unpairLocalApp": unpair_local_app, "updateLocalAppAlias": update_local_app_alias}
|
||||
surface = self.methods()
|
||||
methods: dict[str, Callable[..., object]] = {}
|
||||
for name in LOCAL_METHODS:
|
||||
handler = local.get(name) or surface.get(name) or dispatcher.get(name)
|
||||
if handler is not None:
|
||||
methods[name] = gated(name, handler)
|
||||
return methods
|
||||
|
||||
|
||||
def pinned_endpoint(beacon_endpoint: str) -> str | None:
|
||||
parsed = urlsplit(beacon_endpoint)
|
||||
if parsed.scheme != "ws" or parsed.hostname is None or parsed.port is None:
|
||||
return None
|
||||
return f"wss://{parsed.hostname}:{parsed.port}"
|
||||
|
||||
|
||||
def pinned_endpoints(beacons: list[str]) -> list[str]:
|
||||
return [pinned for pinned in map(pinned_endpoint, beacons) if pinned is not None]
|
||||
|
||||
|
||||
def close_quietly(ws: WebSocket | None) -> None:
|
||||
if ws is None:
|
||||
return
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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")
|
||||
|
||||
LocalSunnylinkd().run(exit_event)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -16,7 +16,6 @@ import ssl
|
||||
import threading
|
||||
import time
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from openpilot.system.athena.rpc import dispatcher
|
||||
from openpilot.common.params import Params, ParamKeyType
|
||||
@@ -35,7 +34,6 @@ 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, CAR_CAPABILITY_FIELDS
|
||||
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import generate_schema
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_params import cloud_may_write, is_local_param
|
||||
|
||||
SUNNYLINK_ATHENA_HOST = os.getenv('SUNNYLINK_ATHENA_HOST', 'wss://athena.sunnylink.ai')
|
||||
HANDLER_THREADS = int(os.getenv('HANDLER_THREADS', "4"))
|
||||
@@ -44,6 +42,8 @@ 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()
|
||||
|
||||
params = Params()
|
||||
|
||||
# Parameters that should never be remotely modified
|
||||
BLOCKED_PARAMS = {
|
||||
"AdbEnabled",
|
||||
@@ -58,6 +58,60 @@ BLOCKED_PARAMS = {
|
||||
}
|
||||
|
||||
|
||||
def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None:
|
||||
cloudlog.info("sunnylinkd.handle_long_poll started")
|
||||
sm = messaging.SubMaster(['deviceState'])
|
||||
end_event = threading.Event()
|
||||
comma_prime_cellular_end_event = threading.Event()
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=ws_manage, args=(ws, end_event), name='ws_manage'),
|
||||
threading.Thread(target=ws_recv, args=(ws, end_event), name='ws_recv'),
|
||||
threading.Thread(target=ws_send, args=(ws, end_event), name='ws_send'),
|
||||
threading.Thread(target=ws_ping, args=(ws, end_event), name='ws_ping'),
|
||||
threading.Thread(target=upload_handler, args=(end_event,), name='upload_handler'),
|
||||
threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'),
|
||||
threading.Thread(target=stat_handler, args=(end_event, Paths.stats_sp_root(), True), name='stat_handler'),
|
||||
] + [
|
||||
threading.Thread(target=jsonrpc_handler, args=(end_event, partial(startLocalProxy, end_event),), name=f'worker_{x}')
|
||||
for x in range(HANDLER_THREADS)
|
||||
]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
try:
|
||||
while not end_event.wait(0.1):
|
||||
if not sunnylink_ready(params):
|
||||
cloudlog.warning("Exiting sunnylinkd.handle_long_poll as SunnylinkEnabled is False")
|
||||
break
|
||||
|
||||
sm.update(0)
|
||||
if exit_event is not None and exit_event.is_set():
|
||||
end_event.set()
|
||||
comma_prime_cellular_end_event.set()
|
||||
|
||||
prime_type = params.get("PrimeType") or 0
|
||||
metered = sm['deviceState'].networkMetered
|
||||
|
||||
if DISALLOW_LOG_UPLOAD.is_set() and not comma_prime_cellular_end_event.is_set():
|
||||
cloudlog.debug("sunnylinkd.handle_long_poll: DISALLOW_LOG_UPLOAD, setting comma_prime_cellular_end_event")
|
||||
comma_prime_cellular_end_event.set()
|
||||
elif metered and int(prime_type) > 2:
|
||||
cloudlog.debug(f"sunnylinkd.handle_long_poll: PrimeType({prime_type}) > 2 and networkMetered({metered})")
|
||||
comma_prime_cellular_end_event.set()
|
||||
elif comma_prime_cellular_end_event.is_set() and not DISALLOW_LOG_UPLOAD.is_set():
|
||||
cloudlog.debug(
|
||||
f"sunnylinkd.handle_long_poll: comma_prime_cellular_end_event is set and not PrimeType({prime_type}) > 2 or not networkMetered({metered})")
|
||||
comma_prime_cellular_end_event.clear()
|
||||
finally:
|
||||
end_event.set()
|
||||
comma_prime_cellular_end_event.set()
|
||||
for thread in threads:
|
||||
cloudlog.debug(f"sunnylinkd athena.joining {thread.name}")
|
||||
thread.join()
|
||||
cloudlog.debug(f"sunnylinkd athena.joined {thread.name}")
|
||||
|
||||
|
||||
def ws_recv(ws: WebSocket, end_event: threading.Event) -> None:
|
||||
last_ping = int(time.monotonic() * 1e9)
|
||||
while not end_event.is_set():
|
||||
@@ -104,171 +158,113 @@ def sunny_log_handler(end_event: threading.Event, comma_prime_cellular_end_event
|
||||
comma_prime_cellular_end_event.set()
|
||||
|
||||
|
||||
class Sunnylinkd:
|
||||
"""Cloud connection serving the settings RPCs."""
|
||||
@dispatcher.add_method
|
||||
def toggleLogUpload(enabled: bool):
|
||||
DISALLOW_LOG_UPLOAD.clear() if enabled and DISALLOW_LOG_UPLOAD.is_set() else DISALLOW_LOG_UPLOAD.set()
|
||||
|
||||
RPC_METHODS = ("toggleLogUpload", "getParamsAllKeys", "getParamsMetadata", "getParams", "saveParams")
|
||||
|
||||
def __init__(self, params: Params | None = None):
|
||||
self.params = params or Params()
|
||||
@dispatcher.add_method
|
||||
def getParamsAllKeys() -> list[str]:
|
||||
keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()]
|
||||
return keys
|
||||
|
||||
def methods(self) -> dict[str, Callable[..., object]]:
|
||||
return {name: getattr(self, name) for name in self.RPC_METHODS}
|
||||
|
||||
def handle_long_poll(self, ws: WebSocket, exit_event: threading.Event | None) -> None:
|
||||
cloudlog.info("sunnylinkd.handle_long_poll started")
|
||||
sm = messaging.SubMaster(['deviceState'])
|
||||
end_event = threading.Event()
|
||||
comma_prime_cellular_end_event = threading.Event()
|
||||
@dispatcher.add_method
|
||||
def getParamsMetadata() -> str:
|
||||
"""Return settings_ui.json + live capabilities as gzip-compressed, base64-encoded string.
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=ws_manage, args=(ws, end_event), name='ws_manage'),
|
||||
threading.Thread(target=ws_recv, args=(ws, end_event), name='ws_recv'),
|
||||
threading.Thread(target=ws_send, args=(ws, end_event), name='ws_send'),
|
||||
threading.Thread(target=ws_ping, args=(ws, end_event), name='ws_ping'),
|
||||
threading.Thread(target=upload_handler, args=(end_event,), name='upload_handler'),
|
||||
threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'),
|
||||
threading.Thread(target=stat_handler, args=(end_event, Paths.stats_sp_root(), True), name='stat_handler'),
|
||||
] + [
|
||||
threading.Thread(target=jsonrpc_handler, args=(end_event, partial(self.startLocalProxy, end_event),), name=f'worker_{x}')
|
||||
for x in range(HANDLER_THREADS)
|
||||
]
|
||||
Reads settings_ui.json, injects live capabilities from CarParams, compresses,
|
||||
and returns. Single RPC for the frontend to get the complete settings UI and
|
||||
runtime capabilities.
|
||||
"""
|
||||
try:
|
||||
schema = generate_schema()
|
||||
schema["capabilities"] = generate_capabilities()
|
||||
schema["capability_labels"] = CAPABILITY_LABELS
|
||||
schema["car_capability_fields"] = CAR_CAPABILITY_FIELDS
|
||||
schema["default_model"] = DEFAULT_MODEL
|
||||
schema["default_big_model"] = DEFAULT_BIG_MODEL
|
||||
schema["chestnut_active"] = params.get_bool("ChestnutActive")
|
||||
raw = json.dumps(schema, separators=(",", ":")).encode("utf-8")
|
||||
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.getParamsMetadata.exception")
|
||||
raise
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
try:
|
||||
while not end_event.wait(0.1):
|
||||
if not sunnylink_ready(self.params):
|
||||
cloudlog.warning("Exiting sunnylinkd.handle_long_poll as SunnylinkEnabled is False")
|
||||
break
|
||||
|
||||
sm.update(0)
|
||||
if exit_event is not None and exit_event.is_set():
|
||||
end_event.set()
|
||||
comma_prime_cellular_end_event.set()
|
||||
@dispatcher.add_method
|
||||
def getParams(params_keys: list[str], compression: bool = False) -> str | dict[str, str]:
|
||||
params = Params()
|
||||
available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()]
|
||||
|
||||
prime_type = self.params.get("PrimeType") or 0
|
||||
metered = sm['deviceState'].networkMetered
|
||||
try:
|
||||
zero_values: dict[int, bytes] = {
|
||||
ParamKeyType.STRING.value: b"",
|
||||
ParamKeyType.BOOL.value: b"0",
|
||||
ParamKeyType.INT.value: b"0",
|
||||
ParamKeyType.FLOAT.value: b"0.0",
|
||||
ParamKeyType.TIME.value: b"",
|
||||
ParamKeyType.JSON.value: b"{}",
|
||||
ParamKeyType.BYTES.value: b"",
|
||||
}
|
||||
|
||||
if DISALLOW_LOG_UPLOAD.is_set() and not comma_prime_cellular_end_event.is_set():
|
||||
cloudlog.debug("sunnylinkd.handle_long_poll: DISALLOW_LOG_UPLOAD, setting comma_prime_cellular_end_event")
|
||||
comma_prime_cellular_end_event.set()
|
||||
elif metered and int(prime_type) > 2:
|
||||
cloudlog.debug(f"sunnylinkd.handle_long_poll: PrimeType({prime_type}) > 2 and networkMetered({metered})")
|
||||
comma_prime_cellular_end_event.set()
|
||||
elif comma_prime_cellular_end_event.is_set() and not DISALLOW_LOG_UPLOAD.is_set():
|
||||
cloudlog.debug(
|
||||
f"sunnylinkd.handle_long_poll: comma_prime_cellular_end_event is set and not PrimeType({prime_type}) > 2 or not networkMetered({metered})")
|
||||
comma_prime_cellular_end_event.clear()
|
||||
finally:
|
||||
end_event.set()
|
||||
comma_prime_cellular_end_event.set()
|
||||
for thread in threads:
|
||||
cloudlog.debug(f"sunnylinkd athena.joining {thread.name}")
|
||||
thread.join()
|
||||
cloudlog.debug(f"sunnylinkd athena.joined {thread.name}")
|
||||
param_keys_validated = [key for key in params_keys if key in available_keys]
|
||||
params_dict: dict[str, list[dict[str, str | bool | int]]] = {"params": []}
|
||||
for key in param_keys_validated:
|
||||
value = get_param_as_byte(key)
|
||||
if value is None:
|
||||
value = get_param_as_byte(key, get_default=True)
|
||||
if value is None:
|
||||
param_type = params.get_type(key)
|
||||
value = zero_values.get(param_type.value, b"")
|
||||
|
||||
def toggleLogUpload(self, enabled: bool):
|
||||
DISALLOW_LOG_UPLOAD.clear() if enabled and DISALLOW_LOG_UPLOAD.is_set() else DISALLOW_LOG_UPLOAD.set()
|
||||
params_dict["params"].append({
|
||||
"key": key,
|
||||
"value": base64.b64encode(gzip.compress(value) if compression else value).decode('utf-8'),
|
||||
"type": int(params.get_type(key).value),
|
||||
"is_compressed": compression
|
||||
})
|
||||
|
||||
def getParamsAllKeys(self) -> list[str]:
|
||||
keys: list[str] = [k.decode('utf-8') for k in self.params.all_keys()]
|
||||
return [key for key in keys if not is_local_param(key)]
|
||||
response = {str(param.get('key')): str(param.get('value')) for param in params_dict.get("params", [])}
|
||||
response |= {"params": json.dumps(params_dict.get("params", []))} # Upcoming for settings v1
|
||||
return response
|
||||
|
||||
def getParamsMetadata(self) -> str:
|
||||
try:
|
||||
schema = generate_schema()
|
||||
schema["capabilities"] = generate_capabilities()
|
||||
schema["capability_labels"] = CAPABILITY_LABELS
|
||||
schema["car_capability_fields"] = CAR_CAPABILITY_FIELDS
|
||||
schema["default_model"] = DEFAULT_MODEL
|
||||
schema["default_big_model"] = DEFAULT_BIG_MODEL
|
||||
schema["chestnut_active"] = self.params.get_bool("ChestnutActive")
|
||||
raw = json.dumps(schema, separators=(",", ":")).encode("utf-8")
|
||||
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.getParamsMetadata.exception")
|
||||
raise
|
||||
except Exception as e:
|
||||
cloudlog.exception("sunnylinkd.getParams.exception", e)
|
||||
raise
|
||||
|
||||
def getParams(self, params_keys: list[str], compression: bool = False) -> str | dict[str, str]:
|
||||
available_keys: list[str] = [k.decode('utf-8') for k in self.params.all_keys()]
|
||||
|
||||
@dispatcher.add_method
|
||||
def saveParams(params_to_update: dict[str, str], compression: bool = False) -> None:
|
||||
for key, value in params_to_update.items():
|
||||
# disallow modifications to blocked parameters
|
||||
if key in BLOCKED_PARAMS:
|
||||
cloudlog.warning(f"sunnylinkd.saveParams.blocked: Attempted to modify blocked parameter '{key}'")
|
||||
continue
|
||||
|
||||
try:
|
||||
zero_values: dict[int, bytes] = {
|
||||
ParamKeyType.STRING.value: b"",
|
||||
ParamKeyType.BOOL.value: b"0",
|
||||
ParamKeyType.INT.value: b"0",
|
||||
ParamKeyType.FLOAT.value: b"0.0",
|
||||
ParamKeyType.TIME.value: b"",
|
||||
ParamKeyType.JSON.value: b"{}",
|
||||
ParamKeyType.BYTES.value: b"",
|
||||
}
|
||||
|
||||
param_keys_validated = [key for key in params_keys if key in available_keys and not is_local_param(key)]
|
||||
params_dict: dict[str, list[dict[str, str | bool | int]]] = {"params": []}
|
||||
for key in param_keys_validated:
|
||||
value = get_param_as_byte(key)
|
||||
if value is None:
|
||||
value = get_param_as_byte(key, get_default=True)
|
||||
if value is None:
|
||||
param_type = self.params.get_type(key)
|
||||
value = zero_values.get(param_type.value, b"")
|
||||
|
||||
params_dict["params"].append({
|
||||
"key": key,
|
||||
"value": base64.b64encode(gzip.compress(value) if compression else value).decode('utf-8'),
|
||||
"type": int(self.params.get_type(key).value),
|
||||
"is_compressed": compression
|
||||
})
|
||||
|
||||
response = {str(param.get('key')): str(param.get('value')) for param in params_dict.get("params", [])}
|
||||
response |= {"params": json.dumps(params_dict.get("params", []))} # Upcoming for settings v1
|
||||
return response
|
||||
|
||||
save_param_from_base64_encoded_string(key, value, compression)
|
||||
except Exception as e:
|
||||
cloudlog.exception("sunnylinkd.getParams.exception", e)
|
||||
raise
|
||||
cloudlog.error(f"sunnylinkd.saveParams.exception {e}")
|
||||
|
||||
def saveParams(self, params_to_update: dict[str, str], compression: bool = False) -> None:
|
||||
for key, value in params_to_update.items():
|
||||
if key in BLOCKED_PARAMS or not cloud_may_write(key):
|
||||
cloudlog.warning(f"sunnylinkd.saveParams.blocked: Attempted to modify blocked parameter '{key}'")
|
||||
continue
|
||||
|
||||
try:
|
||||
save_param_from_base64_encoded_string(key, value, compression)
|
||||
except Exception as e:
|
||||
cloudlog.error(f"sunnylinkd.saveParams.exception {e}")
|
||||
|
||||
# Increment version counter for frontend change detection
|
||||
try:
|
||||
current = int(self.params.get("ParamsVersion") or "0")
|
||||
self.params.put("ParamsVersion", str(current + 1), block=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def startLocalProxy(self, global_end_event: threading.Event, remote_ws_uri: str, local_port: int) -> dict[str, int]:
|
||||
sunnylink_dongle_id = self.params.get("SunnylinkDongleId")
|
||||
sunnylink_api = SunnylinkApi(sunnylink_dongle_id)
|
||||
|
||||
cloudlog.debug("athena.startLocalProxy.starting")
|
||||
ws = create_connection(
|
||||
remote_ws_uri, header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, enable_multithread=True, sslopt={"cert_reqs": ssl.CERT_NONE}
|
||||
)
|
||||
|
||||
return start_local_proxy_shim(global_end_event, local_port, ws)
|
||||
# Increment version counter for frontend change detection
|
||||
try:
|
||||
current = int(params.get("ParamsVersion") or "0")
|
||||
params.put("ParamsVersion", str(current + 1), block=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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")
|
||||
def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local_port: int) -> dict[str, int]:
|
||||
sunnylink_dongle_id = params.get("SunnylinkDongleId")
|
||||
sunnylink_api = SunnylinkApi(sunnylink_dongle_id)
|
||||
|
||||
cloudlog.debug("athena.startLocalProxy.starting")
|
||||
ws = create_connection(
|
||||
remote_ws_uri, header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, enable_multithread=True, sslopt={"cert_reqs": ssl.CERT_NONE}
|
||||
)
|
||||
|
||||
return start_local_proxy_shim(global_end_event, local_port, ws)
|
||||
|
||||
|
||||
def main(exit_event: threading.Event | None = None):
|
||||
@@ -277,12 +273,6 @@ def main(exit_event: threading.Event | None = None):
|
||||
except Exception:
|
||||
cloudlog.exception("failed to set core affinity")
|
||||
|
||||
daemon = Sunnylinkd()
|
||||
for method in daemon.methods().values():
|
||||
dispatcher.add_method(method)
|
||||
|
||||
params = daemon.params
|
||||
|
||||
while sunnylink_need_register(params):
|
||||
cloudlog.info("Waiting for sunnylink registration to complete")
|
||||
time.sleep(10)
|
||||
@@ -316,13 +306,22 @@ def main(exit_event: threading.Event | None = None):
|
||||
conn_retries = 0
|
||||
cur_upload_items.clear()
|
||||
|
||||
daemon.handle_long_poll(ws, exit_event)
|
||||
handle_long_poll(ws, exit_event)
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
break
|
||||
except Exception as e:
|
||||
conn_retries += 1
|
||||
params.remove("LastSunnylinkPingTime")
|
||||
log_connection_error(e)
|
||||
|
||||
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")
|
||||
|
||||
time.sleep(backoff(conn_retries))
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
from typing import cast
|
||||
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_app_rows import (
|
||||
DEFAULT_APP_NAME,
|
||||
MAX_LOCAL_APP_ROWS,
|
||||
NEVER_CONNECTED,
|
||||
build_local_app_rows,
|
||||
format_age,
|
||||
short_key,
|
||||
)
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_auth import MAX_GRANTS
|
||||
|
||||
|
||||
def grant(app_id: str, app_name: str = "Phone") -> dict[str, str]:
|
||||
return {"app_id": app_id, "app_name": app_name, "public_key": "x"}
|
||||
|
||||
|
||||
class TestLocalAppRows(OpenpilotTestCase):
|
||||
"""The device screen's rows: one per enrolled phone."""
|
||||
|
||||
def test_an_enrolled_phone_is_marked_secure(self):
|
||||
rows = build_local_app_rows([grant("key-a", "Nayan's phone")])
|
||||
self.assertEqual("Nayan's phone", rows[0].title)
|
||||
self.assertTrue(short_key("key-a") in rows[0].subtitle)
|
||||
self.assertTrue("secure" in rows[0].subtitle)
|
||||
|
||||
def test_a_nameless_grant_still_gets_a_title(self):
|
||||
rows = build_local_app_rows([{"app_id": "key-a", "app_name": ""}, {"app_id": "key-b"}])
|
||||
self.assertEqual([DEFAULT_APP_NAME, DEFAULT_APP_NAME], [row.title for row in rows])
|
||||
|
||||
def test_malformed_grants_are_skipped_rather_than_rendered(self):
|
||||
# The registry is JSON, so a hand-edited or older document can hold anything.
|
||||
malformed = cast(list[dict[str, str]], ["junk", {"app_name": "no id"},
|
||||
{"app_id": "", "app_name": "blank"}, grant("key-a")])
|
||||
rows = build_local_app_rows(malformed)
|
||||
self.assertEqual(["key-a"], [row.app_id for row in rows])
|
||||
|
||||
def test_every_authorized_phone_has_a_row(self):
|
||||
"""A phone the authority accepts must be visible: an invisible grant cannot be revoked."""
|
||||
rows = build_local_app_rows([grant(f"key-{i}") for i in range(MAX_GRANTS)])
|
||||
self.assertEqual(MAX_GRANTS, len(rows))
|
||||
self.assertEqual(MAX_GRANTS, MAX_LOCAL_APP_ROWS)
|
||||
self.assertEqual(MAX_LOCAL_APP_ROWS, len(build_local_app_rows([grant(f"key-{i}") for i in range(10)])))
|
||||
|
||||
def test_short_key_keeps_short_ids_readable(self):
|
||||
self.assertEqual("abc", short_key("abc"))
|
||||
self.assertEqual("abcdefghij…", short_key("abcdefghijklmnop"))
|
||||
|
||||
def test_a_phone_supplied_alias_wins_over_the_enrolled_name(self):
|
||||
rows = build_local_app_rows([grant("key-a", "Nayan's phone")], {"key-a": {"alias": "Garage phone"}})
|
||||
self.assertEqual("Garage phone", rows[0].title)
|
||||
# A blank or missing alias falls back to what the phone enrolled with.
|
||||
for meta in ({}, {"key-a": {}}, {"key-a": {"alias": " "}}, {"key-a": {"alias": 7}}):
|
||||
self.assertEqual("Nayan's phone", build_local_app_rows([grant("key-a", "Nayan's phone")], meta)[0].title)
|
||||
|
||||
def test_each_phone_reports_when_it_was_last_connected(self):
|
||||
rows = build_local_app_rows([grant("key-a"), grant("key-b")],
|
||||
{"key-a": {"last_seen": 1_000.0}}, now=1_120.0)
|
||||
self.assertTrue("2m ago" in rows[0].subtitle)
|
||||
self.assertTrue(NEVER_CONNECTED in rows[1].subtitle)
|
||||
|
||||
def test_a_stamp_from_before_the_last_reboot_reads_as_unknown(self):
|
||||
# Monotonic time restarts at boot, so a future-looking stamp is not "-5m ago".
|
||||
rows = build_local_app_rows([grant("key-a")], {"key-a": {"last_seen": 5_000.0}}, now=10.0)
|
||||
self.assertTrue(NEVER_CONNECTED in rows[0].subtitle)
|
||||
|
||||
def test_format_age_is_coarse_and_readable(self):
|
||||
self.assertEqual("just now", format_age(0))
|
||||
self.assertEqual("just now", format_age(59.9))
|
||||
self.assertEqual("5m ago", format_age(300))
|
||||
self.assertEqual("3h ago", format_age(3 * 3600))
|
||||
self.assertEqual("2d ago", format_age(2 * 86_400))
|
||||
@@ -1,428 +0,0 @@
|
||||
"""Q: what does the device need to remember so that a later dial proves the phone is still the same installation?
|
||||
|
||||
A grant — the phone's public key, pinned at enrollment — plus nothing that expires. There is no
|
||||
session nonce and no scan ceremony: enrollment is a self-signed statement the app pushes over the
|
||||
existing cloud settings-write transport (the app already knows the exact list of the user's devices
|
||||
from the signed-in cloud device lookup, so it enrolls each one it has never enrolled — on login,
|
||||
and again for newly-seen-online devices on manual refresh). The grant commits only when that phone
|
||||
proves possession of the private key on a pinned-TLS dial. Manual Pair is just the same idempotent
|
||||
push. An explicit unpair anywhere drops the grant, so the next dial re-authenticates from scratch.
|
||||
The device remembers grants, not refusals: a phone-side skip list stops the same install from
|
||||
re-enrolling an unpaired phone, but a fresh install has no memory of it.
|
||||
|
||||
This file pins that contract: staging is not authority (only a stored grant opens a session), the
|
||||
TLS peer match is the commit, and the phone's own signature is the only thing that can enroll or
|
||||
un-enroll it.
|
||||
"""
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
|
||||
AuthorizationError,
|
||||
Grant,
|
||||
LocalAuthority,
|
||||
Origin,
|
||||
decode64,
|
||||
encode64,
|
||||
key_id,
|
||||
statement,
|
||||
MAX_GRANTS,
|
||||
VERSION,
|
||||
)
|
||||
|
||||
# The fields an enrollment carries. The signature binds them in this order.
|
||||
ENROLL_FIELDS = ("cloud_device_id", "comma_device_id", "device_key_id",
|
||||
"public_key", "key_id", "app_name")
|
||||
|
||||
|
||||
def _spki(key) -> str:
|
||||
return encode64(key.public_key().public_bytes(
|
||||
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
|
||||
|
||||
|
||||
class TestV3Enrollment(OpenpilotTestCase):
|
||||
def setup_method(self):
|
||||
self.device_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.app_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.writes: list[dict] = []
|
||||
self.authority = LocalAuthority("cloud-device", "comma-device",
|
||||
self.device_key, self.writes.append)
|
||||
self.app_id = key_id(self.app_key.public_key())
|
||||
|
||||
def enrollment(self, authority=None, key=None, **overrides) -> str:
|
||||
authority = authority or self.authority
|
||||
key = key or self.app_key
|
||||
public = _spki(key)
|
||||
data = {"v": VERSION, "cloud_device_id": authority.cloud_device_id,
|
||||
"comma_device_id": authority.comma_device_id, "device_key_id": authority.device_key_id,
|
||||
"public_key": public, "key_id": key_id(key.public_key()), "app_name": "Test phone"}
|
||||
data.update(overrides)
|
||||
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
|
||||
data["signature"] = encode64(key.sign(proof, ec.ECDSA(hashes.SHA256())))
|
||||
return json.dumps(data)
|
||||
|
||||
def committed(self, raw=None, authority=None):
|
||||
authority = authority or self.authority
|
||||
grant = authority.enroll(raw or self.enrollment(), Origin.CLOUD)
|
||||
return authority.confirm(grant, grant.app_id)
|
||||
|
||||
def assert_session_denied(self, authority, grant, peer):
|
||||
with self.assertRaises(AuthorizationError):
|
||||
authority.session(grant, peer)
|
||||
|
||||
def test_a_staged_enrollment_is_not_yet_authorized(self):
|
||||
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
|
||||
self.assertEqual(self.app_id, grant.app_id)
|
||||
self.assert_session_denied(self.authority, grant, grant.app_id)
|
||||
# Nothing durable either: the grant does not exist until the TLS dial.
|
||||
self.assertEqual([], self.writes)
|
||||
self.assertIsNotNone(self.authority.pending)
|
||||
|
||||
def test_confirming_without_a_matching_enrollment_refuses(self):
|
||||
stranger = Grant("no-such-phone", _spki(self.app_key), "Nobody")
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.confirm(stranger, stranger.app_id)
|
||||
|
||||
def test_the_grant_commits_only_after_the_tls_dial(self):
|
||||
grant = self.committed()
|
||||
session = self.authority.session(grant, grant.app_id)
|
||||
self.assertEqual("ok", session.invoke("getParams", lambda: "ok"))
|
||||
self.assertEqual([{"v": VERSION, "grants": [grant.to_dict()]}], self.writes)
|
||||
|
||||
def test_confirming_with_the_wrong_tls_peer_refuses(self):
|
||||
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.confirm(grant, grant.app_id + "-other")
|
||||
self.assert_session_denied(self.authority, grant, grant.app_id)
|
||||
|
||||
def test_enrolling_over_the_local_lan_is_impossible(self):
|
||||
"""The staged path is cloud-only: a phone on the LAN cannot enroll itself mid-session."""
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.enroll(self.enrollment(), Origin.LOCAL)
|
||||
self.assertIsNone(self.authority.pending)
|
||||
|
||||
def test_there_is_no_pairing_window_to_arm(self):
|
||||
"""Pairing needs no on-screen step anymore: the authority has no window surface at all."""
|
||||
for name in ("arm", "window", "recent", "expire", "prune"):
|
||||
self.assertFalse(hasattr(self.authority, name), name)
|
||||
|
||||
def test_a_v2_enrollment_is_rejected(self):
|
||||
"""The QR-era payload shape is not valid anymore: legacy code was replaced, not kept."""
|
||||
data = json.loads(self.enrollment())
|
||||
data["v"] = 2
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.enroll(json.dumps(data), Origin.CLOUD)
|
||||
self.assertIsNone(self.authority.pending)
|
||||
|
||||
def test_a_pairing_session_field_is_rejected(self):
|
||||
data = json.loads(self.enrollment())
|
||||
data["pairing_session"] = "some-old-nonce"
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.enroll(json.dumps(data), Origin.CLOUD)
|
||||
self.assertIsNone(self.authority.pending)
|
||||
|
||||
def test_a_malformed_envelope_is_rejected(self):
|
||||
for raw in ("not json", "[1, 2]", json.dumps({"v": VERSION, "v2": VERSION}),
|
||||
"x" * 4097, json.dumps({"v": "3"}), json.dumps({"v": True})):
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.enroll(raw, Origin.CLOUD)
|
||||
self.assertIsNone(self.authority.pending)
|
||||
|
||||
def test_the_device_ignores_a_request_that_does_not_name_it(self):
|
||||
"""A push meant for another car stages nothing here."""
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.enroll(self.enrollment(comma_device_id="some-other-car"), Origin.CLOUD)
|
||||
self.assertIsNone(self.authority.pending)
|
||||
|
||||
def test_an_enrollment_cannot_be_replayed_against_another_car(self):
|
||||
other = LocalAuthority("cloud-other", "comma-other",
|
||||
ec.generate_private_key(ec.SECP256R1()), lambda registry: None)
|
||||
with self.assertRaises(AuthorizationError):
|
||||
other.enroll(self.enrollment(), Origin.CLOUD)
|
||||
self.assertIsNone(other.pending)
|
||||
|
||||
def test_same_installation_key_can_enroll_on_another_device(self):
|
||||
"""One phone, two cars: the same installation key enrolls independently on each device."""
|
||||
other = LocalAuthority("cloud-other", "comma-other",
|
||||
ec.generate_private_key(ec.SECP256R1()), lambda registry: None)
|
||||
grant = self.committed(self.enrollment(authority=other), authority=other)
|
||||
other.session(grant, grant.app_id)
|
||||
# …and that enrollment grants nothing on this car.
|
||||
self.assert_session_denied(self.authority, grant, grant.app_id)
|
||||
|
||||
def test_idempotent_reenroll_returns_the_stored_grant(self):
|
||||
"""Manual Pair on an already-paired phone is the same push: no duplicate, no new write."""
|
||||
grant = self.committed()
|
||||
again = self.authority.enroll(self.enrollment(), Origin.CLOUD)
|
||||
self.assertEqual(grant, again)
|
||||
self.assertEqual(1, len(self.writes))
|
||||
self.authority.session(again, again.app_id)
|
||||
|
||||
def test_a_second_pending_enrollment_is_refused(self):
|
||||
self.authority.enroll(self.enrollment(), Origin.CLOUD)
|
||||
other_key = ec.generate_private_key(ec.SECP256R1())
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.enroll(self.enrollment(key=other_key), Origin.CLOUD)
|
||||
# The first phone's enrollment is undisturbed and still committable.
|
||||
self.assertEqual(self.app_id, self.authority.pending.app_id)
|
||||
|
||||
def test_a_signature_that_covers_anything_else_is_rejected(self):
|
||||
"""The signature binds every field: changing anything after signing voids the enrollment."""
|
||||
for field in ENROLL_FIELDS:
|
||||
raw = json.loads(self.enrollment())
|
||||
raw[field] = "tampered"
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.enroll(json.dumps(raw), Origin.CLOUD)
|
||||
self.assertIsNone(self.authority.pending)
|
||||
|
||||
def test_staging_a_second_phone_does_not_disturb_the_first(self):
|
||||
first = self.committed()
|
||||
other_key = ec.generate_private_key(ec.SECP256R1())
|
||||
second = self.authority.enroll(self.enrollment(key=other_key), Origin.CLOUD)
|
||||
self.authority.session(first, first.app_id)
|
||||
self.assert_session_denied(self.authority, second, second.app_id)
|
||||
self.authority.confirm(second, second.app_id)
|
||||
self.authority.session(second, second.app_id)
|
||||
|
||||
def test_grants_stop_at_the_limit(self):
|
||||
for _ in range(MAX_GRANTS):
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
grant = self.authority.enroll(self.enrollment(key=key), Origin.CLOUD)
|
||||
self.authority.confirm(grant, grant.app_id)
|
||||
extra = ec.generate_private_key(ec.SECP256R1())
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.enroll(self.enrollment(key=extra), Origin.CLOUD)
|
||||
self.assertIsNone(self.authority.pending)
|
||||
|
||||
def test_the_statement_can_be_read_backwards_from_the_wire(self):
|
||||
proof = statement("enroll", "cloud", "comma", "device-key", "key", "key-id", "Name")
|
||||
self.assertEqual(b"sunnylink-local\nenroll\ncloud\ncomma\ndevice-key\nkey\nkey-id\nName\n", proof)
|
||||
|
||||
|
||||
class TestAuthorizedSession(OpenpilotTestCase):
|
||||
def setup_method(self):
|
||||
self.device_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.app_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.writes: list[dict] = []
|
||||
self.authority = LocalAuthority("cloud-device", "comma-device",
|
||||
self.device_key, self.writes.append)
|
||||
self.app_id = key_id(self.app_key.public_key())
|
||||
grant = self.authority.enroll(self._enrollment(), Origin.CLOUD)
|
||||
self.grant = self.authority.confirm(grant, grant.app_id)
|
||||
|
||||
def _enrollment(self) -> str:
|
||||
public = _spki(self.app_key)
|
||||
data = {"v": VERSION, "cloud_device_id": "cloud-device", "comma_device_id": "comma-device",
|
||||
"device_key_id": self.authority.device_key_id, "public_key": public,
|
||||
"key_id": self.app_id, "app_name": "Test phone"}
|
||||
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
|
||||
data["signature"] = encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
|
||||
return json.dumps(data)
|
||||
|
||||
def test_a_stranger_is_not_authorized(self):
|
||||
stranger = Grant("no-such-phone", _spki(self.app_key), "Nobody")
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.session(stranger, stranger.app_id)
|
||||
|
||||
def test_a_known_installation_but_unknown_dial_is_not_authorized(self):
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.session(self.grant, self.app_id + "-other")
|
||||
|
||||
def test_a_second_car_does_not_share_this_cars_grants(self):
|
||||
other = LocalAuthority("cloud-other", "comma-other",
|
||||
ec.generate_private_key(ec.SECP256R1()), lambda registry: None)
|
||||
with self.assertRaises(AuthorizationError):
|
||||
other.session(self.grant, self.app_id)
|
||||
|
||||
def test_a_wrong_key_for_a_paired_app_id_is_not_authorized(self):
|
||||
impostor = Grant(self.app_id, _spki(ec.generate_private_key(ec.SECP256R1())), "Test phone")
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.session(impostor, self.app_id)
|
||||
|
||||
def test_an_unpaired_installation_is_not_authorized(self):
|
||||
self.authority.revoke(self.app_id)
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.session(self.grant, self.app_id)
|
||||
|
||||
def test_a_restarted_authority_keeps_its_grants(self):
|
||||
restarted = LocalAuthority("cloud-device", "comma-device",
|
||||
self.device_key, lambda registry: None, registry=self.writes[-1])
|
||||
session = restarted.session(self.grant, self.app_id)
|
||||
self.assertEqual("ok", session.invoke("getParams", lambda: "ok"))
|
||||
|
||||
def test_a_closed_session_refuses_calls(self):
|
||||
session = self.authority.session(self.grant, self.app_id)
|
||||
session.close()
|
||||
with self.assertRaises(AuthorizationError):
|
||||
session.invoke("getParams", lambda: "ok")
|
||||
|
||||
def test_a_method_outside_the_local_surface_is_refused(self):
|
||||
session = self.authority.session(self.grant, self.app_id)
|
||||
with self.assertRaises(AuthorizationError):
|
||||
session.invoke("startLocalProxy", mock.Mock())
|
||||
|
||||
def test_revoking_mid_session_kills_the_session(self):
|
||||
session = self.authority.session(self.grant, self.app_id)
|
||||
self.authority.revoke(self.app_id)
|
||||
with self.assertRaises(AuthorizationError):
|
||||
session.invoke("getParams", mock.Mock())
|
||||
|
||||
|
||||
class TestGrantPersistence(OpenpilotTestCase):
|
||||
def setup_method(self):
|
||||
self.device_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.app_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.writes: list[dict] = []
|
||||
|
||||
def _committed(self, authority=None):
|
||||
authority = authority or LocalAuthority(
|
||||
"cloud-device", "comma-device", self.device_key, self.writes.append)
|
||||
public = _spki(self.app_key)
|
||||
data = {"v": VERSION, "cloud_device_id": "cloud-device", "comma_device_id": "comma-device",
|
||||
"device_key_id": authority.device_key_id, "public_key": public,
|
||||
"key_id": key_id(self.app_key.public_key()), "app_name": "Test phone"}
|
||||
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
|
||||
data["signature"] = encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
|
||||
grant = authority.enroll(json.dumps(data), Origin.CLOUD)
|
||||
return authority.confirm(grant, grant.app_id)
|
||||
|
||||
def test_grants_reload_from_the_persisted_document(self):
|
||||
grant = self._committed()
|
||||
restarted = LocalAuthority("cloud-device", "comma-device",
|
||||
self.device_key, lambda registry: None, registry=self.writes[-1])
|
||||
session = restarted.session(grant, grant.app_id)
|
||||
self.assertEqual("ok", session.invoke("getParams", lambda: "ok"))
|
||||
|
||||
def test_a_registry_with_the_wrong_shape_is_ignored(self):
|
||||
key = _spki(self.app_key)
|
||||
entry = {"app_id": key_id(self.app_key.public_key()), "public_key": key, "app_name": "A"}
|
||||
bad_registries = [
|
||||
None, [], "registry", {"v": VERSION}, {"v": 999, "grants": []},
|
||||
{"v": VERSION, "grants": [{"app_id": "x"}]},
|
||||
{"v": VERSION, "grants": [{"app_id": "x", "public_key": "!!!", "app_name": "A"}]},
|
||||
{"v": VERSION, "grants": "not-a-list"},
|
||||
{"v": VERSION, "grants": [entry], "extra": True},
|
||||
{"v": VERSION, "grants": [dict(entry, app_id="somebody-else")]},
|
||||
{"v": VERSION, "grants": [entry, dict(entry)]},
|
||||
]
|
||||
for bad in bad_registries:
|
||||
restarted = LocalAuthority("cloud-device", "comma-device", self.device_key,
|
||||
lambda registry: None, registry=bad)
|
||||
self.assertEqual({}, restarted.grants, bad)
|
||||
|
||||
def test_more_grants_than_fit_are_ignored(self):
|
||||
entries = []
|
||||
for _ in range(MAX_GRANTS + 1):
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
public = _spki(key)
|
||||
entries.append({"app_id": key_id(key.public_key()), "public_key": public, "app_name": "A"})
|
||||
restarted = LocalAuthority("cloud-device", "comma-device", self.device_key,
|
||||
lambda registry: None,
|
||||
registry={"v": VERSION, "grants": entries})
|
||||
self.assertEqual({}, restarted.grants)
|
||||
|
||||
|
||||
class TestRevocation(OpenpilotTestCase):
|
||||
def setup_method(self):
|
||||
self.device_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.app_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.writes: list[dict] = []
|
||||
self.authority = LocalAuthority("cloud-device", "comma-device",
|
||||
self.device_key, self.writes.append)
|
||||
self.app_id = key_id(self.app_key.public_key())
|
||||
proof = statement("revoke", "cloud-device", "comma-device", self.app_id)
|
||||
self.revocation = json.dumps({"v": VERSION, "cloud_device_id": "cloud-device",
|
||||
"comma_device_id": "comma-device", "key_id": self.app_id,
|
||||
"signature": encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))})
|
||||
public = _spki(self.app_key)
|
||||
data = {"v": VERSION, "cloud_device_id": "cloud-device", "comma_device_id": "comma-device",
|
||||
"device_key_id": self.authority.device_key_id, "public_key": public,
|
||||
"key_id": self.app_id, "app_name": "Test phone"}
|
||||
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
|
||||
data["signature"] = encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
|
||||
grant = self.authority.enroll(json.dumps(data), Origin.CLOUD)
|
||||
self.grant = self.authority.confirm(grant, grant.app_id)
|
||||
|
||||
def test_an_installation_can_revoke_itself(self):
|
||||
self.assertEqual(self.app_id, self.authority.revoke_by_self_proof(self.revocation, Origin.CLOUD))
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.session(self.grant, self.app_id)
|
||||
self.assertEqual({}, self.authority.grants)
|
||||
|
||||
def test_a_revoked_installation_can_enroll_again(self):
|
||||
"""Unpair is not a ban: the next auto-enroll push stages again, the next dial commits."""
|
||||
self.authority.revoke_by_self_proof(self.revocation, Origin.CLOUD)
|
||||
public = _spki(self.app_key)
|
||||
data = {"v": VERSION, "cloud_device_id": "cloud-device", "comma_device_id": "comma-device",
|
||||
"device_key_id": self.authority.device_key_id, "public_key": public,
|
||||
"key_id": self.app_id, "app_name": "Test phone"}
|
||||
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
|
||||
data["signature"] = encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
|
||||
grant = self.authority.enroll(json.dumps(data), Origin.CLOUD)
|
||||
self.authority.confirm(grant, grant.app_id)
|
||||
self.authority.session(grant, grant.app_id)
|
||||
|
||||
def test_revoking_over_the_local_lan_is_impossible(self):
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.revoke_by_self_proof(self.revocation, Origin.LOCAL)
|
||||
self.authority.session(self.grant, self.app_id)
|
||||
|
||||
def test_cloud_revocations_for_other_devices_are_ignored(self):
|
||||
payload = json.loads(self.revocation)
|
||||
payload["comma_device_id"] = "some-other-car"
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.revoke_by_self_proof(json.dumps(payload), Origin.CLOUD)
|
||||
self.authority.session(self.grant, self.app_id)
|
||||
|
||||
def test_revoking_an_unknown_key_is_a_noop(self):
|
||||
other_key = ec.generate_private_key(ec.SECP256R1())
|
||||
other_id = key_id(other_key.public_key())
|
||||
proof = statement("revoke", "cloud-device", "comma-device", other_id)
|
||||
raw = json.dumps({"v": VERSION, "cloud_device_id": "cloud-device", "comma_device_id": "comma-device",
|
||||
"key_id": other_id,
|
||||
"signature": encode64(other_key.sign(proof, ec.ECDSA(hashes.SHA256())))})
|
||||
self.assertEqual(other_id, self.authority.revoke_by_self_proof(raw, Origin.CLOUD))
|
||||
self.assertEqual(1, len(self.authority.grants))
|
||||
|
||||
def test_a_forged_proof_for_an_enrolled_key_is_refused(self):
|
||||
forged = json.loads(self.revocation)
|
||||
forged["signature"] = encode64(ec.generate_private_key(ec.SECP256R1()).sign(
|
||||
statement("revoke", "cloud-device", "comma-device", self.app_id), ec.ECDSA(hashes.SHA256())))
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.revoke_by_self_proof(json.dumps(forged), Origin.CLOUD)
|
||||
self.authority.session(self.grant, self.app_id)
|
||||
|
||||
def test_a_revocation_is_not_an_enrollment(self):
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.enroll(self.revocation, Origin.CLOUD)
|
||||
|
||||
|
||||
class TestDeviceProof(OpenpilotTestCase):
|
||||
"""The device proves itself to the dialing phone: it answers the phone's challenge."""
|
||||
|
||||
def setup_method(self):
|
||||
self.device_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.authority = LocalAuthority("cloud-device", "comma-device",
|
||||
self.device_key, lambda registry: None)
|
||||
|
||||
def test_the_device_signs_the_phones_challenge(self):
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
|
||||
challenge = encode64(b"\x00" * 32)
|
||||
proof = self.authority.authenticate_device("phone-key", challenge)
|
||||
self.assertEqual("cloud-device", proof["cloud_device_id"])
|
||||
self.assertEqual("comma-device", proof["comma_device_id"])
|
||||
self.assertEqual("phone-key", proof["phone_key_id"])
|
||||
expected = statement("authenticate-device", "cloud-device", "comma-device",
|
||||
self.authority.device_key_id, "phone-key", challenge)
|
||||
self.device_key.public_key().verify(
|
||||
decode64(proof["signature"]), expected, ECDSA(hashes.SHA256()))
|
||||
|
||||
def test_a_short_challenge_is_refused(self):
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.authenticate_device("phone-key", encode64(b"\x00" * 31))
|
||||
with self.assertRaises(AuthorizationError):
|
||||
self.authority.authenticate_device("phone-key", "!!!")
|
||||
@@ -1,484 +0,0 @@
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from unittest import mock
|
||||
|
||||
from openpilot.common.params import Params
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.sunnypilot.sunnylink.athena import local_auth_daemon
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
|
||||
AuthorizationError,
|
||||
Grant,
|
||||
LocalAuthority,
|
||||
Origin,
|
||||
encode64,
|
||||
key_id,
|
||||
statement,
|
||||
VERSION as REGISTRY_VERSION,
|
||||
)
|
||||
|
||||
# `<repo>/openpilot/common/params_keys.h` from this file.
|
||||
PARAMS_KEYS_H = Path(__file__).resolve().parents[4] / "common" / "params_keys.h"
|
||||
|
||||
# The fields an enrollment signs, in the order the statement joins them.
|
||||
ENROLL_FIELDS = ("cloud_device_id", "comma_device_id", "device_key_id",
|
||||
"public_key", "key_id", "app_name")
|
||||
|
||||
|
||||
class _ParamsStore:
|
||||
"""Dict-backed stand-in for the Params surface this module uses.
|
||||
|
||||
It deliberately models a JSON-typed param the way libparams does: a dict put in comes back
|
||||
out as the same dict. That contract is exactly what `_persist_registry` and `_read_registry`
|
||||
have to agree on, so a mismatched pair (a second base64 decode, say) fails here instead of
|
||||
only on a device.
|
||||
"""
|
||||
|
||||
def __init__(self, initial: dict[str, Any] | None = None):
|
||||
self.values: dict[str, Any] = dict(initial or {})
|
||||
self.removed: list[str] = []
|
||||
self.fail_reads = False
|
||||
|
||||
def get(self, key: str, block: bool = False, return_default: bool = False) -> Any:
|
||||
if self.fail_reads:
|
||||
raise RuntimeError("params unavailable")
|
||||
return self.values.get(key)
|
||||
|
||||
def get_bool(self, key: str, block: bool = False) -> bool:
|
||||
if self.fail_reads:
|
||||
raise RuntimeError("params unavailable")
|
||||
return bool(self.values.get(key))
|
||||
|
||||
def put(self, key: str, value: Any, block: bool = False) -> None:
|
||||
self.values[key] = value
|
||||
|
||||
def put_bool(self, key: str, value: bool, block: bool = False) -> None:
|
||||
self.values[key] = bool(value)
|
||||
|
||||
def remove(self, key: str) -> None:
|
||||
self.values.pop(key, None)
|
||||
self.removed.append(key)
|
||||
|
||||
|
||||
class TestLocalAuthDaemon(OpenpilotTestCase):
|
||||
def setup_method(self):
|
||||
self.device_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.app_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.private_pem = self.device_key.private_bytes(
|
||||
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
|
||||
self.store = _ParamsStore({"SunnylinkDongleId": "cloud-device", "DongleId": "comma-device"})
|
||||
|
||||
local_auth_daemon.reset_authority()
|
||||
self.addCleanup(local_auth_daemon.reset_authority)
|
||||
patchers = [
|
||||
mock.patch.object(local_auth_daemon, "_params", self.store),
|
||||
mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=("ES256", self.private_pem, "pub")),
|
||||
]
|
||||
for patcher in patchers:
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def enrollment(self, authority) -> str:
|
||||
public = encode64(self.app_key.public_key().public_bytes(
|
||||
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
|
||||
data = {"v": REGISTRY_VERSION, "cloud_device_id": authority.cloud_device_id,
|
||||
"comma_device_id": authority.comma_device_id, "device_key_id": authority.device_key_id,
|
||||
"public_key": public, "key_id": key_id(self.app_key.public_key()), "app_name": "Test phone"}
|
||||
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
|
||||
data["signature"] = encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
|
||||
return json.dumps(data)
|
||||
|
||||
def enrolled(self) -> dict[str, str]:
|
||||
"""Enroll one phone end to end: cloud enrollment staged, then TLS-confirmed."""
|
||||
summary = local_auth_daemon.cloud_enroll(self.enrollment(local_auth_daemon.get_authority()))
|
||||
return local_auth_daemon.confirm_enrollment_tls(summary["app_id"])
|
||||
|
||||
def revocation(self, authority, app_key=None) -> str:
|
||||
"""What the app pushes when it unpairs: a self-signed request to drop its own grant."""
|
||||
key = app_key or self.app_key
|
||||
app_id = key_id(key.public_key())
|
||||
proof = statement("revoke", authority.cloud_device_id, authority.comma_device_id, app_id)
|
||||
return json.dumps({"v": REGISTRY_VERSION, "cloud_device_id": authority.cloud_device_id,
|
||||
"comma_device_id": authority.comma_device_id, "key_id": app_id,
|
||||
"signature": encode64(key.sign(proof, ec.ECDSA(hashes.SHA256())))})
|
||||
|
||||
def registry(self) -> dict[str, Any]:
|
||||
return self.store.values[local_auth_daemon.LOCAL_APPS_KEY]
|
||||
|
||||
def test_a_cloud_enrollment_claimed_on_a_later_tick_stages(self):
|
||||
"""The path an app's push takes: the gateway stores the settings-write and the local half
|
||||
claims it on a later tick. There is no code on any screen — claiming works whenever the
|
||||
push arrives."""
|
||||
authority = local_auth_daemon.get_authority()
|
||||
self.store.put(local_auth_daemon.CLOUD_ENROLL_KEY, self.enrollment(authority))
|
||||
|
||||
local_auth_daemon.claim_cloud_commands(cast(Params, self.store))
|
||||
|
||||
pending = local_auth_daemon.pending_enrollment()
|
||||
assert pending is not None, "the late enrollment was not staged"
|
||||
self.assertEqual(key_id(self.app_key.public_key()), pending["app_id"])
|
||||
self.assertEqual("Test phone", pending["app_name"])
|
||||
# Claimed exactly once, whether or not it validated.
|
||||
self.assertIsNone(self.store.get(local_auth_daemon.CLOUD_ENROLL_KEY))
|
||||
|
||||
def test_cloud_enroll_stages_and_tls_confirm_persists(self):
|
||||
summary = local_auth_daemon.cloud_enroll(self.enrollment(local_auth_daemon.get_authority()))
|
||||
# Staged: nothing durable until the TLS peer proves possession.
|
||||
self.assertNotIn(local_auth_daemon.LOCAL_APPS_KEY, self.store.values)
|
||||
committed = local_auth_daemon.confirm_enrollment_tls(summary["app_id"])
|
||||
self.assertEqual(summary["app_id"], committed["app_id"])
|
||||
registry = self.registry()
|
||||
self.assertEqual(REGISTRY_VERSION, registry["v"])
|
||||
self.assertEqual(1, len(registry["grants"]))
|
||||
self.assertEqual(summary["app_id"], registry["grants"][0]["app_id"])
|
||||
|
||||
def test_grants_reload_from_the_persisted_registry(self):
|
||||
"""The write and read halves must agree: a grant has to survive a daemon restart."""
|
||||
summary = self.enrolled()
|
||||
|
||||
local_auth_daemon.reset_authority()
|
||||
grants = local_auth_daemon.grants()
|
||||
|
||||
self.assertEqual(1, len(grants))
|
||||
self.assertEqual(summary["app_id"], grants[0]["app_id"])
|
||||
self.assertEqual("Test phone", grants[0]["app_name"])
|
||||
|
||||
def test_registry_round_trip_is_the_json_param_itself(self):
|
||||
self.enrolled()
|
||||
registry = self.registry()
|
||||
# Stored as the JSON param, not the base64 cloud-transport form: reading it back through the
|
||||
# same module must return the identical structure.
|
||||
self.assertIsInstance(registry, dict)
|
||||
self.assertEqual({"v", "grants"}, set(registry))
|
||||
self.assertIs(local_auth_daemon._read_registry(), registry)
|
||||
|
||||
def test_revocation_persists_removal(self):
|
||||
summary = self.enrolled()
|
||||
self.assertTrue(local_auth_daemon.revoke_local_app(summary["app_id"]))
|
||||
self.assertEqual([], self.registry()["grants"])
|
||||
self.assertFalse(local_auth_daemon.revoke_local_app("unknown"))
|
||||
|
||||
def test_cloud_revoke_drops_the_phones_own_grant(self):
|
||||
"""Unpairing from an app that is not on the LAN: no live session carries `unpairLocalApp`,
|
||||
so without this the device would keep authorizing that phone forever."""
|
||||
summary = self.enrolled()
|
||||
|
||||
result = local_auth_daemon.cloud_revoke(self.revocation(local_auth_daemon.get_authority()))
|
||||
|
||||
self.assertEqual(summary["app_id"], result["app_id"])
|
||||
self.assertEqual([], self.registry()["grants"])
|
||||
# Persisted: a restart must not resurrect the grant.
|
||||
local_auth_daemon.reset_authority()
|
||||
self.assertEqual([], local_auth_daemon.grants())
|
||||
|
||||
def test_a_forged_proof_for_an_enrolled_key_is_refused(self):
|
||||
self.enrolled()
|
||||
authority = local_auth_daemon.get_authority()
|
||||
forged = json.loads(self.revocation(authority))
|
||||
forged["signature"] = encode64(ec.generate_private_key(ec.SECP256R1()).sign(
|
||||
statement("revoke", authority.cloud_device_id, authority.comma_device_id, forged["key_id"]),
|
||||
ec.ECDSA(hashes.SHA256())))
|
||||
with self.assertRaises(AuthorizationError):
|
||||
local_auth_daemon.cloud_revoke(json.dumps(forged))
|
||||
self.assertEqual(1, len(self.registry()["grants"]))
|
||||
|
||||
def test_a_proof_from_another_key_cannot_remove_a_grant(self):
|
||||
self.enrolled()
|
||||
other = ec.generate_private_key(ec.SECP256R1())
|
||||
# A key that is not enrolled has no grant to drop, so this is a no-op — never a way to
|
||||
# remove someone else's phone.
|
||||
result = local_auth_daemon.cloud_revoke(
|
||||
self.revocation(local_auth_daemon.get_authority(), app_key=other))
|
||||
self.assertEqual(key_id(other.public_key()), result["app_id"])
|
||||
self.assertEqual(1, len(self.registry()["grants"]))
|
||||
|
||||
def test_cloud_revoke_requires_the_right_device(self):
|
||||
self.enrolled()
|
||||
payload = json.loads(self.revocation(local_auth_daemon.get_authority()))
|
||||
payload["comma_device_id"] = "some-other-car"
|
||||
with self.assertRaises(AuthorizationError):
|
||||
local_auth_daemon.cloud_revoke(json.dumps(payload))
|
||||
self.assertEqual(1, len(self.registry()["grants"]))
|
||||
|
||||
def test_the_revoke_adapter_requires_cloud_origin(self):
|
||||
"""Same provenance rule as enrollment: an ordinary local RPC must never be able to reach it."""
|
||||
self.enrolled()
|
||||
authority = local_auth_daemon.get_authority()
|
||||
with self.assertRaises(AuthorizationError):
|
||||
authority.revoke_by_self_proof(self.revocation(authority), Origin.LOCAL)
|
||||
self.assertEqual(1, len(self.registry()["grants"]))
|
||||
|
||||
def test_registry_key_is_declared_in_params_keys(self):
|
||||
"""Every param this adapter stores has to be declared, or Params raises UnknownKeyName.
|
||||
|
||||
get_authority() sits on the daemon's connection path, so an undeclared key would take
|
||||
sunnylinkd down with the cloud link.
|
||||
"""
|
||||
declared = PARAMS_KEYS_H.read_text()
|
||||
for key in (local_auth_daemon.LOCAL_APPS_KEY, local_auth_daemon.LOCAL_APPS_META_KEY,
|
||||
local_auth_daemon.REVOKE_KEY):
|
||||
self.assertTrue(f'"{key}"' in declared)
|
||||
|
||||
def test_read_registry_ignores_a_value_that_is_not_the_registry(self):
|
||||
self.store.values[local_auth_daemon.LOCAL_APPS_KEY] = "not a registry"
|
||||
self.assertIsNone(local_auth_daemon._read_registry())
|
||||
self.assertEqual([], local_auth_daemon.grants())
|
||||
|
||||
def test_authority_fault_fails_closed_without_raising(self):
|
||||
"""A broken trust store must never propagate out of get_authority(): sunnylinkd calls it
|
||||
from its connection loop, where an exception ends the process."""
|
||||
self.store.fail_reads = True
|
||||
self.assertIsNone(local_auth_daemon.get_authority())
|
||||
self.assertEqual([], local_auth_daemon.grants())
|
||||
self.assertFalse(local_auth_daemon.revoke_local_app("anything"))
|
||||
with self.assertRaises(AuthorizationError):
|
||||
local_auth_daemon.cloud_enroll("{}")
|
||||
|
||||
def test_no_identity_key_fails_closed(self):
|
||||
with mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=(None, None, None)):
|
||||
local_auth_daemon.reset_authority()
|
||||
self.assertIsNone(local_auth_daemon.get_authority())
|
||||
with self.assertRaises(AuthorizationError):
|
||||
local_auth_daemon.cloud_enroll("{}")
|
||||
|
||||
|
||||
class TestRegistryThroughRealParams(OpenpilotTestCase):
|
||||
"""The same round trip against the real Params/libparams, in a temporary params directory.
|
||||
|
||||
The double above pins the logic; this proves the C library stores and returns the registry the
|
||||
way the module expects (JSON-typed param: a dict in, the same dict out). It needs `libparams_c`
|
||||
built from a `params_keys.h` that declares `SunnylinkLocalApps`.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
self.dir = tempfile.mkdtemp(prefix="sunnylink_params_")
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.device_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.app_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.private_pem = self.device_key.private_bytes(
|
||||
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
|
||||
self.params = Params(self.dir)
|
||||
self.params.put("SunnylinkDongleId", "cloud-device", block=True)
|
||||
self.params.put("DongleId", "comma-device", block=True)
|
||||
|
||||
local_auth_daemon.reset_authority()
|
||||
self.addCleanup(local_auth_daemon.reset_authority)
|
||||
patchers = [
|
||||
mock.patch.object(local_auth_daemon, "_params", self.params),
|
||||
mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=("ES256", self.private_pem, "pub")),
|
||||
]
|
||||
for patcher in patchers:
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_grant_survives_a_write_and_reload_through_libparams(self):
|
||||
authority = local_auth_daemon.get_authority()
|
||||
self.assertIsNotNone(authority)
|
||||
public = encode64(self.app_key.public_key().public_bytes(
|
||||
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
|
||||
app_id = key_id(self.app_key.public_key())
|
||||
data = {"v": REGISTRY_VERSION, "cloud_device_id": "cloud-device", "comma_device_id": "comma-device",
|
||||
"device_key_id": authority.device_key_id, "public_key": public,
|
||||
"key_id": app_id, "app_name": "Test phone"}
|
||||
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
|
||||
data["signature"] = encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
|
||||
|
||||
summary = local_auth_daemon.cloud_enroll(json.dumps(data))
|
||||
local_auth_daemon.confirm_enrollment_tls(summary["app_id"])
|
||||
|
||||
# A restart has to reload the same grants.
|
||||
local_auth_daemon.reset_authority()
|
||||
grants = local_auth_daemon.grants()
|
||||
self.assertEqual(1, len(grants))
|
||||
self.assertEqual(summary["app_id"], grants[0]["app_id"])
|
||||
self.assertEqual("Test phone", grants[0]["app_name"])
|
||||
self.assertEqual([{"app_id": summary["app_id"], "app_name": "Test phone"}],
|
||||
local_auth_daemon.read_granted_apps())
|
||||
|
||||
# And a revocation persists through the same store.
|
||||
self.assertTrue(local_auth_daemon.revoke_local_app(summary["app_id"]))
|
||||
local_auth_daemon.reset_authority()
|
||||
self.assertEqual([], local_auth_daemon.grants())
|
||||
self.assertEqual([], local_auth_daemon.read_granted_apps())
|
||||
|
||||
|
||||
class TestGrantMetadata(OpenpilotTestCase):
|
||||
"""Display metadata for enrolled phones: cosmetic, and never a source of authority."""
|
||||
|
||||
def setup_method(self):
|
||||
self.store = _ParamsStore()
|
||||
patcher = mock.patch.object(local_auth_daemon, "_params", self.store)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_alias_and_last_seen_round_trip(self):
|
||||
local_auth_daemon.set_grant_alias("key-a", "Nayan's phone")
|
||||
local_auth_daemon.note_grant_seen("key-a")
|
||||
|
||||
meta = local_auth_daemon.read_grant_meta()
|
||||
self.assertEqual("Nayan's phone", meta["key-a"]["alias"])
|
||||
self.assertIsInstance(meta["key-a"]["last_seen"], float)
|
||||
|
||||
def test_a_blank_alias_clears_back_to_the_enrolled_name(self):
|
||||
local_auth_daemon.set_grant_alias("key-a", "Named")
|
||||
local_auth_daemon.set_grant_alias("key-a", " ")
|
||||
self.assertEqual("", local_auth_daemon.read_grant_meta()["key-a"]["alias"])
|
||||
|
||||
def test_an_oversized_alias_is_refused(self):
|
||||
with self.assertRaises(AuthorizationError):
|
||||
local_auth_daemon.set_grant_alias("key-a", "x" * 81)
|
||||
self.assertEqual({}, local_auth_daemon.read_grant_meta())
|
||||
|
||||
def test_metadata_never_grants_anything(self):
|
||||
# A metadata entry for a key with no grant is inert: the authority is the only judge.
|
||||
local_auth_daemon.set_grant_alias("un-enrolled-key", "attacker")
|
||||
self.assertEqual([], local_auth_daemon.grants())
|
||||
self.assertEqual({"un-enrolled-key": {"alias": "attacker"}}, local_auth_daemon.read_grant_meta())
|
||||
|
||||
def test_unreadable_metadata_never_raises(self):
|
||||
self.store.values[local_auth_daemon.LOCAL_APPS_META_KEY] = "not a metadata document"
|
||||
self.assertEqual({}, local_auth_daemon.read_grant_meta())
|
||||
self.store.fail_reads = True
|
||||
self.assertEqual({}, local_auth_daemon.read_grant_meta())
|
||||
# on the session path, so it must swallow the fault rather than break a connection
|
||||
local_auth_daemon.note_grant_seen("key-a")
|
||||
|
||||
def test_revoking_a_phone_drops_its_metadata(self):
|
||||
local_auth_daemon.set_grant_alias("key-a", "Nayan's phone")
|
||||
local_auth_daemon.clear_grant_meta("key-a")
|
||||
self.assertEqual({}, local_auth_daemon.read_grant_meta())
|
||||
|
||||
|
||||
class TestGrantedAppsView(OpenpilotTestCase):
|
||||
"""The device UI's read-only view of enrolled phones."""
|
||||
|
||||
def setup_method(self):
|
||||
self.device_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.private_pem = self.device_key.private_bytes(
|
||||
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
|
||||
self.store = _ParamsStore({"SunnylinkDongleId": "cloud-device", "DongleId": "comma-device"})
|
||||
local_auth_daemon.reset_authority()
|
||||
self.addCleanup(local_auth_daemon.reset_authority)
|
||||
patcher = mock.patch.object(local_auth_daemon, "_params", self.store)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def write_registry(self, grants: list[Any]) -> None:
|
||||
self.store.values[local_auth_daemon.LOCAL_APPS_KEY] = {"v": REGISTRY_VERSION, "grants": grants}
|
||||
|
||||
def test_lists_app_id_and_name_without_building_the_authority(self):
|
||||
self.write_registry([{"app_id": "key-a", "app_name": "Nayan's phone", "public_key": "x"}])
|
||||
with mock.patch.object(local_auth_daemon, "_build_authority",
|
||||
side_effect=AssertionError("the UI must not load the identity key")):
|
||||
rows = local_auth_daemon.read_granted_apps()
|
||||
self.assertEqual([{"app_id": "key-a", "app_name": "Nayan's phone"}], rows)
|
||||
|
||||
def test_skips_entries_that_cannot_be_displayed(self):
|
||||
self.write_registry([{"app_id": "key-a"}, "junk", {"app_name": "no id"}, {"app_id": "", "app_name": "blank"},
|
||||
{"app_id": "key-b", "app_name": "B"}])
|
||||
self.assertEqual([{"app_id": "key-b", "app_name": "B"}], local_auth_daemon.read_granted_apps())
|
||||
|
||||
def test_no_registry_is_an_empty_list(self):
|
||||
self.assertEqual([], local_auth_daemon.read_granted_apps())
|
||||
|
||||
|
||||
class TestRevokeRequests(OpenpilotTestCase):
|
||||
"""Revoking from the device UI is a request the daemon applies through its live authority."""
|
||||
|
||||
def setup_method(self):
|
||||
self.store = _ParamsStore()
|
||||
patcher = mock.patch.object(local_auth_daemon, "_params", self.store)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_request_and_service_round_trip(self):
|
||||
revoked: list[str] = []
|
||||
with mock.patch.object(local_auth_daemon, "revoke_local_app",
|
||||
side_effect=lambda key: revoked.append(key) or True):
|
||||
local_auth_daemon.request_revoke("key-a")
|
||||
self.assertEqual("key-a", self.store.values[local_auth_daemon.REVOKE_KEY])
|
||||
self.assertTrue(local_auth_daemon.service_revoke_requests())
|
||||
self.assertEqual(["key-a"], revoked)
|
||||
# The request is consumed, so it cannot be applied twice on a later tick.
|
||||
self.assertNotIn(local_auth_daemon.REVOKE_KEY, self.store.values)
|
||||
self.assertFalse(local_auth_daemon.service_revoke_requests())
|
||||
|
||||
def test_a_failed_revoke_still_clears_the_request(self):
|
||||
with mock.patch.object(local_auth_daemon, "revoke_local_app", side_effect=RuntimeError("boom")):
|
||||
local_auth_daemon.request_revoke("key-a")
|
||||
self.assertFalse(local_auth_daemon.service_revoke_requests())
|
||||
self.assertNotIn(local_auth_daemon.REVOKE_KEY, self.store.values)
|
||||
|
||||
def test_unreadable_request_never_raises(self):
|
||||
self.store.values[local_auth_daemon.REVOKE_KEY] = "key-a"
|
||||
self.store.fail_reads = True
|
||||
self.assertFalse(local_auth_daemon.service_revoke_requests())
|
||||
|
||||
def test_blank_requests_are_ignored(self):
|
||||
self.assertFalse(local_auth_daemon.request_revoke(""))
|
||||
self.assertEqual({}, self.store.values)
|
||||
|
||||
def test_revoke_all_request_clears_every_grant_once(self):
|
||||
cleared: list[int] = []
|
||||
with mock.patch.object(local_auth_daemon, "revoke_all", side_effect=lambda: cleared.append(1) or 3):
|
||||
local_auth_daemon.request_revoke_all()
|
||||
self.assertEqual(local_auth_daemon.REVOKE_ALL, self.store.values[local_auth_daemon.REVOKE_KEY])
|
||||
self.assertTrue(local_auth_daemon.service_revoke_requests())
|
||||
# Consumed, so a later tick does not revoke a phone the user pairs afterwards.
|
||||
self.assertNotIn(local_auth_daemon.REVOKE_KEY, self.store.values)
|
||||
self.assertEqual(1, len(cleared))
|
||||
self.assertFalse(local_auth_daemon.service_revoke_requests())
|
||||
|
||||
def test_a_failed_revoke_all_still_clears_the_request(self):
|
||||
with mock.patch.object(local_auth_daemon, "revoke_all", side_effect=RuntimeError("boom")):
|
||||
local_auth_daemon.request_revoke_all()
|
||||
self.assertFalse(local_auth_daemon.service_revoke_requests())
|
||||
self.assertNotIn(local_auth_daemon.REVOKE_KEY, self.store.values)
|
||||
|
||||
def test_revoke_all_request_overwrites_a_single_request(self):
|
||||
calls: list[str] = []
|
||||
with mock.patch.object(local_auth_daemon, "revoke_all", side_effect=lambda: calls.append("all") or 2), \
|
||||
mock.patch.object(local_auth_daemon, "revoke_local_app", side_effect=lambda key: calls.append(key) or True):
|
||||
local_auth_daemon.request_revoke("key-a")
|
||||
local_auth_daemon.request_revoke_all()
|
||||
local_auth_daemon.service_revoke_requests()
|
||||
# One action, and no leftover request that could revoke a phone paired afterwards.
|
||||
self.assertEqual(["all"], calls)
|
||||
self.assertNotIn(local_auth_daemon.REVOKE_KEY, self.store.values)
|
||||
|
||||
|
||||
class TestRevokeAllThroughTheAuthority(OpenpilotTestCase):
|
||||
"""`revoke_all` on the core: one durable write, and the grants really go away."""
|
||||
|
||||
def setup_method(self):
|
||||
self.store = _ParamsStore()
|
||||
self.writes: list[dict] = []
|
||||
|
||||
def persist(registry: dict) -> None:
|
||||
self.writes.append(registry)
|
||||
self.store.values[local_auth_daemon.LOCAL_APPS_KEY] = registry
|
||||
|
||||
self.authority = LocalAuthority(
|
||||
cloud_device_id="cloud-device", comma_device_id="comma-device",
|
||||
device_private_key=ec.generate_private_key(ec.SECP256R1()),
|
||||
persist=persist,
|
||||
registry=None,
|
||||
)
|
||||
|
||||
def test_revoke_all_writes_once_and_empties_the_registry(self):
|
||||
for key in ("key-a", "key-b"):
|
||||
self.authority.grants[key] = Grant(key, "spki", "Phone")
|
||||
self.assertEqual(2, self.authority.revoke_all())
|
||||
self.assertEqual({}, self.authority.grants)
|
||||
# One write for the whole action, and the persisted document is an empty registry.
|
||||
self.assertEqual([{"v": REGISTRY_VERSION, "grants": []}], self.writes)
|
||||
|
||||
def test_revoke_all_with_nothing_enrolled_writes_nothing(self):
|
||||
self.assertEqual(0, self.authority.revoke_all())
|
||||
self.assertEqual([], self.writes)
|
||||
@@ -1,631 +0,0 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest import mock
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from websocket import WebSocket
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.system.athena import rpc as rpc_module
|
||||
from openpilot.sunnypilot.sunnylink.athena import local_auth_daemon, localsunnylinkd, sunnylinkd
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
|
||||
AUTHENTICATE_PURPOSE,
|
||||
VERSION,
|
||||
AuthorizationError,
|
||||
AuthorizedSession,
|
||||
LocalAuthority,
|
||||
Origin,
|
||||
encode64,
|
||||
key_id,
|
||||
statement,
|
||||
)
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_discovery import LocalDiscovery
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_params import ENROLL, REVOKE, cloud_may_write, is_local_param
|
||||
|
||||
CHALLENGE = "A" * 43 # base64url of 32 zero bytes: the canonical challenge shape
|
||||
|
||||
ENROLLMENT_TRANSPORT_VALUE = "eyJ2IjozfQ==" # base64 of {"v":3}
|
||||
|
||||
|
||||
class _FakeParams(dict):
|
||||
"""Only the Params calls the daemon makes; a dict is enough to steer them."""
|
||||
|
||||
def get_bool(self, key):
|
||||
return bool(self.get(key))
|
||||
|
||||
def put_bool(self, key, value, block=False):
|
||||
self[key] = value
|
||||
|
||||
def put(self, key, value, block=False):
|
||||
self[key] = value
|
||||
|
||||
def remove(self, key):
|
||||
self.pop(key, None)
|
||||
|
||||
def all_keys(self):
|
||||
return [key.encode() for key in self]
|
||||
|
||||
def get_type(self, key):
|
||||
return SimpleNamespace(value=1)
|
||||
|
||||
|
||||
class _FakeWs:
|
||||
"""A WebSocket that replays queued frames, then reports the peer gone."""
|
||||
|
||||
def __init__(self, messages=()):
|
||||
self._messages = list(messages)
|
||||
self.sent: list[str] = []
|
||||
self.closed = False
|
||||
self.timeouts: list[float] = []
|
||||
|
||||
def settimeout(self, timeout):
|
||||
self.timeouts.append(timeout)
|
||||
|
||||
def recv(self):
|
||||
if self._messages:
|
||||
return self._messages.pop(0)
|
||||
raise sunnylinkd.WebSocketConnectionClosedException("peer gone")
|
||||
|
||||
def send(self, payload):
|
||||
self.sent.append(payload)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Stands in for an AuthorizedSession: records the gated method, never executes it."""
|
||||
|
||||
def __init__(self, key_id: str = "grant-key"):
|
||||
self.calls: list[str] = []
|
||||
self.grant = SimpleNamespace(app_id=key_id)
|
||||
self.closed = False
|
||||
|
||||
def invoke(self, method, handler, *args, **kwargs):
|
||||
self.calls.append(method)
|
||||
return {"ok": True}
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _CallingSession(_FakeSession):
|
||||
"""Like _FakeSession, but actually runs the gated handler, as AuthorizedSession.invoke does."""
|
||||
|
||||
def invoke(self, method, handler, *args, **kwargs):
|
||||
return handler(*args, **kwargs)
|
||||
|
||||
|
||||
class _FakeDiscovery:
|
||||
def __init__(self, endpoints: Iterable[str] = (), recent: Iterable[str] | None = None,
|
||||
app_ids: dict[str, str] | None = None):
|
||||
self._endpoints: list[str] = list(endpoints)
|
||||
# What the shorter backoff window returns; None means "same as the dial window".
|
||||
self._recent: list[str] | None = None if recent is None else list(recent)
|
||||
self._app_ids: dict[str, str] = dict(app_ids or {})
|
||||
|
||||
def fresh_endpoints(self, max_age_s=None):
|
||||
if max_age_s is not None and self._recent is not None:
|
||||
return list(self._recent)
|
||||
return list(self._endpoints)
|
||||
|
||||
def fresh_peers(self, max_age_s=None):
|
||||
return [(e, self._app_ids.get(e, "grant-key")) for e in self.fresh_endpoints(max_age_s)]
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Daemon(localsunnylinkd.LocalSunnylinkd):
|
||||
"""A local daemon with scripted trust state, so target selection is testable in isolation."""
|
||||
|
||||
def __init__(self, endpoints=(), enrolled=True, discovery=None, known=("grant-key",),
|
||||
app_ids=None, **params):
|
||||
fake_discovery = discovery if discovery is not None else _FakeDiscovery(endpoints, app_ids=app_ids)
|
||||
super().__init__(params=cast(Params, _FakeParams(**params)),
|
||||
discovery=cast(LocalDiscovery, fake_discovery))
|
||||
self._enrolled = enrolled
|
||||
self._known = set(known)
|
||||
|
||||
def enrolled(self):
|
||||
return self._enrolled
|
||||
|
||||
def known_app_ids(self):
|
||||
return set(self._known)
|
||||
|
||||
|
||||
def _challenge(phone_key_id: str) -> str:
|
||||
return json.dumps({"v": VERSION, "purpose": AUTHENTICATE_PURPOSE, "phone_key_id": phone_key_id, "challenge": CHALLENGE})
|
||||
|
||||
|
||||
def _authority():
|
||||
device_key = ec.generate_private_key(ec.SECP256R1())
|
||||
return LocalAuthority("cloud-device", "comma-device", device_key, persist=lambda _registry: None)
|
||||
|
||||
|
||||
def _enrollment_json(authority: LocalAuthority, app_key: ec.EllipticCurvePrivateKey, name="Test phone") -> str:
|
||||
public = encode64(app_key.public_key().public_bytes(
|
||||
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
|
||||
data = {"v": VERSION, "cloud_device_id": authority.cloud_device_id, "comma_device_id": authority.comma_device_id,
|
||||
"device_key_id": authority.device_key_id, "public_key": public,
|
||||
"key_id": key_id(app_key.public_key()), "app_name": name}
|
||||
signed_fields = ("cloud_device_id", "comma_device_id", "device_key_id",
|
||||
"public_key", "key_id", "app_name")
|
||||
proof = statement("enroll", *(str(data[field]) for field in signed_fields))
|
||||
data["signature"] = encode64(app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
|
||||
return json.dumps(data)
|
||||
|
||||
|
||||
def _await(condition, timeout=5.0, description="condition") -> bool:
|
||||
"""Bounded polling: a failure fails fast instead of hanging the suite."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if condition():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
class TestLocalTargets(OpenpilotTestCase):
|
||||
"""What the local daemon dials, and when it runs at all."""
|
||||
|
||||
def test_pinned_endpoint_converts_a_beacon_to_tls(self):
|
||||
self.assertEqual("wss://10.0.0.7:8443", localsunnylinkd.pinned_endpoint("ws://10.0.0.7:8443"))
|
||||
self.assertIsNone(localsunnylinkd.pinned_endpoint("wss://10.0.0.7:8443"))
|
||||
self.assertIsNone(localsunnylinkd.pinned_endpoint("ws://10.0.0.7"))
|
||||
|
||||
def test_an_enrolled_phone_is_dialed_over_pinned_tls(self):
|
||||
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
|
||||
self.assertEqual("wss://10.0.0.7:8443", daemon.pick_target())
|
||||
|
||||
daemon.backoffs["wss://10.0.0.7:8443"] = time.monotonic() + 60
|
||||
self.assertIsNone(daemon.pick_target())
|
||||
|
||||
def test_a_failed_dial_backs_off_for_a_tick_then_ramps(self):
|
||||
"""A phone that keeps announcing while it refuses to serve ramps up, and no further."""
|
||||
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
|
||||
endpoint = "wss://10.0.0.7:8443"
|
||||
with mock.patch.object(daemon, "dial", side_effect=AuthorizationError("refused")):
|
||||
daemon.serve(endpoint, None)
|
||||
first = daemon.backoffs[endpoint] - time.monotonic()
|
||||
for _ in range(8):
|
||||
daemon.serve(endpoint, None)
|
||||
last = daemon.backoffs[endpoint] - time.monotonic()
|
||||
|
||||
# The app is a foreground-only server: one missed dial is worth a beacon interval, not the
|
||||
# five-minute cloud reconnect ceiling.
|
||||
self.assertLess(first, localsunnylinkd.ENDPOINT_BACKOFF_S)
|
||||
self.assertGreater(first, 0)
|
||||
self.assertLessEqual(last, localsunnylinkd.ENDPOINT_BACKOFF_MAX_S)
|
||||
self.assertEqual(9, daemon.failures[endpoint])
|
||||
|
||||
def test_a_failed_local_dial_leaves_the_cloud_links_state_alone(self):
|
||||
"""LastSunnylinkPingTime is the CLOUD link's heartbeat, not ours to clear.
|
||||
|
||||
The sidebar reads it for ONLINE/ERROR/OFFLINE. A local dial failing (the phone switched local
|
||||
connections off, walked away, went to sleep) says nothing about the cloud connection, which
|
||||
keeps running in sunnylinkd; wiping the timestamp made the car claim it was offline until the
|
||||
next cloud ping, which is exactly the "device is in an error state" after the switch.
|
||||
"""
|
||||
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"], LastSunnylinkPingTime=12345)
|
||||
with mock.patch.object(daemon, "dial", side_effect=AuthorizationError("refused")):
|
||||
daemon.serve("wss://10.0.0.7:8443", None)
|
||||
|
||||
self.assertEqual(12345, daemon.params.get("LastSunnylinkPingTime"))
|
||||
self.assertEqual(1, daemon.failures["wss://10.0.0.7:8443"])
|
||||
|
||||
def test_the_first_failed_dial_waits_only_one_tick(self):
|
||||
"""One missed dial is worth a tick, not a beacon interval: nothing else re-dials before then."""
|
||||
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
|
||||
endpoint = "wss://10.0.0.7:8443"
|
||||
with mock.patch.object(daemon, "dial", side_effect=AuthorizationError("refused")):
|
||||
daemon.serve(endpoint, None)
|
||||
delay = daemon.backoffs[endpoint] - time.monotonic()
|
||||
|
||||
self.assertGreater(delay, 0)
|
||||
self.assertLessEqual(delay, localsunnylinkd.TICK_INTERVAL_S + 0.05)
|
||||
|
||||
def test_a_phone_that_went_quiet_past_the_forget_window_clears_its_ramp(self):
|
||||
"""A network change silences the app for a moment without moving it out of the dial window.
|
||||
|
||||
Moving from home Wi-Fi to the car's hotspot (or roaming between mesh nodes) reassociates the
|
||||
phone: a few beacon intervals pass with no announcement, and the ramp it earned on the old link
|
||||
must not follow it onto the new one. The endpoint stays dialable for the full beacon window, so
|
||||
the very next beacon is an immediate attempt rather than the rest of a penalty.
|
||||
"""
|
||||
discovery = _FakeDiscovery(["ws://10.0.0.7:8443"], recent=[])
|
||||
daemon = _Daemon(discovery=discovery)
|
||||
endpoint = "wss://10.0.0.7:8443"
|
||||
daemon.backoffs[endpoint] = time.monotonic() + 300
|
||||
daemon.failures[endpoint] = 5
|
||||
|
||||
self.assertEqual(endpoint, daemon.pick_target())
|
||||
self.assertNotIn(endpoint, daemon.backoffs)
|
||||
self.assertNotIn(endpoint, daemon.failures)
|
||||
|
||||
def test_a_phone_still_announcing_keeps_its_ramp(self):
|
||||
"""The short window must not reset a ramp for a phone that is right there and refusing to serve."""
|
||||
discovery = _FakeDiscovery(["ws://10.0.0.7:8443"], recent=["ws://10.0.0.7:8443"])
|
||||
daemon = _Daemon(discovery=discovery)
|
||||
endpoint = "wss://10.0.0.7:8443"
|
||||
daemon.backoffs[endpoint] = time.monotonic() + 300
|
||||
|
||||
self.assertIsNone(daemon.pick_target())
|
||||
self.assertIn(endpoint, daemon.backoffs)
|
||||
|
||||
def test_a_served_session_clears_the_backoff_ramp(self):
|
||||
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
|
||||
endpoint = "wss://10.0.0.7:8443"
|
||||
daemon.backoffs[endpoint] = time.monotonic() + 60
|
||||
daemon.failures[endpoint] = 3
|
||||
|
||||
with mock.patch.object(daemon, "dial", lambda endpoint, exit_event: None), \
|
||||
mock.patch.object(localsunnylinkd, "TICK_INTERVAL_S", 0):
|
||||
daemon.serve(endpoint, None)
|
||||
|
||||
self.assertNotIn(endpoint, daemon.backoffs)
|
||||
self.assertNotIn(endpoint, daemon.failures)
|
||||
|
||||
def test_a_phone_that_stopped_announcing_forgets_its_backoff(self):
|
||||
"""Closing the app must not cost cloud-only service after it is reopened.
|
||||
|
||||
The app is a foreground-only server the user opens and closes: a dial that failed while it
|
||||
was closed recorded a backoff against an endpoint that then had no beacons at all. Reopening
|
||||
the app re-announces within seconds, and that record would otherwise hold the device on the
|
||||
cloud for the rest of the backoff.
|
||||
"""
|
||||
discovery = _FakeDiscovery(["ws://10.0.0.7:8443"])
|
||||
daemon = _Daemon(discovery=discovery)
|
||||
endpoint = "wss://10.0.0.7:8443"
|
||||
daemon.backoffs[endpoint] = time.monotonic() + 300
|
||||
daemon.failures[endpoint] = 1
|
||||
|
||||
# The app was closed: it announces nothing, so the failure outlives its reason.
|
||||
discovery._endpoints = []
|
||||
self.assertIsNone(daemon.pick_target())
|
||||
self.assertNotIn(endpoint, daemon.backoffs)
|
||||
self.assertNotIn(endpoint, daemon.failures)
|
||||
|
||||
# The app is opened again: the device dials it now instead of waiting the record out.
|
||||
discovery._endpoints = ["ws://10.0.0.7:8443"]
|
||||
self.assertEqual(endpoint, daemon.pick_target())
|
||||
|
||||
def test_a_pending_enrollment_is_dialed_even_before_any_grant(self):
|
||||
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"], known=("pending",),
|
||||
app_ids={"ws://10.0.0.7:8443": "pending"})
|
||||
self.assertEqual("wss://10.0.0.7:8443", daemon.pick_target())
|
||||
|
||||
def test_a_second_phone_is_dialed_while_the_first_holds_a_session(self):
|
||||
"""The second-pairing bug: one long-lived session used to block every other phone.
|
||||
|
||||
A phone keeps its session for as long as its app is open, so the old dial loop sat inside the
|
||||
first phone's session and never looked at the second. Pairing the Android phone next to an
|
||||
already-paired iPhone therefore never happened, and its app waited on "registration sent".
|
||||
"""
|
||||
discovery = _FakeDiscovery(["ws://10.0.0.7:8443", "ws://10.0.0.8:8443"],
|
||||
app_ids={"ws://10.0.0.7:8443": "iphone", "ws://10.0.0.8:8443": "android"})
|
||||
daemon = _Daemon(discovery=discovery, known=("iphone", "android"))
|
||||
first, second = "wss://10.0.0.7:8443", "wss://10.0.0.8:8443"
|
||||
holding = threading.Event()
|
||||
with mock.patch.object(daemon, "dial", lambda endpoint, exit_event: holding.wait(10)):
|
||||
self.assertTrue(daemon.start_session(first, None))
|
||||
self.assertTrue(daemon.has_session(first))
|
||||
|
||||
# The first phone's session is live: the second is STILL a target and still gets served.
|
||||
self.assertEqual([second], daemon.pick_targets())
|
||||
self.assertTrue(daemon.start_session(second, None))
|
||||
self.assertTrue(daemon.has_session(second))
|
||||
self.assertFalse(daemon.start_session(second, None), "one session per phone")
|
||||
holding.set()
|
||||
self.assertTrue(_await(lambda: not daemon._workers, description="session threads to exit"))
|
||||
|
||||
def test_the_phone_that_is_still_pairing_is_dialed_first(self):
|
||||
"""A phone being paired must not queue behind phones that are already paired."""
|
||||
discovery = _FakeDiscovery(["ws://10.0.0.7:8443", "ws://10.0.0.8:8443"],
|
||||
app_ids={"ws://10.0.0.7:8443": "iphone", "ws://10.0.0.8:8443": "android"})
|
||||
daemon = _Daemon(discovery=discovery, known=("iphone", "android"))
|
||||
|
||||
with mock.patch.object(local_auth_daemon, "pending_enrollment",
|
||||
return_value={"app_id": "android"}):
|
||||
self.assertEqual(["wss://10.0.0.8:8443", "wss://10.0.0.7:8443"], daemon.pick_targets())
|
||||
|
||||
def test_the_run_loop_serves_every_known_phone_at_once(self):
|
||||
"""End to end: both phones are dialed while neither has hung up."""
|
||||
discovery = _FakeDiscovery(["ws://10.0.0.7:8443", "ws://10.0.0.8:8443"],
|
||||
app_ids={"ws://10.0.0.7:8443": "iphone", "ws://10.0.0.8:8443": "android"})
|
||||
daemon = _Daemon(discovery=discovery, known=("iphone", "android"),
|
||||
SunnylinkEnabled=True, SunnylinkLocalEnabled=True)
|
||||
holding = threading.Event()
|
||||
dialed: list[str] = []
|
||||
|
||||
def fake_dial(endpoint, exit_event):
|
||||
dialed.append(endpoint)
|
||||
holding.wait(10)
|
||||
|
||||
stop = threading.Event()
|
||||
with mock.patch.object(daemon, "dial", fake_dial), \
|
||||
mock.patch.object(local_auth_daemon, "service_revoke_requests", lambda *a, **k: False), \
|
||||
mock.patch.object(local_auth_daemon, "claim_cloud_commands", lambda *a, **k: None), \
|
||||
mock.patch.object(localsunnylinkd, "TICK_INTERVAL_S", 0.01):
|
||||
loop = threading.Thread(target=daemon.run, args=(stop,), daemon=True)
|
||||
loop.start()
|
||||
both = {"wss://10.0.0.7:8443", "wss://10.0.0.8:8443"}
|
||||
self.assertTrue(_await(lambda: both <= set(dialed), description="both phones to be dialed"))
|
||||
stop.set()
|
||||
holding.set()
|
||||
loop.join(5)
|
||||
|
||||
# Both were dialed while the other was still connected; a redial after the sessions ended is
|
||||
# expected, so only the set of phones matters.
|
||||
self.assertEqual(both, set(dialed))
|
||||
|
||||
def test_a_beacon_from_an_unknown_phone_is_never_dialed(self):
|
||||
daemon = _Daemon(endpoints=["ws://10.0.0.9:8443"],
|
||||
app_ids={"ws://10.0.0.9:8443": "stranger"})
|
||||
self.assertIsNone(daemon.pick_target())
|
||||
|
||||
def test_the_local_switch_gates_the_process(self):
|
||||
running = _Daemon(SunnylinkEnabled=True, SunnylinkLocalEnabled=True)
|
||||
self.assertTrue(running.serviceable())
|
||||
|
||||
switched_off = _Daemon(SunnylinkEnabled=True, SunnylinkLocalEnabled=False)
|
||||
self.assertFalse(switched_off.serviceable())
|
||||
|
||||
sunnylink_off = _Daemon(SunnylinkEnabled=False, SunnylinkLocalEnabled=True)
|
||||
self.assertFalse(sunnylink_off.serviceable())
|
||||
|
||||
class TestLocalSurface(OpenpilotTestCase):
|
||||
"""What a local peer may reach, and what it may never touch."""
|
||||
|
||||
def test_a_local_session_can_never_write_local_params(self):
|
||||
"""Enrollment only ever arrives through the cloud, so a local write is refused outright."""
|
||||
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
|
||||
saved: list[str] = []
|
||||
with mock.patch.object(sunnylinkd, "save_param_from_base64_encoded_string",
|
||||
lambda key, value, compression=False: saved.append(key)):
|
||||
daemon.saveParams({ENROLL: ENROLLMENT_TRANSPORT_VALUE, "SunnylinkLocalApps": "{}", "SpeedLimitOffset": "5"})
|
||||
self.assertEqual(["SpeedLimitOffset"], saved)
|
||||
|
||||
def test_session_methods_are_local_only_and_gated(self):
|
||||
session = _FakeSession()
|
||||
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
|
||||
methods = daemon.session_methods(cast(AuthorizedSession, session))
|
||||
self.assertTrue("getParams" in methods)
|
||||
self.assertTrue("saveParams" in methods)
|
||||
self.assertTrue("unpairLocalApp" in methods)
|
||||
self.assertNotIn("startLocalProxy", methods)
|
||||
self.assertNotIn("echo", methods)
|
||||
self.assertEqual(methods["getParams"](params_keys=[]), {"ok": True})
|
||||
self.assertEqual(session.calls, ["getParams"])
|
||||
|
||||
def test_a_session_serves_local_methods_and_refuses_the_rest(self):
|
||||
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
|
||||
session = _FakeSession()
|
||||
methods = daemon.session_methods(cast(AuthorizedSession, session))
|
||||
ws = _FakeWs([
|
||||
rpc_module.dumps_call("getParams", {"params_keys": []}, 1),
|
||||
rpc_module.dumps_call("startLocalProxy", {"port": 8022}, 2),
|
||||
])
|
||||
daemon.serve_rpc(cast(WebSocket, ws), methods)
|
||||
self.assertEqual(session.calls, ["getParams"])
|
||||
first, second = (json.loads(payload) for payload in ws.sent)
|
||||
self.assertEqual(1, first["id"])
|
||||
self.assertEqual({"ok": True}, first["result"])
|
||||
self.assertEqual(2, second["id"])
|
||||
self.assertEqual(rpc_module.METHOD_NOT_FOUND, second["error"]["code"])
|
||||
|
||||
class TestLocalDial(OpenpilotTestCase):
|
||||
"""The pinned TLS dial and the challenge the phone must answer."""
|
||||
|
||||
def test_the_device_proves_itself_to_the_phone_first(self):
|
||||
authority = SimpleNamespace(authenticate_device=lambda phone, challenge: {"phone_key_id": phone,
|
||||
"challenge": challenge})
|
||||
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
|
||||
ws = _FakeWs([_challenge("pinned-key")])
|
||||
daemon.authenticate_phone(cast(WebSocket, ws), cast(LocalAuthority, authority), "pinned-key")
|
||||
proof = json.loads(ws.sent[0])
|
||||
self.assertEqual("pinned-key", proof["phone_key_id"])
|
||||
self.assertEqual(CHALLENGE, proof["challenge"])
|
||||
|
||||
def test_a_challenge_for_another_key_is_refused(self):
|
||||
authority = SimpleNamespace(authenticate_device=lambda phone, challenge: {})
|
||||
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
|
||||
with self.assertRaises(AuthorizationError):
|
||||
daemon.authenticate_phone(cast(WebSocket, _FakeWs([_challenge("someone-else")])),
|
||||
cast(LocalAuthority, authority), "pinned-key")
|
||||
|
||||
def test_a_malformed_challenge_is_refused(self):
|
||||
authority = SimpleNamespace(authenticate_device=lambda phone, challenge: {})
|
||||
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
|
||||
with self.assertRaises(AuthorizationError):
|
||||
daemon.authenticate_phone(cast(WebSocket, _FakeWs(["not json"])),
|
||||
cast(LocalAuthority, authority), "pinned-key")
|
||||
|
||||
def test_the_dial_confirms_a_pending_enrollment_before_serving(self):
|
||||
pending = SimpleNamespace(app_id="pending-key")
|
||||
grant = SimpleNamespace(app_id="pending-key")
|
||||
authority = SimpleNamespace(lock=threading.RLock(), pending=pending, grants={},
|
||||
authenticate_device=lambda phone, challenge: {"phone_key_id": phone,
|
||||
"challenge": challenge})
|
||||
session = _FakeSession("pending-key")
|
||||
confirmed: list[str] = []
|
||||
ws = _FakeWs([_challenge(pending.app_id)])
|
||||
|
||||
def confirm(key_id):
|
||||
confirmed.append(key_id)
|
||||
authority.grants[key_id] = grant
|
||||
authority.pending = None
|
||||
return {"app_id": grant.app_id, "app_name": "phone"}
|
||||
|
||||
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
|
||||
with mock.patch.object(authority, "session", lambda g, peer: session, create=True), \
|
||||
mock.patch.object(local_auth_daemon, "get_authority", lambda: authority), \
|
||||
mock.patch.object(local_auth_daemon, "confirm_enrollment_tls", confirm), \
|
||||
mock.patch.object(localsunnylinkd, "connect_pinned", lambda endpoint, key_id, timeout_s=10: ws):
|
||||
daemon.dial("wss://10.0.0.7:8443", None)
|
||||
|
||||
self.assertEqual(["pending-key"], confirmed)
|
||||
self.assertTrue(session.closed)
|
||||
self.assertTrue(ws.closed)
|
||||
self.assertEqual("pending-key", json.loads(ws.sent[0])["phone_key_id"])
|
||||
|
||||
def test_a_dial_without_an_enrolled_peer_never_serves(self):
|
||||
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
|
||||
authority = SimpleNamespace(lock=threading.RLock(), pending=None, grants={})
|
||||
with mock.patch.object(local_auth_daemon, "get_authority", lambda: authority), \
|
||||
mock.patch.object(localsunnylinkd, "connect_pinned",
|
||||
mock.Mock(side_effect=AuthorizationError("wrong TLS peer"))):
|
||||
with self.assertRaises(AuthorizationError):
|
||||
daemon.dial("wss://10.0.0.7:8443", None)
|
||||
|
||||
def test_a_rename_only_ever_renames_the_sessions_own_grant(self):
|
||||
calls: list[tuple[str, str]] = []
|
||||
session = _CallingSession("grant-key")
|
||||
with mock.patch.object(local_auth_daemon, "set_grant_alias",
|
||||
lambda key_id, alias: calls.append((key_id, alias)) or True):
|
||||
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
|
||||
methods = daemon.session_methods(cast(AuthorizedSession, session))
|
||||
result = methods["updateLocalAppAlias"](app_id="another-phone", alias="Nayan's phone")
|
||||
self.assertEqual([("grant-key", "Nayan's phone")], calls)
|
||||
self.assertEqual({"success": True, "updated": True}, result)
|
||||
|
||||
def test_an_alias_the_protocol_rejects_is_refused(self):
|
||||
def refuse(key_id, alias):
|
||||
raise AuthorizationError("invalid alias")
|
||||
|
||||
session = _CallingSession("grant-key")
|
||||
with mock.patch.object(local_auth_daemon, "set_grant_alias", refuse):
|
||||
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
|
||||
methods = daemon.session_methods(cast(AuthorizedSession, session))
|
||||
result = methods["updateLocalAppAlias"](app_id="grant-key", alias="bad\u0000alias")
|
||||
self.assertEqual({"success": False, "error": "invalid alias"}, result)
|
||||
|
||||
def test_unpair_revokes_the_sessions_own_grant(self):
|
||||
revoked: list[str] = []
|
||||
session = _CallingSession("grant-key")
|
||||
with mock.patch.object(local_auth_daemon, "revoke_local_app",
|
||||
lambda key_id: revoked.append(key_id) or True):
|
||||
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
|
||||
methods = daemon.session_methods(cast(AuthorizedSession, session))
|
||||
result = methods["unpairLocalApp"](app_id="another-phone")
|
||||
self.assertEqual(["grant-key"], revoked)
|
||||
self.assertEqual({"success": True, "removed": True}, result)
|
||||
|
||||
|
||||
class TestCloudCommandClaim(OpenpilotTestCase):
|
||||
"""The handoff: the cloud daemon stores a command, the local daemon claims it."""
|
||||
|
||||
def setup_method(self):
|
||||
self.device_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.app_key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.persisted: list[dict] = []
|
||||
self.authority = LocalAuthority("cloud-device", "comma-device", self.device_key, persist=self.persisted.append)
|
||||
patcher = mock.patch.object(local_auth_daemon, "get_authority", lambda: self.authority)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_a_valid_enrollment_is_staged_and_the_command_is_claimed_once(self):
|
||||
params = _FakeParams({ENROLL: _enrollment_json(self.authority, self.app_key)})
|
||||
local_auth_daemon.claim_cloud_commands(cast(Params, params))
|
||||
|
||||
self.assertIsNotNone(self.authority.pending)
|
||||
self.assertEqual("Test phone", self.authority.pending.app_name)
|
||||
self.assertEqual(key_id(self.app_key.public_key()), self.authority.pending.app_id)
|
||||
# Staged, not yet authorized, and never replayable.
|
||||
self.assertEqual([], self.persisted)
|
||||
self.assertNotIn(ENROLL, params)
|
||||
|
||||
def test_a_bad_payload_is_refused_and_still_cleared(self):
|
||||
params = _FakeParams({ENROLL: "not an enrollment"})
|
||||
local_auth_daemon.claim_cloud_commands(cast(Params, params))
|
||||
|
||||
self.assertIsNone(self.authority.pending)
|
||||
self.assertNotIn(ENROLL, params)
|
||||
|
||||
def test_a_revocation_drops_the_phones_own_grant(self):
|
||||
committed = self.authority.enroll(_enrollment_json(self.authority, self.app_key), origin=Origin.CLOUD)
|
||||
self.authority.confirm(committed, committed.app_id)
|
||||
|
||||
proof = statement("revoke", self.authority.cloud_device_id, self.authority.comma_device_id, committed.app_id)
|
||||
revocation = json.dumps({"v": VERSION, "cloud_device_id": self.authority.cloud_device_id,
|
||||
"comma_device_id": self.authority.comma_device_id, "key_id": committed.app_id,
|
||||
"signature": encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))})
|
||||
params = _FakeParams({REVOKE: revocation})
|
||||
local_auth_daemon.claim_cloud_commands(cast(Params, params))
|
||||
|
||||
self.assertEqual({}, self.authority.grants)
|
||||
self.assertNotIn(REVOKE, params)
|
||||
|
||||
def test_no_command_means_no_work(self):
|
||||
params = _FakeParams()
|
||||
local_auth_daemon.claim_cloud_commands(cast(Params, params))
|
||||
self.assertIsNone(self.authority.pending)
|
||||
|
||||
def test_the_command_round_trips_through_the_real_library(self):
|
||||
"""The declared param is a STRING, so the JSON the app pushes is what the claim reads."""
|
||||
directory = tempfile.mkdtemp(prefix="sunnylink_params_")
|
||||
self.addCleanup(shutil.rmtree, directory, ignore_errors=True)
|
||||
params = Params(directory)
|
||||
|
||||
params.put(ENROLL, _enrollment_json(self.authority, self.app_key), block=True)
|
||||
payload = params.get(ENROLL)
|
||||
if isinstance(payload, bytes):
|
||||
payload = payload.decode()
|
||||
|
||||
local_auth_daemon.claim_cloud_commands(params)
|
||||
|
||||
self.assertIsNotNone(self.authority.pending)
|
||||
self.assertEqual(b"", params.get(ENROLL) or b"")
|
||||
|
||||
def test_local_param_policy(self):
|
||||
for key in ("SunnylinkLocalApps", "SunnylinkLocalNewTrustKey", "_sec_anything"):
|
||||
self.assertTrue(is_local_param(key))
|
||||
self.assertFalse(is_local_param("SpeedLimitOffset"))
|
||||
|
||||
# The cloud's only way into the local side is its two commands.
|
||||
self.assertTrue(cloud_may_write(ENROLL))
|
||||
self.assertTrue(cloud_may_write(REVOKE))
|
||||
self.assertTrue(cloud_may_write("SpeedLimitOffset"))
|
||||
for key in ("SunnylinkLocalApps", "SunnylinkLocalRevoke", "_sec_x"):
|
||||
self.assertFalse(cloud_may_write(key))
|
||||
|
||||
|
||||
class TestLocalDiscovery(OpenpilotTestCase):
|
||||
BEACON = b'SUNNYLINK1 {"v":1,"role":"app","app_id":"phone-1","ws_port":8443}'
|
||||
|
||||
def test_fresh_beacons_are_recorded(self):
|
||||
discovery = LocalDiscovery()
|
||||
discovery._handle(self.BEACON, "10.0.0.7")
|
||||
discovery._handle(b'not a beacon', "10.0.0.8")
|
||||
self.assertEqual(["ws://10.0.0.7:8443"], discovery.fresh_endpoints())
|
||||
self.assertEqual([], discovery.fresh_endpoints(max_age_s=-1))
|
||||
|
||||
def test_fresh_peers_carry_the_announcing_app_id(self):
|
||||
discovery = LocalDiscovery()
|
||||
discovery._handle(self.BEACON, "10.0.0.7")
|
||||
self.assertEqual([("ws://10.0.0.7:8443", "phone-1")], discovery.fresh_peers())
|
||||
|
||||
|
||||
class TestEnrollmentTransportValue(OpenpilotTestCase):
|
||||
"""The exact bytes the app pushes, kept in step with the mobile constant."""
|
||||
|
||||
def test_the_transport_value_is_standard_base64(self):
|
||||
self.assertEqual('{"v":3}', base64.b64decode(ENROLLMENT_TRANSPORT_VALUE).decode())
|
||||
@@ -4,136 +4,57 @@ 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.
|
||||
"""
|
||||
import base64
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.sunnypilot.sunnylink.athena import sunnylinkd
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_params import ENROLL, REVOKE
|
||||
|
||||
# The local daemon owns this payload's contents; to the cloud transport it is one opaque value.
|
||||
ENROLLMENT_JSON = '{"v":3,"purpose":"sunnylink-local-enroll"}'
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
|
||||
|
||||
class TestCloudRpcSurface(OpenpilotTestCase):
|
||||
"""The cloud daemon serves settings and must never expose or mutate local trust."""
|
||||
|
||||
class TestSunnylinkdMethods(OpenpilotTestCase):
|
||||
def setup_method(self):
|
||||
self.saved_params = []
|
||||
|
||||
self.original_save = sunnylinkd.save_param_from_base64_encoded_string
|
||||
sunnylinkd.save_param_from_base64_encoded_string = self._record # ty: ignore[invalid-assignment]
|
||||
self.daemon = sunnylinkd.Sunnylinkd()
|
||||
|
||||
def mock_save_param(key, value, compression=False):
|
||||
self.saved_params.append((key, value, compression))
|
||||
|
||||
sunnylinkd.save_param_from_base64_encoded_string = mock_save_param # ty: ignore[invalid-assignment]
|
||||
|
||||
def teardown_method(self):
|
||||
sunnylinkd.save_param_from_base64_encoded_string = self.original_save # ty: ignore[invalid-assignment]
|
||||
|
||||
def _record(self, key, value, compression=False):
|
||||
self.saved_params.append((key, value, compression))
|
||||
|
||||
def test_methods_are_the_cloud_surface_only(self):
|
||||
methods = self.daemon.methods()
|
||||
self.assertTrue("getParams" in methods)
|
||||
self.assertTrue("saveParams" in methods)
|
||||
# Local serving is another process: no local pairing or alias RPC is registered here.
|
||||
for name in ("pairLocalApp", "updateLocalAppAlias", "unpairLocalApp"):
|
||||
self.assertNotIn(name, methods)
|
||||
|
||||
def test_saveParams_blocked(self):
|
||||
blocked_params = {
|
||||
"GithubUsername": "attacker",
|
||||
"GithubSshKeys": "ssh-rsa attacker_key",
|
||||
}
|
||||
|
||||
self.daemon.saveParams(blocked_params)
|
||||
sunnylinkd.saveParams(blocked_params)
|
||||
|
||||
assert len(self.saved_params) == 0
|
||||
|
||||
def test_saveParams_allowed(self):
|
||||
self.daemon.saveParams({
|
||||
allowed_params = {
|
||||
"SpeedLimitOffset": "5",
|
||||
"MyCustomParam": "123",
|
||||
})
|
||||
"MyCustomParam": "123"
|
||||
}
|
||||
|
||||
sunnylinkd.saveParams(allowed_params)
|
||||
|
||||
# verify content
|
||||
assert len(self.saved_params) == 2
|
||||
keys_saved = [p[0] for p in self.saved_params]
|
||||
assert "SpeedLimitOffset" in keys_saved
|
||||
assert "MyCustomParam" in keys_saved
|
||||
|
||||
def test_saveParams_mixed(self):
|
||||
self.daemon.saveParams({
|
||||
mixed_params = {
|
||||
"GithubUsername": "attacker",
|
||||
"SpeedLimitOffset": "10",
|
||||
})
|
||||
"SpeedLimitOffset": "10"
|
||||
}
|
||||
|
||||
sunnylinkd.saveParams(mixed_params)
|
||||
|
||||
# should save allowed one
|
||||
assert len(self.saved_params) == 1
|
||||
assert self.saved_params[0][0] == "SpeedLimitOffset"
|
||||
assert self.saved_params[0][1] == "10"
|
||||
|
||||
def test_saveParams_cannot_modify_local_trust(self):
|
||||
self.daemon.saveParams({
|
||||
"SunnylinkLocalApps": "attacker registry",
|
||||
"SunnylinkLocalAppsMeta": "attacker metadata",
|
||||
"SunnylinkLocalRevoke": "attacker revoke request",
|
||||
"SunnylinkLocalFutureKey": "attacker key",
|
||||
"_sec_SunnylinkLocalFuture": "attacker secret",
|
||||
})
|
||||
self.assertEqual(self.saved_params, [])
|
||||
|
||||
def test_saveParams_delivers_only_the_two_local_commands(self):
|
||||
self.daemon.saveParams({ENROLL: "ZW5yb2xs", REVOKE: "cmV2b2tl", "SunnylinkLocalApps": "attacker"})
|
||||
self.assertEqual(self.saved_params, [(ENROLL, "ZW5yb2xs", False), (REVOKE, "cmV2b2tl", False)])
|
||||
|
||||
def test_getParams_never_exposes_local_parameters(self):
|
||||
response = self.daemon.getParams(["SunnylinkLocalApps", "SunnylinkLocalAppsMeta",
|
||||
"SunnylinkLocalRevoke", ENROLL, REVOKE])
|
||||
self.assertEqual(response, {"params": "[]"})
|
||||
|
||||
def test_getParams_serves_ordinary_settings(self):
|
||||
response = self.daemon.getParams(["SunnylinkEnabled"])
|
||||
self.assertTrue("SunnylinkEnabled" in response)
|
||||
|
||||
def test_getParamsAllKeys_excludes_local_params(self):
|
||||
keys = self.daemon.getParamsAllKeys()
|
||||
for key in ("SunnylinkLocalApps", "SunnylinkLocalAppsMeta", "SunnylinkLocalRevoke",
|
||||
"SunnylinkLocalEnabled", ENROLL, REVOKE):
|
||||
self.assertNotIn(key, keys)
|
||||
self.assertTrue("SpeedLimitValueOffset" in keys)
|
||||
|
||||
|
||||
class TestCloudCommandTransport(OpenpilotTestCase):
|
||||
"""How an app's local command reaches the device: the cloud settings-write stores it.
|
||||
|
||||
The two commands are ordinary declared params on the cloud path, so the encoding the app
|
||||
already uses (standard base64 of a UTF-8 JSON payload) is decoded by the same helper that
|
||||
every other remotely written param goes through.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
self.dir = tempfile.mkdtemp(prefix="sunnylink_params_")
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.daemon = sunnylinkd.Sunnylinkd(params=Params(self.dir))
|
||||
patcher = mock.patch("openpilot.sunnypilot.sunnylink.utils.Params", lambda: Params(self.dir))
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_the_enrollment_lands_as_json_in_its_param(self):
|
||||
self.daemon.saveParams({ENROLL: base64.b64encode(ENROLLMENT_JSON.encode()).decode("ascii")})
|
||||
self.assertEqual(ENROLLMENT_JSON, Params(self.dir).get(ENROLL))
|
||||
|
||||
def test_the_revocation_lands_as_json_in_its_param(self):
|
||||
self.daemon.saveParams({REVOKE: base64.b64encode(b'{"v":2}').decode("ascii")})
|
||||
self.assertEqual('{"v":2}', Params(self.dir).get(REVOKE))
|
||||
|
||||
def test_a_compressed_command_also_arrives_readable(self):
|
||||
import gzip
|
||||
self.daemon.saveParams({ENROLL: base64.b64encode(gzip.compress(ENROLLMENT_JSON.encode())).decode("ascii")},
|
||||
compression=True)
|
||||
self.assertEqual(ENROLLMENT_JSON, Params(self.dir).get(ENROLL))
|
||||
|
||||
def test_the_cloud_may_not_write_any_other_local_param(self):
|
||||
with mock.patch.object(sunnylinkd, "save_param_from_base64_encoded_string") as save:
|
||||
self.daemon.saveParams({"SunnylinkLocalApps": base64.b64encode(b"{}").decode("ascii")})
|
||||
save.assert_not_called()
|
||||
|
||||
@@ -620,6 +620,19 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "AccelBoostSP",
|
||||
"widget": "toggle",
|
||||
"title": "Sunnypilot Accel Boost",
|
||||
"description": "Adds acceleration, sized by driving personality, when the driving model predicts no reason to slow, so it reaches your set speed sooner. Fades out in tight curves.",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "IntelligentCruiseButtonManagement",
|
||||
"widget": "toggle",
|
||||
|
||||
@@ -30,6 +30,13 @@ sections:
|
||||
label: Relaxed
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: AccelBoostSP
|
||||
widget: toggle
|
||||
title: Sunnypilot Accel Boost
|
||||
description: Adds acceleration, sized by driving personality, when the driving model predicts no reason to slow, so it
|
||||
reaches your set speed sooner. Fades out in tight curves.
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: IntelligentCruiseButtonManagement
|
||||
widget: toggle
|
||||
title: Intelligent Cruise Button Management (ICBM) (Alpha)
|
||||
|
||||
@@ -2,7 +2,6 @@ import base64
|
||||
import gzip
|
||||
import json
|
||||
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID
|
||||
from openpilot.sunnypilot.sunnylink.athena.local_state import is_locally_paired
|
||||
from openpilot.common.params import Params, ParamKeyType
|
||||
from openpilot.common.version import is_prebuilt
|
||||
|
||||
@@ -17,10 +16,10 @@ def get_sunnylink_status(params=None) -> tuple[bool, bool, bool]:
|
||||
|
||||
|
||||
def sunnylink_ready(params=None) -> bool:
|
||||
"""Enabled, registered or locally paired, and not on temporary fault."""
|
||||
"""Check if the device is ready to communicate with Sunnylink. That means it is enabled and registered."""
|
||||
params = params or Params()
|
||||
is_sunnylink_enabled, is_registered, is_on_temporary_fault = get_sunnylink_status(params)
|
||||
return is_sunnylink_enabled and (is_registered or is_locally_paired(params)) and not is_on_temporary_fault
|
||||
return is_sunnylink_enabled and is_registered and not is_on_temporary_fault
|
||||
|
||||
|
||||
def use_sunnylink_uploader(params) -> bool:
|
||||
@@ -29,10 +28,10 @@ def use_sunnylink_uploader(params) -> bool:
|
||||
|
||||
|
||||
def sunnylink_need_register(params=None) -> bool:
|
||||
"""Enabled, unregistered, and not locally paired."""
|
||||
"""Check if the device needs to be registered with Sunnylink."""
|
||||
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_locally_paired(params) and not is_on_temporary_fault
|
||||
return is_sunnylink_enabled and not is_registered and not is_on_temporary_fault
|
||||
|
||||
|
||||
def register_sunnylink():
|
||||
|
||||
@@ -74,9 +74,6 @@ def livestream(started: bool, params: Params, CP: car.CarParams) -> bool:
|
||||
def use_copyparty(started, params, CP: car.CarParams) -> bool:
|
||||
return bool(params.get_bool("EnableCopyparty"))
|
||||
|
||||
def localsunnylinkd_enabled(started, params, CP: car.CarParams) -> bool:
|
||||
return params.get_bool("SunnylinkEnabled") and params.get_bool("SunnylinkLocalEnabled")
|
||||
|
||||
def sunnylink_ready_shim(started, params, CP: car.CarParams) -> bool:
|
||||
"""Shim for sunnylink_ready to match the process manager signature."""
|
||||
return sunnylink_ready(params)
|
||||
@@ -169,7 +166,6 @@ procs = [
|
||||
|
||||
# sunnylink <3
|
||||
DaemonProcess("manage_sunnylinkd", "openpilot.sunnypilot.sunnylink.athena.manage_sunnylinkd", "SunnylinkdPid"),
|
||||
PythonProcess("localsunnylinkd", "openpilot.sunnypilot.sunnylink.athena.localsunnylinkd", localsunnylinkd_enabled),
|
||||
PythonProcess("sunnylink_registration_manager", "openpilot.sunnypilot.sunnylink.registration_manager", sunnylink_need_register_shim),
|
||||
PythonProcess("statsd_sp", "openpilot.sunnypilot.sunnylink.statsd", and_(always_run, sunnylink_ready_shim)),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user