sunnypilot: remove Sunnylink integration

This commit is contained in:
1okko
2026-10-21 21:24:22 +08:00
parent 4cad7dde60
commit 96ce41dced
75 changed files with 17073 additions and 12821 deletions
Binary file not shown.
-10
View File
@@ -163,7 +163,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"CarParamsSPPersistent", {PERSISTENT, BYTES}},
{"CarPlatformBundle", {PERSISTENT | BACKUP, JSON}},
{"ChevronInfo", {PERSISTENT | BACKUP, INT, "4"}},
{"CompletedSunnylinkConsentVersion", {PERSISTENT, STRING, "0"}},
{"CustomAccIncrementsEnabled", {PERSISTENT | BACKUP, BOOL, "0"}},
{"CustomAccLongPressIncrement", {PERSISTENT | BACKUP, INT, "5"}},
{"CustomAccShortPressIncrement", {PERSISTENT | BACKUP, INT, "1"}},
@@ -224,16 +223,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
// Neural Network Lateral Control
{"NeuralNetworkLateralControl", {PERSISTENT | BACKUP, BOOL, "0"}},
// sunnylink params
{"EnableSunnylinkUploader", {PERSISTENT | BACKUP, BOOL}},
{"LastSunnylinkPingTime", {CLEAR_ON_MANAGER_START, INT}},
{"ParamsVersion", {PERSISTENT, INT}},
{"SunnylinkCache_Roles", {PERSISTENT, STRING}},
{"SunnylinkCache_Users", {PERSISTENT, STRING}},
{"SunnylinkDongleId", {PERSISTENT, STRING}},
{"SunnylinkdPid", {PERSISTENT, INT}},
{"SunnylinkEnabled", {PERSISTENT, BOOL, "1"}},
{"SunnylinkTempFault", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL, "0"}},
// Backup Manager params
{"BackupManager_CreateBackup", {PERSISTENT, BOOL}},
-2
View File
@@ -44,8 +44,6 @@ BUILD_METADATA_FILENAME = "build.json"
training_version: str = "0.2.0"
terms_version: str = "2"
terms_version_sp: str = "1.0"
sunnylink_consent_version: str = "1.0"
sunnylink_consent_declined: str = "-1"
def get_version(path: str = BASEDIR) -> str:
@@ -54,7 +54,7 @@
"severity": 1
},
"Offroad_CarUnrecognized": {
"text": "sunnypilot was unable to identify your car. Your car is either unsupported or its ECUs are not recognized. Please select your vehicle manually at https://www.sunnylink.ai/. Need help? Visit https://community.sunnypilot.ai/",
"text": "sunnypilot was unable to identify your car. Your car is either unsupported or its ECUs are not recognized. Please select your vehicle manually in Settings.",
"severity": 0
},
"Offroad_Recalibration": {
+2 -19
View File
@@ -13,7 +13,6 @@ from openpilot.system.ui.widgets.label import Label
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.common.version import terms_version, training_version, terms_version_sp
from openpilot.selfdrive.ui.sunnypilot.layouts.onboarding import SunnylinkOnboarding
DEBUG = False
@@ -35,7 +34,6 @@ class OnboardingState(IntEnum):
TERMS = 0
ONBOARDING = 1
DECLINE = 2
SUNNYLINK_CONSENT = 3
class TrainingGuide(Widget):
@@ -186,21 +184,15 @@ class OnboardingWindow(Widget):
self._training_guide: TrainingGuide | None = None
self._decline_page = DeclinePage(back_callback=self._on_decline_back)
# sunnylink consent pages
self._accepted_terms = self._accepted_terms and ui_state.params.get("HasAcceptedTermsSP") == terms_version_sp
self._sunnylink = SunnylinkOnboarding()
if not self._accepted_terms:
self._state = OnboardingState.TERMS
elif not self._sunnylink.completed:
self._state = OnboardingState.SUNNYLINK_CONSENT
elif not self._training_done:
self._state = OnboardingState.ONBOARDING
else:
self._state = OnboardingState.ONBOARDING
@property
def completed(self) -> bool:
return self._accepted_terms and self._sunnylink.completed and self._training_done
return self._accepted_terms and self._training_done
def _on_terms_declined(self):
self._state = OnboardingState.DECLINE
@@ -211,9 +203,7 @@ class OnboardingWindow(Widget):
def _on_terms_accepted(self):
ui_state.params.put("HasAcceptedTerms", terms_version, block=True)
ui_state.params.put("HasAcceptedTermsSP", terms_version_sp, block=True)
if not self._sunnylink.completed:
self._state = OnboardingState.SUNNYLINK_CONSENT
elif not self._training_done:
if not self._training_done:
self._state = OnboardingState.ONBOARDING
else:
gui_app.pop_widget()
@@ -227,13 +217,6 @@ class OnboardingWindow(Widget):
if self._state == OnboardingState.TERMS:
self._terms.render(self._rect)
elif self._state == OnboardingState.SUNNYLINK_CONSENT:
self._sunnylink.render(self._rect)
if self._sunnylink.completed:
if not self._training_done:
self._state = OnboardingState.ONBOARDING
else:
gui_app.pop_widget()
elif self._state == OnboardingState.ONBOARDING:
if not self._training_done:
self._training_guide.render(self._rect)
@@ -117,7 +117,6 @@ class Sidebar(Widget, SidebarSP):
self._update_temperature_status(device_state)
self._update_connection_status(device_state)
self._update_panda_status()
SidebarSP._update_sunnylink_status(self)
def _update_network_status(self, device_state):
self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown"))
@@ -230,13 +229,6 @@ class Sidebar(Widget, SidebarSP):
rl.draw_text_ex(self._font_regular, tr(self._net_type), text_pos, FONT_SIZE, 0, Colors.WHITE)
def _draw_metrics(self, rect: rl.Rectangle):
if gui_app.sunnypilot_ui():
metrics, start_y, spacing = SidebarSP._draw_metrics_w_sunnylink(self, rect, self._temp_status, self._panda_status, self._connect_status)
for idx, metric in enumerate(metrics):
self._draw_metric(rect, metric, start_y + idx * spacing)
return
metrics = [(self._temp_status, 338), (self._panda_status, 496), (self._connect_status, 654)]
for metric, y_offset in metrics:
@@ -1,115 +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 pyray as rl
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import FontWeight, TextAlignment
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import Label
from openpilot.common.version import sunnylink_consent_version, sunnylink_consent_declined
class SunnylinkConsentPage(Widget):
def __init__(self, done_callback=None):
super().__init__()
self._done_callback = done_callback
self._step = 0
self._title = self._child(Label(tr("sunnylink"), font_size=90, font_weight=FontWeight.AUDIOWIDE, text_alignment=TextAlignment.LEFT))
self._content = [
{
"text": tr("sunnylink enables secured remote access to your comma device from anywhere, " +
"including settings management, remote monitoring, real-time dashboard, etc."),
"primary_btn": tr("Enable"),
"secondary_btn": tr("Disable"),
"highlight_primary": True
},
{
"text": tr("sunnylink is designed to be enabled as part of sunnypilot's core functionality. " +
"If sunnylink is disabled, features such as settings management, remote monitoring, " +
"real-time dashboards will be unavailable."),
"secondary_btn": tr("Back"),
"danger_btn": tr("Disable"),
"highlight_primary": True
}
]
self._primary_btn = self._child(Button("", button_style=ButtonStyle.PRIMARY, click_callback=lambda: self._handle_choice("enable")))
self._secondary_btn = self._child(Button("", button_style=ButtonStyle.NORMAL, click_callback=lambda: self._handle_choice("secondary")))
self._danger_btn = self._child(Button("", button_style=ButtonStyle.DANGER, click_callback=lambda: self._handle_choice("disable")))
self._desc = self._child(Label("", font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT))
def _handle_choice(self, choice):
if choice == "enable":
ui_state.params.put_bool("SunnylinkEnabled", True)
ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version)
if self._done_callback:
self._done_callback()
elif choice == "secondary":
if self._step == 0:
self._step = 1
elif self._step == 1:
self._step = 0
elif choice == "disable":
ui_state.params.put_bool("SunnylinkEnabled", False)
ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined)
if self._done_callback:
self._done_callback()
def _render(self, _):
step_data = self._content[self._step]
welcome_x = self._rect.x + 95
welcome_y = self._rect.y + 165
welcome_rect = rl.Rectangle(welcome_x, welcome_y, self._rect.width - welcome_x, 90)
self._title.render(welcome_rect)
desc_x = welcome_x
desc_y = welcome_y + 120
desc_rect = rl.Rectangle(desc_x, desc_y, self._rect.width - desc_x, self._rect.height - desc_y - 250)
self._desc.set_text(step_data["text"])
self._desc.render(desc_rect)
btn_y = self._rect.y + self._rect.height - 160 - 45
if "danger_btn" in step_data:
btn_width = (self._rect.width - 45 * 3) / 2
self._secondary_btn.set_text(step_data["secondary_btn"])
self._secondary_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160))
self._danger_btn.set_text(step_data["danger_btn"])
self._danger_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160))
else:
btn_width = (self._rect.width - 45 * 3) / 2
self._secondary_btn.set_text(step_data["secondary_btn"])
self._secondary_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160))
self._primary_btn.set_text(step_data["primary_btn"])
self._primary_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160))
class SunnylinkOnboarding:
def __init__(self):
self.consent_page = SunnylinkConsentPage(done_callback=self._on_done)
self.consent_done: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") in {sunnylink_consent_version, sunnylink_consent_declined}
@property
def completed(self) -> bool:
return self.consent_done
def _on_done(self):
self.consent_done = True
def render(self, rect):
if not self.consent_done:
self.consent_page.render(rect)
@@ -20,7 +20,6 @@ from openpilot.selfdrive.ui.sunnypilot.layouts.settings.network import NetworkUI
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.osm import OSMLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.software import SoftwareLayoutSP
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.steering import SteeringLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.sunnylink import SunnylinkLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.trips import TripsLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle import VehicleLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.visuals import VisualsLayout
@@ -40,7 +39,6 @@ ICON_SIZE = 70
OP.PanelType = IntEnum( # type: ignore[assignment] # ty: ignore[invalid-assignment]
"PanelType",
[es.name for es in OP.PanelType] + [
"SUNNYLINK",
"MODELS",
"STEERING",
"CRUISE",
@@ -112,7 +110,6 @@ class SettingsLayoutSP(OP.SettingsLayout):
self._panels = {
OP.PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayoutSP(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_home.png"),
OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUISP(wifi_manager), icon="icons/network.png"),
# OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/wifi_strength_full.png"), # hidden
# OP.PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_toggle.png"), # hidden, moved into Cruise
OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayoutSP(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_software.png"),
OP.PanelType.MODELS: PanelInfo(tr_noop("Models"), ModelsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_models.png"),
@@ -1,366 +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 pyray as rl
from openpilot.cereal import custom
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.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
from openpilot.system.ui.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.scroller_tici import Scroller, LineSeparator
from openpilot.common.version import sunnylink_consent_version
class SunnylinkHeader(Widget):
def __init__(self):
super().__init__()
self._title = UnifiedLabel(
text="🚀 sunnylink 🚀",
font_size=90,
font_weight=FontWeight.AUDIOWIDE,
text_color=rl.WHITE,
alignment=TextAlignment.CENTER,
alignment_vertical=TextAlignmentVertical.TOP,
wrap_text=False,
elide=False
)
self._description = UnifiedLabel(
text=tr("For secure backup, restore, and remote configuration"),
font_size=40,
font_weight=FontWeight.NORMAL,
text_color=rl.Color(0, 255, 0, 255), # Green
alignment=TextAlignment.CENTER,
alignment_vertical=TextAlignmentVertical.TOP,
wrap_text=True,
elide=False
)
self._sponsor_msg = UnifiedLabel(
text=tr("Sponsorship isn't required for basic backup/restore") + "\n" +
tr("Click the Sponsor button for more details"),
font_size=35,
font_weight=FontWeight.NORMAL,
text_color=rl.Color(255, 165, 0, 255), # Orange
alignment=TextAlignment.CENTER,
alignment_vertical=TextAlignmentVertical.TOP,
wrap_text=True,
elide=False
)
self._padding = 20
self._spacing = 10
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
content_width = int(parent_rect.width - (self._padding * 2))
title_height = self._title.get_content_height(content_width)
desc_height = self._description.get_content_height(content_width)
sponsor_height = self._sponsor_msg.get_content_height(content_width)
total_height = (self._padding + title_height + self._spacing +
desc_height + self._spacing + sponsor_height + self._padding)
self._rect.width = parent_rect.width
self._rect.height = total_height
def _render(self, rect: rl.Rectangle):
content_width = rect.width - (self._padding * 2)
current_y = rect.y + self._padding
# Render title
title_height = self._title.get_content_height(int(content_width))
title_rect = rl.Rectangle(rect.x + self._padding, current_y, content_width, title_height)
self._title.render(title_rect)
current_y += title_height + self._spacing
# Render description
desc_height = self._description.get_content_height(int(content_width))
desc_rect = rl.Rectangle(rect.x + self._padding, current_y, content_width, desc_height)
self._description.render(desc_rect)
current_y += desc_height + self._spacing
# Render sponsor message
sponsor_height = self._sponsor_msg.get_content_height(int(content_width))
sponsor_rect = rl.Rectangle(rect.x + self._padding, current_y, content_width, sponsor_height)
self._sponsor_msg.render(sponsor_rect)
class SunnylinkDescriptionItem(Widget):
def __init__(self):
super().__init__()
self._description = UnifiedLabel(
text="",
font_size=40,
font_weight=FontWeight.NORMAL,
text_color=rl.WHITE,
alignment=TextAlignment.LEFT,
alignment_vertical=TextAlignmentVertical.TOP,
wrap_text=True,
elide=False,
)
self._padding = 20
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
desc_height = self._description.get_content_height(int(parent_rect.width)) + self._padding * 2
self._rect.width = parent_rect.width
self._rect.height = desc_height
def set_text(self, text: str):
self._description.set_text(text)
def set_color(self, color: rl.Color):
self._description.set_text_color(color)
def _render(self, rect: rl.Rectangle):
content_width = rect.width - (self._padding * 2)
desc_height = self._description.get_content_height(int(content_width))
desc_rect = rl.Rectangle(rect.x + self._padding, rect.y, content_width, desc_height)
self._description.render(desc_rect)
class SunnylinkLayout(Widget):
def __init__(self):
super().__init__()
self._sunnylink_pairing_dialog: SunnylinkPairingDialog | None = None
self._restore_in_progress = False
self._backup_in_progress = False
self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled")
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=False, spacing=0)
def _initialize_items(self):
self._sunnylink_toggle = toggle_item_sp(
title=lambda: tr("Enable sunnylink"),
description=lambda: tr("This is the master switch, it will allow you to cutoff any sunnylink requests should you want to do that."),
param="SunnylinkEnabled",
callback=self._sunnylink_toggle_callback
)
self._sunnylink_description = SunnylinkDescriptionItem()
self._sunnylink_description.set_visible(False)
self._sponsor_btn = button_item_sp(
title=lambda: tr("Sponsor Status"),
button_text=lambda: tr("SPONSOR"),
description=lambda: tr(
"Become a sponsor of sunnypilot to get early access to sunnylink features when they become available."),
callback=lambda: self._handle_pair_btn(False)
)
self._pair_btn = button_item_sp(
title=lambda: tr("Pair GitHub Account"),
button_text=lambda: tr("Not Paired"),
description=lambda: tr(
"Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink."),
callback=lambda: self._handle_pair_btn(True)
)
self._sunnylink_uploader_toggle = toggle_item_sp(
title=lambda: tr("Enable sunnylink uploader (infrastructure test)"),
description=lambda: tr("Enable sunnylink uploader to allow sunnypilot to upload your driving data to sunnypilot servers. ") +
tr("(Only for highest tiers, and does NOT bring ANY benefit to you yet. We are just testing data volume.)"),
param="EnableSunnylinkUploader"
)
self._sunnylink_backup_restore_buttons = dual_button_item(
description="",
left_text=lambda: tr("Backup Settings"),
right_text=lambda: tr("Restore Settings"),
left_callback=self._handle_backup_btn,
right_callback=self._handle_restore_btn
)
self._backup_btn: Button = self._sunnylink_backup_restore_buttons.action_item.left_button # store for easy individual access
self._restore_btn: Button = self._sunnylink_backup_restore_buttons.action_item.right_button
self._backup_btn.set_button_style(ButtonStyle.NORMAL)
self._restore_btn.set_button_style(ButtonStyle.PRIMARY)
items = [
SunnylinkHeader(),
LineSeparator(),
self._sunnylink_toggle,
self._sunnylink_description,
LineSeparator(),
self._sponsor_btn,
LineSeparator(),
self._pair_btn,
LineSeparator(),
self._sunnylink_uploader_toggle,
LineSeparator(),
self._sunnylink_backup_restore_buttons
]
return items
@staticmethod
def _get_sunnylink_dongle_id() -> str:
return ui_state.params.get("SunnylinkDongleId") or tr("N/A")
def _handle_pair_btn(self, sponsor_pairing: bool = False):
sunnylink_dongle_id = self._get_sunnylink_dongle_id()
if sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID:
gui_app.push_widget(alert_dialog(message=tr("sunnylink Dongle ID not found. ") +
tr("This may be due to weak internet connection or sunnylink registration issue. ") +
tr("Please reboot and try again.")))
elif not self._sunnylink_pairing_dialog:
self._sunnylink_pairing_dialog = SunnylinkPairingDialog(sponsor_pairing)
gui_app.push_widget(self._sunnylink_pairing_dialog)
def _handle_backup_btn(self):
backup_dialog = ConfirmDialog(text=tr("Are you sure you want to backup your current sunnypilot settings?"), confirm_text="Backup",
callback=self._backup_handler)
gui_app.push_widget(backup_dialog)
def _handle_restore_btn(self):
self._restore_btn.set_enabled(False)
restore_dialog = ConfirmDialog(text=tr("Are you sure you want to restore the last backed up sunnypilot settings?"),
confirm_text="Restore", callback=self._restore_handler)
gui_app.push_widget(restore_dialog)
def _backup_handler(self, dialog_result: int):
if dialog_result == DialogResult.CONFIRM:
self._backup_in_progress = True
self._backup_btn.set_enabled(False)
ui_state.params.put_bool("BackupManager_CreateBackup", True)
def _restore_handler(self, dialog_result: int):
if dialog_result == DialogResult.CONFIRM:
self._restore_in_progress = True
self._restore_btn.set_enabled(False)
ui_state.params.put("BackupManager_RestoreVersion", "latest")
def handle_backup_restore_progress(self):
sunnylink_backup_manager = ui_state.sm["backupManagerSP"]
backup_status = sunnylink_backup_manager.backupStatus
restore_status = sunnylink_backup_manager.restoreStatus
backup_progress = sunnylink_backup_manager.backupProgress
restore_progress = sunnylink_backup_manager.restoreProgress
if self._backup_in_progress:
self._restore_btn.set_enabled(False)
self._backup_btn.set_enabled(False)
if backup_status == custom.BackupManagerSP.Status.inProgress:
self._backup_in_progress = True
text = tr(f"Backing up {backup_progress}%")
self._backup_btn.set_text(text)
elif backup_status == custom.BackupManagerSP.Status.failed:
self._backup_in_progress = False
self._backup_btn.set_enabled(not ui_state.is_onroad())
self._backup_btn.set_text(tr("Backup Failed"))
elif (backup_status == custom.BackupManagerSP.Status.completed or
(backup_status == custom.BackupManagerSP.Status.idle and backup_progress == 100.0)):
self._backup_in_progress = False
dialog = alert_dialog(tr("Settings backup completed."))
gui_app.push_widget(dialog)
self._backup_btn.set_enabled(not ui_state.is_onroad())
elif self._restore_in_progress:
self._restore_btn.set_enabled(False)
self._backup_btn.set_enabled(False)
if restore_status == custom.BackupManagerSP.Status.inProgress:
self._restore_in_progress = True
text = tr(f"Restoring {restore_progress}%")
self._restore_btn.set_text(text)
elif restore_status == custom.BackupManagerSP.Status.failed:
self._restore_in_progress = False
self._restore_btn.set_enabled(not ui_state.is_onroad())
self._restore_btn.set_text(tr("Restore Failed"))
dialog = alert_dialog(tr("Unable to restore the settings, try again later."))
gui_app.push_widget(dialog)
elif (restore_status == custom.BackupManagerSP.Status.completed or
(restore_status == custom.BackupManagerSP.Status.idle and restore_progress == 100.0)):
self._restore_in_progress = False
dialog = ConfirmDialog(tr("Settings restored. Confirm to restart the interface."), tr("OK"), cancel_text="", callback=lambda _: gui_app.request_close())
gui_app.push_widget(dialog)
else:
can_enable = self._sunnylink_enabled and not ui_state.is_onroad()
self._backup_btn.set_enabled(can_enable)
self._backup_btn.set_text(tr("Backup Settings"))
self._restore_btn.set_enabled(can_enable)
self._restore_btn.set_text(tr("Restore Settings"))
def _sunnylink_toggle_callback(self, state: bool):
sl_consent: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") == sunnylink_consent_version
sl_enabled: bool = ui_state.params.get_bool("SunnylinkEnabled")
if state and not sl_consent and not sl_enabled:
def on_consent_done():
enabled = ui_state.params.get_bool("SunnylinkEnabled")
self._update_description(enabled)
gui_app.pop_widget()
sl_terms_dlg = SunnylinkConsentPage(done_callback=on_consent_done)
gui_app.push_widget(sl_terms_dlg)
else:
ui_state.params.put_bool("SunnylinkEnabled", state)
self._update_description(state)
def _update_description(self, state: bool):
if state:
description = tr(
"Welcome back!! We're excited to see you've enabled sunnylink again!")
color = rl.Color(0, 255, 0, 255) # Green
else:
description = ("😢 " + tr("Not going to lie, it's sad to see you disabled sunnylink") +
tr(", but we'll be here when you're ready to come back."))
color = rl.Color(255, 165, 0, 255) # Orange
self._sunnylink_description.set_text(description)
self._sunnylink_description.set_color(color)
self._sunnylink_description.set_visible(True)
self._sunnylink_toggle.show_description(False)
def _update_state(self):
super()._update_state()
self._sunnylink_enabled = ui_state.params.get_bool("SunnylinkEnabled")
self._sunnylink_toggle.set_right_value(tr("Dongle ID") + ": " + self._get_sunnylink_dongle_id())
self._sunnylink_toggle.action_item.set_enabled(not ui_state.is_onroad())
self._sunnylink_toggle.action_item.set_state(self._sunnylink_enabled)
self._sunnylink_uploader_toggle.action_item.set_enabled(self._sunnylink_enabled)
self.handle_backup_restore_progress()
sponsor_btn_text = tr("THANKS ♥") if ui_state.sunnylink_state.is_sponsor() else tr("SPONSOR")
tier_name = ui_state.sunnylink_state.get_sponsor_tier().name.capitalize() or tr("Not Sponsor")
self._sponsor_btn.action_item.set_text(sponsor_btn_text)
self._sponsor_btn.action_item.set_value(tier_name, ui_state.sunnylink_state.get_sponsor_tier_color())
self._sponsor_btn.action_item.set_enabled(self._sunnylink_enabled)
pair_btn_text = tr("Paired") if ui_state.sunnylink_state.is_paired() else tr("Not Paired")
self._pair_btn.action_item.set_text(pair_btn_text)
self._pair_btn.action_item.set_enabled(self._sunnylink_enabled)
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
super().show_event()
ui_state.sunnylink_state.set_settings_open(True)
self._scroller.show_event()
self._sunnylink_description.set_visible(False)
def hide_event(self):
super().hide_event()
ui_state.sunnylink_state.set_settings_open(False)
@@ -7,15 +7,12 @@ See the LICENSE.md file in the root directory for more details.
import math
import pyray as rl
import time
from dataclasses import dataclass
from openpilot.selfdrive.ui.ui_state import ui_state, ChestnutState
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr_noop
PING_TIMEOUT_NS = 80_000_000_000 # 80 seconds in nanoseconds
METRIC_HEIGHT = 126
METRIC_MARGIN = 30
METRIC_START_Y = 300
@@ -35,8 +32,6 @@ class Colors:
GOOD = rl.WHITE
WARNING = rl.Color(218, 202, 37, 255)
DANGER = rl.Color(201, 34, 49, 255)
PROGRESS = rl.Color(0, 134, 233, 255)
DISABLED = rl.Color(128, 128, 128, 255)
# UI elements
METRIC_BORDER = rl.Color(255, 255, 255, 85)
@@ -58,35 +53,10 @@ class MetricData:
class SidebarSP:
def __init__(self):
self._sunnylink_status = MetricData(tr_noop("SUNNYLINK"), tr_noop("OFFLINE"), Colors.WARNING)
self._chestnut_green_img = gui_app.texture("icons_mici/chestnut_green.png", CHESTNUT_ICON_WIDTH, CHESTNUT_ICON_HEIGHT)
self._chestnut_default_img = gui_app.texture("icons_mici/chestnut.png", CHESTNUT_ICON_WIDTH, CHESTNUT_ICON_HEIGHT)
self._chestnut_orange_img = gui_app.texture("icons_mici/chestnut_orange.png", CHESTNUT_ICON_WIDTH, CHESTNUT_ICON_HEIGHT)
def _update_sunnylink_status(self):
if not ui_state.params.get_bool("SunnylinkEnabled"):
self._sunnylink_status.update(tr_noop("SUNNYLINK"), tr_noop("DISABLED"), Colors.DISABLED)
return
last_ping = ui_state.params.get("LastSunnylinkPingTime") or 0
dongle_id = ui_state.params.get("SunnylinkDongleId")
is_online = last_ping and (time.monotonic_ns() - last_ping) < PING_TIMEOUT_NS
is_temp_fault = ui_state.params.get_bool("SunnylinkTempFault")
is_registering = not is_temp_fault and dongle_id in (None, "", UNREGISTERED_SUNNYLINK_DONGLE_ID)
# Determine status/color pair based on priority
if last_ping:
status, color = (tr_noop("ONLINE"), Colors.GOOD) if is_online else (tr_noop("ERROR"), Colors.DANGER)
elif is_temp_fault:
status, color = (tr_noop("FAULT"), Colors.WARNING)
elif is_registering:
status, color = (tr_noop("REGIST..."), Colors.PROGRESS)
else:
status, color = (tr_noop("OFFLINE"), Colors.DANGER)
self._sunnylink_status.update(tr_noop("SUNNYLINK"), status, color)
def _get_home_icon(self, default_img: rl.Texture) -> tuple[rl.Texture, rl.Vector2, float]:
default_pos = rl.Vector2(HOME_BTN.x, HOME_BTN.y)
state = ui_state.chestnut_state
@@ -104,11 +74,3 @@ class SidebarSP:
x = HOME_BTN.x + (HOME_BTN.width - icon.width) / 2
y = HOME_BTN.y + (HOME_BTN.height - icon.height) / 2
return icon, rl.Vector2(x, y), opacity
def _draw_metrics_w_sunnylink(self, rect: rl.Rectangle, _temp, _panda, _connect):
metrics = [_temp, _panda, _connect] # sunnylink status hidden
start_y = int(rect.y) + METRIC_START_Y
available_height = max(0, int(HOME_BTN.y) - METRIC_MARGIN - METRIC_HEIGHT - start_y)
spacing = available_height / max(1, len(metrics) - 1)
return metrics, start_y, spacing
@@ -12,7 +12,6 @@ from opendbc.sunnypilot.car.interfaces import has_bsm
from openpilot.common.params import Params
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.display import OnroadBrightness
from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_active_source
from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.sunnypilot.widgets.screen_saver import ScreenSaverSP
@@ -39,8 +38,6 @@ class UIStateSP:
"lateralTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "lateralDelay"
]
self.sunnylink_state = SunnylinkState()
self.screensaver = ScreenSaverSP(params=self.params)
self.screensaver_enabled: bool = False
@@ -59,19 +56,12 @@ class UIStateSP:
self.rocket_fuel: bool = False
self.speed_limit_mode = None
self.standstill_timer: bool = False
self.sunnylink_enabled: bool = False
self.torque_bar: bool = False
self.enforce_torque_control: bool = False
self.custom_torque_params: bool = False
self.torque_override_enabled: bool = False
self._sp_initialized: bool = False
def update(self) -> None:
if self.sunnylink_enabled:
self.sunnylink_state.start()
else:
self.sunnylink_state.stop()
def onroad_brightness_handle_alerts(self, _ui_state, alert):
if _ui_state.sm.recv_frame["carState"] < _ui_state.started_frame:
return
@@ -172,7 +162,6 @@ class UIStateSP:
self.rocket_fuel = self.params.get_bool("RocketFuel")
self.speed_limit_mode = self.params.get("SpeedLimitMode", return_default=True)
self.standstill_timer = self.params.get_bool("StandstillTimer")
self.sunnylink_enabled = self.params.get_bool("SunnylinkEnabled")
self.torque_bar = self.params.get_bool("TorqueBar")
self.enforce_torque_control = self.params.get_bool("EnforceTorqueControl")
self.custom_torque_params = self.params.get_bool("CustomTorqueParams")
+1 -2
View File
@@ -14,7 +14,7 @@ from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.ui.tests.diff.diff import DIFF_OUT_DIR
from openpilot.system.updated.updated import parse_release_notes
from openpilot.common.version import terms_version, training_version, terms_version_sp, sunnylink_consent_version
from openpilot.common.version import terms_version, training_version, terms_version_sp
LayoutVariant = Literal["tizi"]
@@ -31,7 +31,6 @@ def setup_state():
params.put("UpdaterCurrentDescription", "0.10.1 / test-branch / abc1234 / Nov 30", block=True)
params.put("UpdaterCurrentReleaseNotes", parse_release_notes(BASEDIR), block=True)
params.put("HasAcceptedTermsSP", terms_version_sp, block=True)
params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version, block=True)
# Params for mici home
params.put("Version", "0.10.1", block=True)
params.put("GitBranch", "test-branch", block=True)
+67 -511
View File
@@ -2,42 +2,6 @@ msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Pair your GitHub account"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Early Access: Become a sunnypilot Sponsor"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Scan the QR code to login to your GitHub account"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Follow the prompts to complete the pairing process"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Choose your sponsorship tier and confirm your support"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/tree_dialog.py
msgid "Search"
msgstr ""
@@ -46,7 +10,6 @@ msgstr ""
msgid "Enter search query"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
#: openpilot/system/ui/widgets/confirm_dialog.py
#: openpilot/system/ui/widgets/html_render.py
msgid "OK"
@@ -73,7 +36,6 @@ msgid "Advanced"
msgstr ""
#: openpilot/selfdrive/ui/layouts/onboarding.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py
@@ -249,7 +211,7 @@ msgid "Open"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
#: openpilot/selfdrive/ui/widgets/setup.py
msgid "Firehose Mode"
msgstr ""
@@ -388,7 +350,6 @@ msgid "VI"
msgstr ""
#: openpilot/selfdrive/ui/layouts/sidebar.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "ONLINE"
msgstr ""
@@ -397,7 +358,6 @@ msgid "RA"
msgstr ""
#: openpilot/selfdrive/ui/layouts/sidebar.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "OFFLINE"
msgstr ""
@@ -418,7 +378,6 @@ msgid "PANDA"
msgstr ""
#: openpilot/selfdrive/ui/layouts/sidebar.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "ERROR"
msgstr ""
@@ -595,6 +554,56 @@ msgstr ""
msgid "up to date, last checked {}"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Frequently Asked Questions"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Does it matter how or where I drive?"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Nope, just drive as you normally would."
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Do all of my segments get pulled in Firehose Mode?"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "No, we selectively pull a subset of your segments."
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "What's a good USB-C adapter?"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Any fast phone or laptop charger should be fine."
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Does it matter which software I run?"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Yes, only upstream openpilot (and particular forks) are able to be used for training."
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "ACTIVE"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "INACTIVE: connect to an unmetered network"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "{} segment of your driving is in the training dataset so far."
msgid_plural "{} segments of your driving is in the training dataset so far."
msgstr[0] ""
msgstr[1] ""
#: openpilot/selfdrive/ui/layouts/settings/device.py
msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)"
msgstr ""
@@ -661,7 +670,6 @@ msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/device.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Dongle ID"
msgstr ""
@@ -724,7 +732,6 @@ msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/device.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "N/A"
msgstr ""
@@ -757,7 +764,6 @@ msgid " Steering torque response calibration is {}% complete."
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py
#: openpilot/selfdrive/ui/mici/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/initial_cruise_speed.py
@@ -766,8 +772,6 @@ msgid "km/h"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py
#: openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py
#: openpilot/selfdrive/ui/mici/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/initial_cruise_speed.py
@@ -818,7 +822,6 @@ msgid "UI Debug Mode"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/developer.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py
msgid "Enable"
msgstr ""
@@ -871,264 +874,15 @@ msgstr ""
msgid "Reboot Device"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/onboarding.py
#: openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py
#: openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py
#: openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py
msgid "camera starting"
msgstr ""
#: openpilot/selfdrive/ui/mici/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/sunnypilot/onroad/hud_renderer.py
msgid "MAX"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "small model"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "big model"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "unavailable"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "getting ready"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "active model"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "select model"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "refresh models"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "cancel download"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "clear cache"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "small models"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "big models"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "clearing..."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "fetching..."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "download failed"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "error"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "active"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "No models available"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "No models are available for this hardware yet. Connect to the internet and refresh the model list."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "slide to"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "verifying"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "downloading"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "cancel verification"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "progress"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "downloaded"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "queued"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "device id"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "sponsor tier"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "backup settings"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "restore settings"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "slide to restore"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "slide to backup"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "enable sunnylink"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "sunnylink uploader"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "thanks"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "sponsor"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "paired"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "pair"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "no internet"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "please connect to WiFi & try again"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "backing up"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "sunnylink dongle id not found"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "please reboot & try again"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "backup"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "failed"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "restoring"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "restore"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "settings backed up"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "unable to restore"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "sunnylink"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "models"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "slide to exit always offroad"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "slide to force offroad"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "disengage to enable always offroad"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py
msgid "pair with sunnylink"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py
msgid "become a sunnypilot sponsor"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py
msgid "Disable"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "SUNNYLINK"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "DISABLED"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "FAULT"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "REGIST..."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/software.py
msgid "When enabled, automatic software updates will be off.<br><b>This requires a reboot to take effect.</b>"
msgstr ""
@@ -1633,6 +1387,14 @@ msgstr ""
msgid "{} will drive when the chestnut is ready."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "download failed"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "verifying"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "Only available when vehicle is off, or always offroad mode is on"
msgstr ""
@@ -1657,10 +1419,18 @@ msgstr ""
msgid "Live Steer Delay:"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "queued"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "{} will drive. If it fails during a drive, {} takes over until the next drive."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "No models are available for this hardware yet. Connect to the internet and refresh the model list."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "Actuator Delay:"
msgstr ""
@@ -1750,134 +1520,6 @@ msgstr ""
msgid "Speed Limit"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Welcome back!! We're excited to see you've enabled sunnylink again!"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "THANKS ♥"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "SPONSOR"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Not Sponsor"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Paired"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Not Paired"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "For secure backup, restore, and remote configuration"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Are you sure you want to backup your current sunnypilot settings?"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Are you sure you want to restore the last backed up sunnypilot settings?"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid ", but we'll be here when you're ready to come back."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Click the Sponsor button for more details"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Enable sunnylink"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "This is the master switch, it will allow you to cutoff any sunnylink requests should you want to do that."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Sponsor Status"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Become a sponsor of sunnypilot to get early access to sunnylink features when they become available."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Pair GitHub Account"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Enable sunnylink uploader (infrastructure test)"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Backup Settings"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Restore Settings"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Not going to lie, it's sad to see you disabled sunnylink"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Sponsorship isn't required for basic backup/restore"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Enable sunnylink uploader to allow sunnypilot to upload your driving data to sunnypilot servers. "
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "(Only for highest tiers, and does NOT bring ANY benefit to you yet. We are just testing data volume.)"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Backup Failed"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Please reboot and try again."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Settings backup completed."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Restore Failed"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Unable to restore the settings, try again later."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "sunnylink Dongle ID not found. "
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "This may be due to weak internet connection or sunnylink registration issue. "
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Settings restored. Confirm to restart the interface."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/developer.py
msgid "Show Advanced Controls"
msgstr ""
@@ -2400,92 +2042,6 @@ msgstr ""
msgid "Near"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py
msgid "no alerts"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/software.py
msgid "Please connect to Wi-Fi to update."
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/software.py
msgid "Failed to get available branches. Ensure you're connected to the internet and try again."
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
msgid "Frequently Asked Questions"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
msgid "Does it matter how or where I drive?"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
msgid "Nope, just drive as you normally would."
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
msgid "Do all of my segments get pulled in Firehose Mode?"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
msgid "No, we selectively pull a subset of your segments."
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
msgid "What's a good USB-C adapter?"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
msgid "Any fast phone or laptop charger should be fine."
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
msgid "Does it matter which software I run?"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
msgid "Yes, only upstream openpilot (and particular forks) are able to be used for training."
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
msgid "ACTIVE"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
msgid "INACTIVE: connect to an unmetered network"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
msgid "{} segment of your driving is in the training dataset so far."
msgid_plural "{} segments of your driving is in the training dataset so far."
msgstr[0] ""
msgstr[1] ""
#: openpilot/selfdrive/ui/mici/layouts/settings/device.py
msgid "Please connect to Wi-Fi to complete initial pairing."
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/device.py
msgid "Device must be registered with the comma.ai backend to pair."
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py
msgid "KPH"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py
msgid "MPH"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py
msgid "kph"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py
msgid "Ensure you're connected to the internet and try again."
msgstr ""
#: openpilot/selfdrive/selfdrived/events.py
msgid "Toggle stock LKAS on or off to engage"
msgstr ""
File diff suppressed because it is too large Load Diff
+13 -458
View File
@@ -24,14 +24,6 @@ msgstr " Your device is pointed {:.1f}° {} and {:.1f}° {}."
msgid "%"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "(Only for highest tiers, and does NOT bring ANY benefit to you yet. We are just testing data volume.)"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid ", but we'll be here when you're ready to come back."
msgstr ""
#: openpilot/selfdrive/ui/layouts/sidebar.py
msgid "--"
msgstr "--"
@@ -76,7 +68,7 @@ msgstr "<br><br>Steering lag calibration is complete."
msgid "<br><br>Steering lag calibration is {}% complete."
msgstr "<br><br>Steering lag calibration is {}% complete."
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "ACTIVE"
msgstr ""
@@ -160,14 +152,10 @@ msgstr ""
msgid "Always On"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Any fast phone or laptop charger should be fine."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Are you sure you want to backup your current sunnypilot settings?"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py
msgid "Are you sure you want to enter Always Offroad mode?"
msgstr ""
@@ -192,10 +180,6 @@ msgstr ""
msgid "Are you sure you want to reset calibration?"
msgstr "Are you sure you want to reset calibration?"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Are you sure you want to restore the last backed up sunnypilot settings?"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/software.py
msgid "Are you sure you want to uninstall?"
msgstr "Are you sure you want to uninstall?"
@@ -233,7 +217,6 @@ msgid "BRAKE!"
msgstr ""
#: openpilot/selfdrive/ui/layouts/onboarding.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py
@@ -243,14 +226,6 @@ msgstr ""
msgid "Back"
msgstr "Back"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Backup Failed"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Backup Settings"
msgstr ""
#: openpilot/selfdrive/selfdrived/events.py
msgid "Be ready to take over at any time"
msgstr ""
@@ -259,10 +234,6 @@ msgstr ""
msgid "Become a comma prime member at connect.comma.ai"
msgstr "Become a comma prime member at connect.comma.ai"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Become a sponsor of sunnypilot to get early access to sunnylink features when they become available."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "Big Model"
msgstr ""
@@ -442,10 +413,6 @@ msgstr ""
msgid "Choose how Automatic Lane Centering (ALC) behaves after the brake pedal is manually pressed in sunnypilot."
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Choose your sponsorship tier and confirm your support"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "Clear Cache"
msgstr ""
@@ -458,10 +425,6 @@ msgstr ""
msgid "Click \"add new device\" and scan the QR code on the right"
msgstr "Click \"add new device\" and scan the QR code on the right"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Click the Sponsor button for more details"
msgstr ""
#: openpilot/selfdrive/ui/widgets/offroad_alerts.py
msgid "Close"
msgstr "Close"
@@ -551,10 +514,6 @@ msgstr ""
msgid "DELETE"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "DISABLED"
msgstr ""
#: openpilot/selfdrive/selfdrived/events.py
msgid "DISENGAGE IMMEDIATELY"
msgstr ""
@@ -615,20 +574,12 @@ msgstr ""
msgid "Device"
msgstr "Device"
#: openpilot/selfdrive/ui/mici/layouts/settings/device.py
msgid "Device must be registered with the comma.ai backend to pair."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py
msgid ""
"Device will automatically shutdown after set time once the engine is turned off.\n"
"(30h is the default)"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py
msgid "Disable"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/software.py
msgid "Disable Updates"
msgstr ""
@@ -697,21 +648,20 @@ msgstr ""
msgid "Distance"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Do all of my segments get pulled in Firehose Mode?"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Does it matter how or where I drive?"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Does it matter which software I run?"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/device.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Dongle ID"
msgstr "Dongle ID"
@@ -769,7 +719,6 @@ msgid "EDIT"
msgstr "EDIT"
#: openpilot/selfdrive/ui/layouts/sidebar.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "ERROR"
msgstr "ERROR"
@@ -777,10 +726,6 @@ msgstr "ERROR"
msgid "ETH"
msgstr "ETH"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Early Access: Become a sunnypilot Sponsor"
msgstr ""
#: openpilot/selfdrive/selfdrived/events.py
msgid "Electronic Stability Control Active"
msgstr ""
@@ -790,7 +735,6 @@ msgid "Electronic Stability Control Disabled"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/developer.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py
msgid "Enable"
msgstr "Enable"
@@ -852,18 +796,6 @@ msgstr ""
msgid "Enable if your vehicle uses an aftermarket blind spot monitoring (BSM) system. Factory BSM enablement will be disabled."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Enable sunnylink"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Enable sunnylink uploader (infrastructure test)"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Enable sunnylink uploader to allow sunnypilot to upload your driving data to sunnypilot servers. "
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering.py
msgid "Enable this to enforce sunnypilot to steer with Torque lateral control."
msgstr ""
@@ -888,10 +820,6 @@ msgstr ""
msgid "Engage lateral and longitudinal control with cruise control engagement."
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py
msgid "Ensure you're connected to the internet and try again."
msgstr ""
#: openpilot/system/ui/widgets/network.py
msgid "Enter APN"
msgstr "Enter APN"
@@ -944,10 +872,6 @@ msgstr ""
msgid "Experimental feature to enable auto-resume during stop-and-go for certain supported Subaru platforms."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "FAULT"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/osm.py
msgid "FETCHING..."
@@ -957,10 +881,6 @@ msgstr ""
msgid "FORGETTING..."
msgstr "FORGETTING..."
#: openpilot/selfdrive/ui/mici/layouts/settings/software.py
msgid "Failed to get available branches. Ensure you're connected to the internet and try again."
msgstr ""
#: openpilot/selfdrive/selfdrived/events.py
msgid "Fan Malfunction"
msgstr ""
@@ -978,7 +898,7 @@ msgid "Firehose"
msgstr "Firehose"
#: openpilot/selfdrive/ui/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
#: openpilot/selfdrive/ui/widgets/setup.py
msgid "Firehose Mode"
msgstr "Firehose Mode"
@@ -991,18 +911,10 @@ msgstr ""
msgid "Fixed: Adds a fixed offset [Speed Limit + Offset]"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Follow the prompts to complete the pairing process"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/visuals.py
msgid "For applicable vehicles, always display the true vehicle current speed from wheel speed sensors."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "For secure backup, restore, and remote configuration"
msgstr ""
#: openpilot/system/ui/widgets/network.py
msgid "Forget"
msgstr "Forget"
@@ -1011,7 +923,7 @@ msgstr "Forget"
msgid "Forget Wi-Fi Network \"{}\"?"
msgstr "Forget Wi-Fi Network \"{}\"?"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Frequently Asked Questions"
msgstr ""
@@ -1063,7 +975,7 @@ msgstr ""
msgid "How long the screen saver runs before the screen turns off."
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "INACTIVE: connect to an unmetered network"
msgstr ""
@@ -1075,10 +987,6 @@ msgstr "INSTALL"
msgid "IP Address"
msgstr "IP Address"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py
msgid "Info"
msgstr ""
@@ -1107,10 +1015,6 @@ msgstr ""
msgid "Invalid LKAS setting"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/developer.py
msgid "Joystick Debug Mode"
msgstr "Joystick Debug Mode"
@@ -1123,10 +1027,6 @@ msgstr ""
msgid "KM"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py
msgid "KPH"
msgstr ""
#: openpilot/selfdrive/selfdrived/events.py
msgid "LKAS Fault: Restart the Car"
msgstr ""
@@ -1205,16 +1105,11 @@ msgstr ""
msgid "MADS Screen Activation"
msgstr ""
#: openpilot/selfdrive/ui/mici/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/sunnypilot/onroad/hud_renderer.py
msgid "MAX"
msgstr "MAX"
#: openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py
msgid "MPH"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py
msgid "Manual Real-Time Tuning"
msgstr ""
@@ -1273,7 +1168,6 @@ msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/device.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "N/A"
msgstr "N/A"
@@ -1307,14 +1201,9 @@ msgid "No SSH keys found for user '{}'"
msgstr "No SSH keys found for user '{}'"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "No models are available for this hardware yet. Connect to the internet and refresh the model list."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "No models available"
msgstr ""
#: openpilot/selfdrive/ui/widgets/offroad_alerts.py
msgid "No release notes available."
msgstr "No release notes available."
@@ -1323,7 +1212,7 @@ msgstr "No release notes available."
msgid "No vehicle selected"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "No, we selectively pull a subset of your segments."
msgstr ""
@@ -1335,26 +1224,14 @@ msgstr ""
msgid "None: No Offset"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Nope, just drive as you normally would."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Not Paired"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Not Sponsor"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py
msgid "Not fingerprinted or manually selected"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Not going to lie, it's sad to see you disabled sunnylink"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py
msgid "Note: For vehicles without LFA/LKAS button, disabling this will prevent lateral control engagement."
msgstr ""
@@ -1376,18 +1253,15 @@ msgid "Nudgeless"
msgstr ""
#: openpilot/selfdrive/ui/layouts/sidebar.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "OFFLINE"
msgstr "OFFLINE"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
#: openpilot/system/ui/widgets/confirm_dialog.py
#: openpilot/system/ui/widgets/html_render.py
msgid "OK"
msgstr "OK"
#: openpilot/selfdrive/ui/layouts/sidebar.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "ONLINE"
msgstr "ONLINE"
@@ -1463,22 +1337,10 @@ msgstr "PRIME FEATURES:"
msgid "Pair Device"
msgstr "Pair Device"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Pair GitHub Account"
msgstr ""
#: openpilot/selfdrive/ui/widgets/setup.py
msgid "Pair device"
msgstr "Pair device"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Pair your GitHub account"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink."
msgstr ""
#: openpilot/selfdrive/ui/widgets/pairing_dialog.py
msgid "Pair your device to your comma account"
msgstr "Pair your device to your comma account"
@@ -1488,10 +1350,6 @@ msgstr "Pair your device to your comma account"
msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer."
msgstr "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer."
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Paired"
msgstr ""
#: openpilot/selfdrive/selfdrived/events.py
msgid "Parking Brake Engaged"
msgstr ""
@@ -1536,22 +1394,10 @@ msgstr ""
msgid "Please connect to Wi-Fi to complete initial pairing"
msgstr "Please connect to Wi-Fi to complete initial pairing"
#: openpilot/selfdrive/ui/mici/layouts/settings/device.py
msgid "Please connect to Wi-Fi to complete initial pairing."
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/software.py
msgid "Please connect to Wi-Fi to update."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/software.py
msgid "Please enable \"Always Offroad\" mode or turn off the vehicle to adjust these toggles."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Please reboot and try again."
msgstr ""
#: openpilot/selfdrive/selfdrived/events.py
msgid "Posenet Speed Invalid"
msgstr ""
@@ -1625,10 +1471,6 @@ msgstr ""
msgid "REFRESH"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "REGIST..."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py
#: openpilot/selfdrive/ui/widgets/ssh_key.py
msgid "REMOVE"
@@ -1650,10 +1492,6 @@ msgstr ""
msgid "Radar Temporarily Unavailable"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/honda.py
msgid "Read factory radar targets from CAN. Read-only; factory AEB and longitudinal control are unaffected. Requires a vehicle restart."
msgstr ""
@@ -1742,14 +1580,6 @@ msgstr "Reset Calibration"
msgid "Reset Settings"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Restore Failed"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Restore Settings"
msgstr ""
#: openpilot/selfdrive/selfdrived/events.py
msgid "Reverse"
msgstr ""
@@ -1788,18 +1618,10 @@ msgstr ""
msgid "SELECT"
msgstr "SELECT"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "SPONSOR"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/developer.py
msgid "SSH Keys"
msgstr "SSH Keys"
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "SUNNYLINK"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/volkswagen.py
msgid "Scale steering torque down at low speeds to avoid EPS lockout."
msgstr ""
@@ -1808,14 +1630,6 @@ msgstr ""
msgid "Scan"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Scan the QR code to login to your GitHub account"
msgstr ""
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page"
msgstr ""
#: openpilot/system/ui/widgets/network.py
msgid "Scanning Wi-Fi networks..."
msgstr "Scanning Wi-Fi networks..."
@@ -1902,14 +1716,6 @@ msgstr "Serial"
msgid "Set the maximum speed for lane turn desires. Default is 19 mph."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Settings backup completed."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Settings restored. Confirm to restart the interface."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py
msgid "Short Press Increment"
msgstr ""
@@ -1988,14 +1794,6 @@ msgstr ""
msgid "Speedometer: Hide from Onroad Screen"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Sponsor Status"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Sponsorship isn't required for basic backup/restore"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/toggles.py
msgid "Standard"
msgstr "Standard"
@@ -2107,10 +1905,6 @@ msgstr "TAKE CONTROL IMMEDIATELY"
msgid "TEMP"
msgstr "TEMP"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "THANKS ♥"
msgstr ""
#: openpilot/selfdrive/selfdrived/events.py
msgid "Take Control"
msgstr ""
@@ -2163,14 +1957,6 @@ msgstr ""
msgid "This feature requires sunnypilot longitudinal control to be available."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "This is the master switch, it will allow you to cutoff any sunnylink requests should you want to do that."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "This may be due to weak internet connection or sunnylink registration issue. "
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py
msgid "This platform only supports Disengage mode due to vehicle limitations."
msgstr ""
@@ -2263,10 +2049,6 @@ msgstr "UNINSTALL"
msgid "UPDATE"
msgstr "UPDATE"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Unable to restore the settings, try again later."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py
msgid "Unified Engagement Mode (UEM)"
msgstr ""
@@ -2381,15 +2163,11 @@ msgstr ""
msgid "Warning: Provides a warning when exceeding the current road's speed limit."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Welcome back!! We're excited to see you've enabled sunnylink again!"
msgstr ""
#: openpilot/selfdrive/ui/layouts/onboarding.py
msgid "Welcome to sunnypilot"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "What's a good USB-C adapter?"
msgstr ""
@@ -2433,7 +2211,7 @@ msgstr ""
msgid "Yes, delete all maps"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Yes, only upstream openpilot (and particular forks) are able to be used for training."
msgstr ""
@@ -2457,69 +2235,18 @@ msgstr ""
msgid "Your vehicle will use the Predictive longitudinal tuning."
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "active"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "active model"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "backing up"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "backup"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "backup settings"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py
msgid "become a sunnypilot sponsor"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "big"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "big model"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "big models"
msgstr ""
#: openpilot/selfdrive/ui/mici/layouts/onboarding.py
#: openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py
#: openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py
#: openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py
msgid "camera starting"
msgstr "camera starting"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "cancel download"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "cancel verification"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/software.py
msgid "checking..."
msgstr "checking..."
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "clear cache"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "clearing..."
msgstr ""
#: openpilot/selfdrive/ui/widgets/prime.py
msgid "comma prime"
msgstr "comma prime"
@@ -2532,55 +2259,22 @@ msgstr ""
msgid "default"
msgstr "default"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "device id"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "disengage to enable always offroad"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/device.py
msgid "down"
msgstr "down"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "download failed"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "downloaded"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "downloading"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/software.py
msgid "downloading..."
msgstr "downloading..."
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "enable sunnylink"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "error"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "failed"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/software.py
msgid "failed to check for update"
msgstr "failed to check for update"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "fetching..."
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/software.py
msgid "finalizing update..."
msgstr "finalizing update..."
@@ -2589,16 +2283,11 @@ msgstr "finalizing update..."
msgid "for \"{}\""
msgstr "for \"{}\""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "getting ready"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py
msgid "h"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py
#: openpilot/selfdrive/ui/mici/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/initial_cruise_speed.py
@@ -2606,10 +2295,6 @@ msgstr ""
msgid "km/h"
msgstr "km/h"
#: openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py
msgid "kph"
msgstr ""
#: openpilot/system/ui/widgets/network.py
msgid "leave blank for automatic configuration"
msgstr "leave blank for automatic configuration"
@@ -2634,13 +2319,7 @@ msgstr ""
msgid "metered"
msgstr "metered"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "models"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py
#: openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py
#: openpilot/selfdrive/ui/mici/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/initial_cruise_speed.py
@@ -2652,14 +2331,6 @@ msgstr "mph"
msgid "never"
msgstr "never"
#: openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py
msgid "no alerts"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "no internet"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/software.py
msgid "now"
msgstr "now"
@@ -2668,18 +2339,6 @@ msgstr "now"
msgid "openpilot Canceled"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "pair"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py
msgid "pair with sunnylink"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "paired"
msgstr ""
#: openpilot/selfdrive/selfdrived/events.py
msgid "paramsd Permanent Error"
msgstr ""
@@ -2688,39 +2347,10 @@ msgstr ""
msgid "paramsd Temporary Error"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "please connect to WiFi & try again"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "please reboot & try again"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "progress"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "queued"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "refresh models"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "restore"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "restore settings"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "restoring"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/device.py
msgid "right"
msgstr "right"
@@ -2729,71 +2359,10 @@ msgstr "right"
msgid "s"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "select model"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "settings backed up"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "slide to"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "slide to backup"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "slide to exit always offroad"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "slide to force offroad"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "slide to restore"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "small"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "small model"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "small models"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "sponsor"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "sponsor tier"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "sunnylink"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "sunnylink Dongle ID not found. "
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "sunnylink dongle id not found"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "sunnylink uploader"
msgstr ""
#: openpilot/selfdrive/ui/layouts/settings/developer.py
msgid "sunnypilot Longitudinal Control (Alpha)"
msgstr ""
@@ -2822,18 +2391,6 @@ msgstr ""
msgid "take control"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "thanks"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "unable to restore"
msgstr ""
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "unavailable"
msgstr ""
#: openpilot/system/ui/widgets/network.py
msgid "unmetered"
msgstr "unmetered"
@@ -2855,7 +2412,6 @@ msgid "update available"
msgstr "update available"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "verifying"
msgstr ""
@@ -2888,7 +2444,7 @@ msgid_plural "{} minutes ago"
msgstr[0] "{} minute ago"
msgstr[1] "{} minutes ago"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "{} segment of your driving is in the training dataset so far."
msgid_plural "{} segments of your driving is in the training dataset so far."
msgstr[0] "{} segment of your driving is in the training dataset so far."
@@ -2905,4 +2461,3 @@ msgstr ""
#: openpilot/selfdrive/ui/widgets/prime.py
msgid "✓ SUBSCRIBED"
msgstr "✓ SUBSCRIBED"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -457
View File
@@ -24,14 +24,6 @@ msgstr " 您的设备朝向 {:.1f}° {} 与 {:.1f}° {}。"
msgid "%"
msgstr "%"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "(Only for highest tiers, and does NOT bring ANY benefit to you yet. We are just testing data volume.)"
msgstr "(仅限最高等级,目前不会给您带来任何好处。我们只是在测试数据量。)"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid ", but we'll be here when you're ready to come back."
msgstr ",但当您准备好回来时,我们会一直在这里。"
#: openpilot/selfdrive/ui/layouts/sidebar.py
msgid "--"
msgstr "--"
@@ -76,7 +68,7 @@ msgstr "<br><br>转向延迟校准完成。"
msgid "<br><br>Steering lag calibration is {}% complete."
msgstr "<br><br>转向延迟校准已完成 {}%。"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "ACTIVE"
msgstr "激活"
@@ -160,14 +152,10 @@ msgstr "允许驾驶员在 openpilot 启用时提供有限的转向输入。"
msgid "Always On"
msgstr "始终开启"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Any fast phone or laptop charger should be fine."
msgstr "任何快速手机或笔记本电脑充电器都可以。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Are you sure you want to backup your current sunnypilot settings?"
msgstr "您确定要备份当前 sunnypilot 设置吗?"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py
msgid "Are you sure you want to enter Always Offroad mode?"
msgstr "您确定要进入停车设置模式吗?"
@@ -192,10 +180,6 @@ msgstr "您确定要将所有 sunnypilot 设置恢复为默认值吗?一旦重
msgid "Are you sure you want to reset calibration?"
msgstr "确定要重置校准吗?"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Are you sure you want to restore the last backed up sunnypilot settings?"
msgstr "您确定要恢复上次备份的 sunnypilot 设置吗?"
#: openpilot/selfdrive/ui/layouts/settings/software.py
msgid "Are you sure you want to uninstall?"
msgstr "确定要卸载吗?"
@@ -233,7 +217,6 @@ msgid "BRAKE!"
msgstr "刹车!"
#: openpilot/selfdrive/ui/layouts/onboarding.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/lane_change_settings.py
@@ -243,14 +226,6 @@ msgstr "刹车!"
msgid "Back"
msgstr "返回"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Backup Failed"
msgstr "备份失败"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Backup Settings"
msgstr "备份设置"
#: openpilot/selfdrive/selfdrived/events.py
msgid "Be ready to take over at any time"
msgstr "随时准备接管"
@@ -259,10 +234,6 @@ msgstr "随时准备接管"
msgid "Become a comma prime member at connect.comma.ai"
msgstr "前往 connect.comma.ai 成为 comma prime 会员"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Become a sponsor of sunnypilot to get early access to sunnylink features when they become available."
msgstr "成为 sunnypilot 赞助者,抢先体验 sunnylink 即将推出的功能。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "Big Model"
msgstr "大模型"
@@ -442,10 +413,6 @@ msgstr "如果车辆已通电,更改此设置将重启 sunnypilot。"
msgid "Choose how Automatic Lane Centering (ALC) behaves after the brake pedal is manually pressed in sunnypilot."
msgstr "选择在 sunnypilot 中手动踩下刹车踏板后自动车道居中(ALC)的行为。"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Choose your sponsorship tier and confirm your support"
msgstr "选择您的赞助等级并确认支持"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "Clear Cache"
msgstr "清除缓存"
@@ -458,10 +425,6 @@ msgstr "清除模型缓存"
msgid "Click \"add new device\" and scan the QR code on the right"
msgstr "点击“添加新设备”,扫描右侧二维码"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Click the Sponsor button for more details"
msgstr "点击赞助按钮了解更多详情"
#: openpilot/selfdrive/ui/widgets/offroad_alerts.py
msgid "Close"
msgstr "关闭"
@@ -551,10 +514,6 @@ msgstr "自定义扭矩参数"
msgid "DELETE"
msgstr "删除"
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "DISABLED"
msgstr "已禁用"
#: openpilot/selfdrive/selfdrived/events.py
msgid "DISENGAGE IMMEDIATELY"
msgstr "立即退出"
@@ -615,10 +574,6 @@ msgstr "开发者界面"
msgid "Device"
msgstr "设备"
#: openpilot/selfdrive/ui/mici/layouts/settings/device.py
msgid "Device must be registered with the comma.ai backend to pair."
msgstr "设备必须先在 comma.ai 后端注册才能配对。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py
msgid ""
"Device will automatically shutdown after set time once the engine is turned off.\n"
@@ -627,10 +582,6 @@ msgstr ""
"发动机关闭后,设备将在设定时间后自动关机。\n"
"(默认为 30 小时)"
#: openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py
msgid "Disable"
msgstr "禁用"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/software.py
msgid "Disable Updates"
msgstr "禁用更新"
@@ -699,21 +650,20 @@ msgstr "启用横向控制时,在驾驶屏幕上显示转向弧。"
msgid "Distance"
msgstr "距离"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Do all of my segments get pulled in Firehose Mode?"
msgstr "Firehose 模式下我的所有片段都会被拉取吗?"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Does it matter how or where I drive?"
msgstr "我驾驶的方式或地点有关系吗?"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Does it matter which software I run?"
msgstr "我运行的软件有关系吗?"
#: openpilot/selfdrive/ui/layouts/settings/device.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Dongle ID"
msgstr "设备 ID"
@@ -771,7 +721,6 @@ msgid "EDIT"
msgstr "编辑"
#: openpilot/selfdrive/ui/layouts/sidebar.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "ERROR"
msgstr "错误"
@@ -779,10 +728,6 @@ msgstr "错误"
msgid "ETH"
msgstr "ETH"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Early Access: Become a sunnypilot Sponsor"
msgstr "抢先体验:成为 sunnypilot 赞助者"
#: openpilot/selfdrive/selfdrived/events.py
msgid "Electronic Stability Control Active"
msgstr "电子稳定控制已激活"
@@ -792,7 +737,6 @@ msgid "Electronic Stability Control Disabled"
msgstr "电子稳定控制已禁用"
#: openpilot/selfdrive/ui/layouts/settings/developer.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py
msgid "Enable"
msgstr "启用"
@@ -854,18 +798,6 @@ msgstr "即使 sunnypilot 未启用也开启驾驶员监控。"
msgid "Enable if your vehicle uses an aftermarket blind spot monitoring (BSM) system. Factory BSM enablement will be disabled."
msgstr "如果您的车辆使用副厂盲区监测(BSM)系统,请开启此开关。开启后将禁用原厂 BSM 功能。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Enable sunnylink"
msgstr "启用 sunnylink"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Enable sunnylink uploader (infrastructure test)"
msgstr "启用 sunnylink 上传器(基础设施测试)"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Enable sunnylink uploader to allow sunnypilot to upload your driving data to sunnypilot servers. "
msgstr "启用 sunnylink 上传器以允许 sunnypilot 将您的驾驶数据上传到 sunnypilot 服务器。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering.py
msgid "Enable this to enforce sunnypilot to steer with Torque lateral control."
msgstr "启用此项以强制 sunnypilot 使用扭矩横向控制转向。"
@@ -890,10 +822,6 @@ msgstr "强制扭矩横向控制"
msgid "Engage lateral and longitudinal control with cruise control engagement."
msgstr "启用巡航控制时同时启用横向和纵向控制。"
#: openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py
msgid "Ensure you're connected to the internet and try again."
msgstr "请确认已连接互联网后重试。"
#: openpilot/system/ui/widgets/network.py
msgid "Enter APN"
msgstr "输入 APN"
@@ -946,10 +874,6 @@ msgstr "退出停车设置模式"
msgid "Experimental feature to enable auto-resume during stop-and-go for certain supported Subaru platforms."
msgstr "实验性功能:为某些受支持的 Subaru 平台在走走停停时启用自动恢复。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "FAULT"
msgstr "故障"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/osm.py
msgid "FETCHING..."
@@ -959,10 +883,6 @@ msgstr "获取中..."
msgid "FORGETTING..."
msgstr "正在遗忘..."
#: openpilot/selfdrive/ui/mici/layouts/settings/software.py
msgid "Failed to get available branches. Ensure you're connected to the internet and try again."
msgstr "无法获取可用分支。请确认已连接互联网后重试。"
#: openpilot/selfdrive/selfdrived/events.py
msgid "Fan Malfunction"
msgstr "风扇故障"
@@ -980,7 +900,7 @@ msgid "Firehose"
msgstr "数据洪流"
#: openpilot/selfdrive/ui/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
#: openpilot/selfdrive/ui/widgets/setup.py
msgid "Firehose Mode"
msgstr "Firehose 模式"
@@ -993,18 +913,10 @@ msgstr "固定"
msgid "Fixed: Adds a fixed offset [Speed Limit + Offset]"
msgstr "固定:添加固定偏移量 [限速 + 偏移]"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Follow the prompts to complete the pairing process"
msgstr "按照提示完成配对过程"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/visuals.py
msgid "For applicable vehicles, always display the true vehicle current speed from wheel speed sensors."
msgstr "对于适用车辆,始终显示来自轮速传感器的真实车辆当前速度。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "For secure backup, restore, and remote configuration"
msgstr "用于安全的备份、恢复和远程配置"
#: openpilot/system/ui/widgets/network.py
msgid "Forget"
msgstr "忘记"
@@ -1013,7 +925,7 @@ msgstr "忘记"
msgid "Forget Wi-Fi Network \"{}\"?"
msgstr "要忘记 Wi‑Fi 网络“{}”吗?"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Frequently Asked Questions"
msgstr "常见问题"
@@ -1065,7 +977,7 @@ msgstr "小时"
msgid "How long the screen saver runs before the screen turns off."
msgstr "屏幕保护程序运行多长时间后屏幕关闭。"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "INACTIVE: connect to an unmetered network"
msgstr "未激活:连接到非计费网络"
@@ -1077,10 +989,6 @@ msgstr "安装"
msgid "IP Address"
msgstr "IP 地址"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai"
msgstr "如果赞助状态未更新,请联系社区论坛管理员:https://community.sunnypilot.ai"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py
msgid "Info"
msgstr "信息"
@@ -1109,10 +1017,6 @@ msgstr "交互超时"
msgid "Invalid LKAS setting"
msgstr "LKAS 设置无效"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues"
msgstr "加入我们的社区论坛 https://community.sunnypilot.ai,如有问题请联系管理员"
#: openpilot/selfdrive/ui/layouts/settings/developer.py
msgid "Joystick Debug Mode"
msgstr "摇杆调试模式"
@@ -1125,10 +1029,6 @@ msgstr "遥杆模式"
msgid "KM"
msgstr "公里"
#: openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py
msgid "KPH"
msgstr "公里/小时"
#: openpilot/selfdrive/selfdrived/events.py
msgid "LKAS Fault: Restart the Car"
msgstr "LKAS 故障:请重启车辆"
@@ -1207,16 +1107,11 @@ msgstr "内存不足:请重启设备"
msgid "MADS Screen Activation"
msgstr "MADS 屏幕激活"
#: openpilot/selfdrive/ui/mici/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/sunnypilot/onroad/hud_renderer.py
msgid "MAX"
msgstr "最大"
#: openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py
msgid "MPH"
msgstr "英里/小时"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/torque_settings.py
msgid "Manual Real-Time Tuning"
msgstr "手动实时调校"
@@ -1275,7 +1170,6 @@ msgstr "全时车道居中控制 (MADS)"
#: openpilot/selfdrive/ui/layouts/settings/device.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "N/A"
msgstr "无"
@@ -1309,14 +1203,9 @@ msgid "No SSH keys found for user '{}'"
msgstr "未找到用户“{}”的 SSH 密钥"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "No models are available for this hardware yet. Connect to the internet and refresh the model list."
msgstr "当前硬件暂无可用模型。请连接网络后刷新模型列表。"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "No models available"
msgstr "暂无可用模型"
#: openpilot/selfdrive/ui/widgets/offroad_alerts.py
msgid "No release notes available."
msgstr "暂无发行说明。"
@@ -1325,7 +1214,7 @@ msgstr "暂无发行说明。"
msgid "No vehicle selected"
msgstr "未选择车辆"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "No, we selectively pull a subset of your segments."
msgstr "不,我们有选择地拉取您的一部分片段。"
@@ -1337,26 +1226,14 @@ msgstr "无"
msgid "None: No Offset"
msgstr "无:不加偏移"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Nope, just drive as you normally would."
msgstr "不需要,正常驾驶即可。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Not Paired"
msgstr "未配对"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Not Sponsor"
msgstr "非赞助者"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py
msgid "Not fingerprinted or manually selected"
msgstr "未指纹识别或手动选择"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Not going to lie, it's sad to see you disabled sunnylink"
msgstr "说实话,看到您禁用 sunnylink 我们很难过"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py
msgid "Note: For vehicles without LFA/LKAS button, disabling this will prevent lateral control engagement."
msgstr "注意:对于没有 LFA/LKAS 按钮的车辆,禁用此选项将阻止横向控制启用。"
@@ -1378,18 +1255,15 @@ msgid "Nudgeless"
msgstr "无轻推"
#: openpilot/selfdrive/ui/layouts/sidebar.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "OFFLINE"
msgstr "离线"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
#: openpilot/system/ui/widgets/confirm_dialog.py
#: openpilot/system/ui/widgets/html_render.py
msgid "OK"
msgstr "确定"
#: openpilot/selfdrive/ui/layouts/sidebar.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "ONLINE"
msgstr "在线"
@@ -1465,22 +1339,10 @@ msgstr "PRIME 功能:"
msgid "Pair Device"
msgstr "配对设备"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Pair GitHub Account"
msgstr "配对 GitHub 账户"
#: openpilot/selfdrive/ui/widgets/setup.py
msgid "Pair device"
msgstr "配对设备"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Pair your GitHub account"
msgstr "配对您的 GitHub 账户"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Pair your GitHub account to grant your device sponsor benefits, including API access on sunnylink."
msgstr "配对您的 GitHub 账户,为您的设备提供赞助者权益,包括 sunnylink 上的 API 访问权限。"
#: openpilot/selfdrive/ui/widgets/pairing_dialog.py
msgid "Pair your device to your comma account"
msgstr "将设备配对到您的 comma 账号"
@@ -1490,10 +1352,6 @@ msgstr "将设备配对到您的 comma 账号"
msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer."
msgstr "将设备与 comma connect(connect.comma.ai)配对,领取您的 comma prime 优惠。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Paired"
msgstr "已配对"
#: openpilot/selfdrive/selfdrived/events.py
msgid "Parking Brake Engaged"
msgstr "手刹已拉起"
@@ -1538,22 +1396,10 @@ msgstr "使用硬件蜂鸣器播放接管、退出和警告提示音。"
msgid "Please connect to Wi-Fi to complete initial pairing"
msgstr "请连接 Wi‑Fi 以完成初始配对"
#: openpilot/selfdrive/ui/mici/layouts/settings/device.py
msgid "Please connect to Wi-Fi to complete initial pairing."
msgstr "请连接 Wi-Fi 以完成初始配对。"
#: openpilot/selfdrive/ui/mici/layouts/settings/software.py
msgid "Please connect to Wi-Fi to update."
msgstr "请连接 Wi-Fi 以进行更新。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/software.py
msgid "Please enable \"Always Offroad\" mode or turn off the vehicle to adjust these toggles."
msgstr "请启用\"停车设置模式\"或关闭车辆电源以调整这些开关。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Please reboot and try again."
msgstr "请重启后重试。"
#: openpilot/selfdrive/selfdrived/events.py
msgid "Posenet Speed Invalid"
msgstr "Posenet 速度无效"
@@ -1627,10 +1473,6 @@ msgstr "远程协助"
msgid "REFRESH"
msgstr "刷新"
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "REGIST..."
msgstr "注册..."
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py
#: openpilot/selfdrive/ui/widgets/ssh_key.py
msgid "REMOVE"
@@ -1652,10 +1494,6 @@ msgstr "雷达错误:请重启车辆"
msgid "Radar Temporarily Unavailable"
msgstr "雷达暂时不可用"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status"
msgstr "重新进入「sunnylink」面板以确认赞助状态"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/honda.py
msgid "Read factory radar targets from CAN. Read-only; factory AEB and longitudinal control are unaffected. Requires a vehicle restart."
msgstr "读取原厂 Bosch-A 雷达目标,仅接收不发送,不影响原车 AEB 和纵向控制。切换后需要重新启动车辆。"
@@ -1744,14 +1582,6 @@ msgstr "重置校准"
msgid "Reset Settings"
msgstr "重置设置"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Restore Failed"
msgstr "恢复失败"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Restore Settings"
msgstr "恢复设置"
#: openpilot/selfdrive/selfdrived/events.py
msgid "Reverse"
msgstr "倒车"
@@ -1790,18 +1620,10 @@ msgstr "右侧和底部"
msgid "SELECT"
msgstr "选择"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "SPONSOR"
msgstr "赞助"
#: openpilot/selfdrive/ui/layouts/settings/developer.py
msgid "SSH Keys"
msgstr "SSH 密钥"
#: openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py
msgid "SUNNYLINK"
msgstr "SUNNYLINK"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/volkswagen.py
msgid "Scale steering torque down at low speeds to avoid EPS lockout."
msgstr "低速时按速度降低转向扭矩,避免EPS锁定。"
@@ -1810,14 +1632,6 @@ msgstr "低速时按速度降低转向扭矩,避免EPS锁定。"
msgid "Scan"
msgstr "扫描"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Scan the QR code to login to your GitHub account"
msgstr "扫描二维码登录您的 GitHub 账户"
#: openpilot/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page"
msgstr "扫描二维码访问 sunnyhaibin 的 GitHub 赞助页面"
#: openpilot/system/ui/widgets/network.py
msgid "Scanning Wi-Fi networks..."
msgstr "正在扫描 Wi‑Fi 网络…"
@@ -1904,14 +1718,6 @@ msgstr "序列号"
msgid "Set the maximum speed for lane turn desires. Default is 19 mph."
msgstr "设置车道转弯期望的最大速度。默认为 19 mph。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Settings backup completed."
msgstr "设置备份完成。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Settings restored. Confirm to restart the interface."
msgstr "设置已恢复。确认以重启界面。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py
msgid "Short Press Increment"
msgstr "短按增量"
@@ -1990,14 +1796,6 @@ msgstr "速度表:始终显示真实速度"
msgid "Speedometer: Hide from Onroad Screen"
msgstr "速度表:从行车屏幕隐藏"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Sponsor Status"
msgstr "赞助状态"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Sponsorship isn't required for basic backup/restore"
msgstr "基本备份/恢复不需要赞助"
#: openpilot/selfdrive/ui/layouts/settings/toggles.py
msgid "Standard"
msgstr "标准"
@@ -2109,10 +1907,6 @@ msgstr "请立即接管控制"
msgid "TEMP"
msgstr "温度"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "THANKS ♥"
msgstr "谢谢 ♥"
#: openpilot/selfdrive/selfdrived/events.py
msgid "Take Control"
msgstr "接管控制"
@@ -2165,14 +1959,6 @@ msgstr "车辆行驶时此功能不可用。"
msgid "This feature requires sunnypilot longitudinal control to be available."
msgstr "此功能需要 sunnypilot 纵向控制可用。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "This is the master switch, it will allow you to cutoff any sunnylink requests should you want to do that."
msgstr "这是主开关,如果需要,您可以切断任何 sunnylink 请求。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "This may be due to weak internet connection or sunnylink registration issue. "
msgstr "这可能是由于网络连接较弱或 sunnylink 注册问题。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py
msgid "This platform only supports Disengage mode due to vehicle limitations."
msgstr "由于车辆限制,此平台仅支持脱离模式。"
@@ -2268,10 +2054,6 @@ msgstr "卸载"
msgid "UPDATE"
msgstr "更新"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Unable to restore the settings, try again later."
msgstr "无法恢复设置,请稍后重试。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/steering_sub_layouts/mads_settings.py
msgid "Unified Engagement Mode (UEM)"
msgstr "统一启用模式 (UEM)"
@@ -2386,15 +2168,11 @@ msgstr "警告"
msgid "Warning: Provides a warning when exceeding the current road's speed limit."
msgstr "警告:超过当前道路限速时提供警告。"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "Welcome back!! We're excited to see you've enabled sunnylink again!"
msgstr "欢迎回来!!很高兴看到您再次启用 sunnylink!"
#: openpilot/selfdrive/ui/layouts/onboarding.py
msgid "Welcome to sunnypilot"
msgstr "欢迎使用 sunnypilot"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "What's a good USB-C adapter?"
msgstr "什么是好的 USB-C 适配器?"
@@ -2438,7 +2216,7 @@ msgstr "是"
msgid "Yes, delete all maps"
msgstr "是,删除所有地图"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "Yes, only upstream openpilot (and particular forks) are able to be used for training."
msgstr "是的,只有上游 openpilot(及特定分支)能用于训练。"
@@ -2462,69 +2240,18 @@ msgstr "您的车辆将使用动态纵向调校。"
msgid "Your vehicle will use the Predictive longitudinal tuning."
msgstr "您的车辆将使用预测纵向调校。"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "active"
msgstr "活动"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "active model"
msgstr "活动模型"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "backing up"
msgstr "备份中"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "backup"
msgstr "备份"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "backup settings"
msgstr "备份设置"
#: openpilot/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py
msgid "become a sunnypilot sponsor"
msgstr "成为 sunnypilot 赞助者"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "big"
msgstr "大"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "big model"
msgstr "大模型"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "big models"
msgstr "大模型"
#: openpilot/selfdrive/ui/mici/layouts/onboarding.py
#: openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py
#: openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py
#: openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py
msgid "camera starting"
msgstr "相机启动中"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "cancel download"
msgstr "取消下载"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "cancel verification"
msgstr "取消校验"
#: openpilot/selfdrive/ui/layouts/settings/software.py
msgid "checking..."
msgstr "检查中..."
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "clear cache"
msgstr "清除缓存"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "clearing..."
msgstr "清除中…"
#: openpilot/selfdrive/ui/widgets/prime.py
msgid "comma prime"
msgstr "comma prime"
@@ -2537,55 +2264,22 @@ msgstr "copyparty 服务"
msgid "default"
msgstr "默认"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "device id"
msgstr "设备 ID"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "disengage to enable always offroad"
msgstr "脱离以启用停车设置模式"
#: openpilot/selfdrive/ui/layouts/settings/device.py
msgid "down"
msgstr "下"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "download failed"
msgstr "下载失败"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "downloaded"
msgstr "已下载"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "downloading"
msgstr "下载中"
#: openpilot/selfdrive/ui/layouts/settings/software.py
msgid "downloading..."
msgstr "下载中..."
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "enable sunnylink"
msgstr "启用 sunnylink"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "error"
msgstr "错误"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "failed"
msgstr "失败"
#: openpilot/selfdrive/ui/layouts/settings/software.py
msgid "failed to check for update"
msgstr "检查更新失败"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "fetching..."
msgstr "获取中…"
#: openpilot/selfdrive/ui/layouts/settings/software.py
msgid "finalizing update..."
msgstr "正在完成更新..."
@@ -2594,16 +2288,11 @@ msgstr "正在完成更新..."
msgid "for \"{}\""
msgstr "用于“{}”"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "getting ready"
msgstr "准备中"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py
msgid "h"
msgstr "小时"
#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py
#: openpilot/selfdrive/ui/mici/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/initial_cruise_speed.py
@@ -2611,10 +2300,6 @@ msgstr "小时"
msgid "km/h"
msgstr "km/h"
#: openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py
msgid "kph"
msgstr "公里/小时"
#: openpilot/system/ui/widgets/network.py
msgid "leave blank for automatic configuration"
msgstr "留空以自动配置"
@@ -2639,13 +2324,7 @@ msgstr "分钟"
msgid "metered"
msgstr "计量"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "models"
msgstr "模型"
#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py
#: openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py
#: openpilot/selfdrive/ui/mici/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/onroad/hud_renderer.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_settings.py
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/initial_cruise_speed.py
@@ -2657,14 +2336,6 @@ msgstr "mph"
msgid "never"
msgstr "从不"
#: openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py
msgid "no alerts"
msgstr "无警报"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "no internet"
msgstr "无网络"
#: openpilot/selfdrive/ui/layouts/settings/software.py
msgid "now"
msgstr "现在"
@@ -2673,18 +2344,6 @@ msgstr "现在"
msgid "openpilot Canceled"
msgstr "openpilot 已取消"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "pair"
msgstr "配对"
#: openpilot/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py
msgid "pair with sunnylink"
msgstr "与 sunnylink 配对"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "paired"
msgstr "已配对"
#: openpilot/selfdrive/selfdrived/events.py
msgid "paramsd Permanent Error"
msgstr "paramsd 永久错误"
@@ -2693,39 +2352,10 @@ msgstr "paramsd 永久错误"
msgid "paramsd Temporary Error"
msgstr "paramsd 临时错误"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "please connect to WiFi & try again"
msgstr "请连接 WiFi 并重试"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "please reboot & try again"
msgstr "请重启并重试"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "progress"
msgstr "进度"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "queued"
msgstr "已排队"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "refresh models"
msgstr "刷新模型"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "restore"
msgstr "恢复"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "restore settings"
msgstr "恢复设置"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "restoring"
msgstr "恢复中"
#: openpilot/selfdrive/ui/layouts/settings/device.py
msgid "right"
msgstr "右"
@@ -2734,71 +2364,10 @@ msgstr "右"
msgid "s"
msgstr "秒"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "select model"
msgstr "选择模型"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "settings backed up"
msgstr "设置已备份"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "slide to"
msgstr "滑动以"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "slide to backup"
msgstr "滑动以备份"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "slide to exit always offroad"
msgstr "滑动以退出停车设置模式"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "slide to force offroad"
msgstr "滑动以强制停车"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "slide to restore"
msgstr "滑动以恢复"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
msgid "small"
msgstr "小"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "small model"
msgstr "小模型"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "small models"
msgstr "小模型"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "sponsor"
msgstr "赞助"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "sponsor tier"
msgstr "赞助等级"
#: openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py
msgid "sunnylink"
msgstr "sunnylink"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
msgid "sunnylink Dongle ID not found. "
msgstr "未找到 sunnylink Dongle ID。"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "sunnylink dongle id not found"
msgstr "未找到 sunnylink dongle id"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "sunnylink uploader"
msgstr "sunnylink 上传器"
#: openpilot/selfdrive/ui/layouts/settings/developer.py
msgid "sunnypilot Longitudinal Control (Alpha)"
msgstr "sunnypilot 纵向控制(测试版)"
@@ -2827,18 +2396,6 @@ msgstr "sunnypilot 不会接管油门和刹车控制。将使用原厂 Toyota
msgid "take control"
msgstr "接管控制"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "thanks"
msgstr "谢谢"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
msgid "unable to restore"
msgstr "无法恢复"
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "unavailable"
msgstr "不可用"
#: openpilot/system/ui/widgets/network.py
msgid "unmetered"
msgstr "不限流量"
@@ -2860,7 +2417,6 @@ msgid "update available"
msgstr "有可用更新"
#: openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
#: openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py
msgid "verifying"
msgstr "校验中"
@@ -2893,7 +2449,7 @@ msgid_plural "{} minutes ago"
msgstr[0] "{} 分钟前"
msgstr[1] "{} 分钟前"
#: openpilot/selfdrive/ui/mici/layouts/settings/firehose.py
#: openpilot/selfdrive/ui/layouts/settings/firehose_base.py
msgid "{} segment of your driving is in the training dataset so far."
msgid_plural "{} segments of your driving is in the training dataset so far."
msgstr[0] "到目前为止,您有 {} 段驾驶数据被用于训练数据集。"
File diff suppressed because it is too large Load Diff
-1
View File
@@ -153,7 +153,6 @@ class UIState(UIStateSP):
self._update_status()
self._update_chestnut_state()
device.update()
UIStateSP.update(self)
def _params_refresh_worker(self):
drop_realtime()
@@ -13,8 +13,6 @@ from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.selfdrive.controls.lib.nnlc.helpers import get_nn_model_path
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.helpers import set_speed_limit_assist_availability
from openpilot.sunnypilot.sunnylink.statsd import STATSLOGSP
def _enforce_torque_lateral_control(CP: structs.CarParams, params: Params | None = None, enabled: bool = False) -> bool:
if params is None:
@@ -95,12 +93,6 @@ def setup_interfaces(CI: CarInterfaceBase, params: Params | None = None) -> None
_initialize_torque_lateral_control(CI, CI.CP, enforce_torque, nnlc_enabled)
_cleanup_unsupported_params(CI.CP, CI.CP_SP)
try:
STATSLOGSP.raw('sunnypilot.car_params', CI.CP.to_dict())
except RuntimeError:
pass # to_dict fails on macOS due to library issues.
# STATSLOGSP.raw('sunnypilot_params.car_params_sp', CP_SP.to_dict()) # https://github.com/sunnypilot/opendbc/pull/361
def initialize_params(params) -> list[dict[str, Any]]:
keys: list = []
-151
View File
@@ -1,151 +0,0 @@
import json
import os
import random
import time
import jwt
from typing import cast
from datetime import datetime, timedelta, UTC
from openpilot.common.api.base import BaseApi
from openpilot.common.params import Params
from openpilot.common.hardware import HARDWARE
from openpilot.common.hardware.hw import Paths
API_HOST = os.getenv('SUNNYLINK_API_HOST', 'https://stg.api.sunnypilot.ai')
UNREGISTERED_SUNNYLINK_DONGLE_ID = "UnregisteredDevice"
MAX_RETRIES = 6
CRASH_LOG_DIR = Paths.crash_log_root()
class SunnylinkApi(BaseApi):
def __init__(self, dongle_id):
super().__init__(dongle_id, API_HOST)
self.user_agent = "sunnypilot-"
self.spinner = None
self.params = Params()
def api_get(self, endpoint, method='GET', timeout=10, access_token=None, session=None, json=None, **kwargs):
if not self.params.get_bool("SunnylinkEnabled"):
return None
return super().api_get(endpoint, method, timeout, access_token, session, json, **kwargs)
def get_token(self, payload_extra=None, expiry_hours=1):
# Add your additional data here
additional_data = {}
return super()._get_token(payload_extra, expiry_hours, **additional_data)
def _status_update(self, message):
print(message)
if self.spinner:
self.spinner.update(message)
time.sleep(0.5)
def _resolve_dongle_ids(self):
sunnylink_dongle_id = self.params.get("SunnylinkDongleId")
comma_dongle_id = self.dongle_id or self.params.get("DongleId")
return sunnylink_dongle_id, comma_dongle_id
def _resolve_imeis(self):
imei = '865420071781913'
imei_try = 0
while imei is None and imei_try < MAX_RETRIES:
try:
imei = HARDWARE.get_imei()
except Exception:
self._status_update(f"Error getting imei, trying again... [{imei_try + 1}/{MAX_RETRIES}]")
time.sleep(1)
imei_try += 1
return imei, ""
def _resolve_serial(self):
return (self.params.get("HardwareSerial")
or HARDWARE.get_serial())
def register_device(self, spinner=None, timeout=60, verbose=False):
self.spinner = spinner
sunnylink_dongle_id, comma_dongle_id = self._resolve_dongle_ids()
if comma_dongle_id is None:
self._status_update("Comma dongle ID not found, deferring sunnylink's registration to comma's registration process.")
return None
imei1, imei2 = self._resolve_imeis()
serial = self._resolve_serial()
if sunnylink_dongle_id not in (None, UNREGISTERED_SUNNYLINK_DONGLE_ID):
return sunnylink_dongle_id
jwt_algo, private_key, public_key = BaseApi.get_key_pair()
start_time = time.monotonic()
successful_registration = False
if not public_key:
sunnylink_dongle_id = UNREGISTERED_SUNNYLINK_DONGLE_ID
self._status_update("Public key not found, setting dongle ID to unregistered.")
else:
Params().put("LastSunnylinkPingTime", 0, block=True) # Reset the last ping time to 0 if we are trying to register
backoff = 1
while True:
register_token = jwt.encode({'register': True, 'exp': datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1)},
cast(str, private_key), algorithm=jwt_algo)
try:
if verbose or time.monotonic() - start_time < timeout / 2:
self._status_update("Registering device to sunnylink...")
elif time.monotonic() - start_time >= timeout / 2:
self._status_update("Still registering device to sunnylink...")
resp = self.api_get("v2/pilotauth/", method='POST', timeout=15, imei=imei1, imei2=imei2, serial=serial,
comma_dongle_id=comma_dongle_id, public_key=public_key, register_token=register_token)
if resp is None:
raise Exception("Unable to register device, request was None")
if resp.status_code in (409, 412):
timeout = time.monotonic() - start_time # Don't retry if the public key is already in use
key_in_use = "Public key is already in use, is your key unique? Contact your vendor for a new key."
unsafe_key = "Public key is known to not be unique and it's unsafe. Contact your vendor for a new key."
error_message = key_in_use if resp.status_code == 409 else unsafe_key
raise Exception(error_message)
if resp.status_code != 200:
raise Exception(f"Failed to register with sunnylink. Status code: {resp.status_code}\nData\n:{resp.text}")
dongleauth = json.loads(resp.text)
sunnylink_dongle_id = dongleauth["device_id"]
if sunnylink_dongle_id:
self._status_update("Device registered successfully.")
successful_registration = True
break
except Exception as e:
if verbose:
self._status_update(f"Waiting {backoff}s before retry, Exception occurred during registration: [{str(e)}]")
if not os.path.exists(CRASH_LOG_DIR):
os.makedirs(CRASH_LOG_DIR)
with open(f'{CRASH_LOG_DIR}/error.txt', 'a') as f:
f.write(f"[{datetime.now()}] sunnylink: {str(e)}\n")
backoff = min(backoff * 2 * (0.5 + random.random()), 60)
time.sleep(backoff)
if time.monotonic() - start_time > timeout:
self._status_update(f"Giving up on sunnylink's registration after {timeout}s. Will retry on next boot.")
time.sleep(3)
break
self.params.put("SunnylinkDongleId", sunnylink_dongle_id or UNREGISTERED_SUNNYLINK_DONGLE_ID, block=True)
# Set the last ping time to the current time since we were just talking to the API
last_ping = int((time.monotonic() if successful_registration else start_time) * 1e9)
Params().put("LastSunnylinkPingTime", last_ping, block=True)
# Disable sunnylink if registration was not successful
if not successful_registration:
Params().put_bool("SunnylinkEnabled", False, block=True)
self.spinner = None
return sunnylink_dongle_id
@@ -1,5 +0,0 @@
#!/usr/bin/env python3
from openpilot.system.athena.manage_athenad import manage_athenad
if __name__ == '__main__':
manage_athenad("SunnylinkDongleId", "SunnylinkdPid", 'sunnylinkd', 'openpilot.sunnypilot.sunnylink.athena.sunnylinkd')
@@ -1,333 +0,0 @@
#!/usr/bin/env python3
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from __future__ import annotations
import base64
import errno
import gzip
import json
import os
import ssl
import threading
import time
from functools import partial
from openpilot.system.athena.rpc import dispatcher
from openpilot.common.params import Params, ParamKeyType
from openpilot.common.realtime import set_core_affinity
from openpilot.common.swaglog import cloudlog
from openpilot.common.hardware.hw import Paths
from openpilot.system.athena.athenad import ws_send, jsonrpc_handler, \
recv_queue, UploadQueueCache, upload_queue, cur_upload_items, backoff, ws_manage, log_handler, start_local_proxy_shim, upload_handler, stat_handler
from websocket import (ABNF, WebSocket, WebSocketException, WebSocketTimeoutException,
create_connection, WebSocketConnectionClosedException)
import openpilot.cereal.messaging as messaging
from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL, DEFAULT_BIG_MODEL
from openpilot.sunnypilot.selfdrive.car.sync_sunnylink_params import update_car_list_param
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, get_param_as_byte, save_param_from_base64_encoded_string
from openpilot.sunnypilot.sunnylink.capabilities import generate_capabilities, CAPABILITY_LABELS
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import generate_schema
SUNNYLINK_ATHENA_HOST = os.getenv('SUNNYLINK_ATHENA_HOST', 'wss://athena.sunnylink.ai')
HANDLER_THREADS = int(os.getenv('HANDLER_THREADS', "4"))
LOCAL_PORT_WHITELIST = {8022}
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",
"CompletedSunnylinkConsentVersion",
"CompletedTrainingVersion",
"GithubUsername", # Could grant SSH access
"GithubSshKeys", # Direct SSH key injection
"HasAcceptedTerms",
"HasAcceptedTermsSP",
"OnroadCycleRequested", # Prevent remote cycle trigger
"ParamsVersion", # Device-managed version counter
}
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():
try:
opcode, data = ws.recv_data(control_frame=True)
if opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY):
if opcode == ABNF.OPCODE_TEXT:
data = data.decode("utf-8")
recv_queue.put_nowait(data)
cloudlog.debug(f"sunnylinkd.ws_recv.recv {data}")
elif opcode in (ABNF.OPCODE_PING, ABNF.OPCODE_PONG):
cloudlog.debug("sunnylinkd.ws_recv.pong")
last_ping = int(time.monotonic() * 1e9)
Params().put("LastSunnylinkPingTime", last_ping, block=True)
except WebSocketTimeoutException:
ns_since_last_ping = int(time.monotonic() * 1e9) - last_ping
if ns_since_last_ping > SUNNYLINK_RECONNECT_TIMEOUT_S * 1e9:
cloudlog.warning("sunnylinkd.ws_recv.timeout")
end_event.set()
except Exception as e:
if isinstance(e, WebSocketConnectionClosedException):
cloudlog.warning(f"sunnylinkd.ws_recv.{type(e).__name__}")
else:
cloudlog.exception("sunnylinkd.ws_recv.exception")
end_event.set()
def ws_ping(ws: WebSocket, end_event: threading.Event) -> None:
ws.ping() # Send the first ping
while not end_event.wait(SUNNYLINK_RECONNECT_TIMEOUT_S * 0.7): # Sleep about 70% before a timeout
try:
ws.ping()
cloudlog.debug("sunnylinkd.ws_recv.ws_ping: Pinging")
except Exception:
cloudlog.exception("sunnylinkd.ws_ping.exception")
end_event.set()
cloudlog.debug("sunnylinkd.ws_ping.end_event is set, exiting ws_ping thread")
def sunny_log_handler(end_event: threading.Event, comma_prime_cellular_end_event: threading.Event) -> None:
while not end_event.wait(0.1):
if not comma_prime_cellular_end_event.is_set():
log_handler(comma_prime_cellular_end_event, SUNNYLINK_LOG_ATTR_NAME)
comma_prime_cellular_end_event.set()
@dispatcher.add_method
def toggleLogUpload(enabled: bool):
DISALLOW_LOG_UPLOAD.clear() if enabled and DISALLOW_LOG_UPLOAD.is_set() else DISALLOW_LOG_UPLOAD.set()
@dispatcher.add_method
def getParamsAllKeys() -> list[str]:
keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()]
return keys
@dispatcher.add_method
def getParamsMetadata() -> str:
"""Return settings_ui.json + live capabilities as gzip-compressed, base64-encoded string.
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["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
@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()]
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]
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"")
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
})
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
except Exception as e:
cloudlog.exception("sunnylinkd.getParams.exception", e)
raise
@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:
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(params.get("ParamsVersion") or "0")
params.put("ParamsVersion", str(current + 1), block=True)
except Exception:
pass
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):
try:
set_core_affinity([0, 1, 2, 3])
except Exception:
cloudlog.exception("failed to set core affinity")
while sunnylink_need_register(params):
cloudlog.info("Waiting for sunnylink registration to complete")
time.sleep(10)
sunnylink_dongle_id = params.get("SunnylinkDongleId")
sunnylink_api = SunnylinkApi(sunnylink_dongle_id)
UploadQueueCache.initialize(upload_queue)
update_car_list_param()
ws_uri = f"{SUNNYLINK_ATHENA_HOST}"
conn_start = None
conn_retries = 0
while (exit_event is None or not exit_event.is_set()) and sunnylink_ready(params):
try:
if conn_start is None:
conn_start = time.monotonic()
cloudlog.event("sunnylinkd.main.connecting_ws", ws_uri=ws_uri, retries=conn_retries)
ws = create_connection(
ws_uri,
header={"Authorization": f"Bearer {sunnylink_api.get_token()}"},
enable_multithread=True,
sslopt={"cert_reqs": ssl.CERT_NONE if "localhost" in ws_uri else ssl.CERT_REQUIRED},
timeout=SUNNYLINK_RECONNECT_TIMEOUT_S,
)
cloudlog.event("sunnylinkd.main.connected_ws", ws_uri=ws_uri, retries=conn_retries,
duration=time.monotonic() - conn_start)
conn_start = None
conn_retries = 0
cur_upload_items.clear()
handle_long_poll(ws, exit_event)
except (KeyboardInterrupt, SystemExit):
break
except Exception as e:
conn_retries += 1
params.remove("LastSunnylinkPingTime")
if isinstance(e, (ConnectionError, TimeoutError, WebSocketException)):
cloudlog.warning(f"sunnylinkd.main.{type(e).__name__}")
elif isinstance(e, OSError):
name = errno.errorcode.get(e.errno or -1, "UNKNOWN")
msg = f"sunnylinkd.main.OSError.{name} ({e.errno})"
is_expected_error = e.errno in (errno.ENETDOWN, errno.ENETRESET, errno.ENETUNREACH)
cloudlog.warning(msg) if is_expected_error else cloudlog.exception(msg)
else:
cloudlog.exception("sunnylinkd.main.exception")
time.sleep(backoff(conn_retries))
if not sunnylink_ready(params):
cloudlog.debug("Reached end of sunnylinkd.main while sunnylink is not ready. Waiting 60s before retrying")
time.sleep(60)
if __name__ == "__main__":
main()
@@ -1,60 +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.
"""
from openpilot.sunnypilot.sunnylink.athena import sunnylinkd
from openpilot.common.test import OpenpilotTestCase
class TestSunnylinkdMethods(OpenpilotTestCase):
def setup_method(self):
self.saved_params = []
self.original_save = sunnylinkd.save_param_from_base64_encoded_string
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 test_saveParams_blocked(self):
blocked_params = {
"GithubUsername": "attacker",
"GithubSshKeys": "ssh-rsa attacker_key",
}
sunnylinkd.saveParams(blocked_params)
assert len(self.saved_params) == 0
def test_saveParams_allowed(self):
allowed_params = {
"SpeedLimitOffset": "5",
"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):
mixed_params = {
"GithubUsername": "attacker",
"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"
@@ -1,34 +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.
"""
from Crypto.Cipher import AES
class AESCipher:
def __init__(self, key: bytes, iv: bytes):
if len(key) not in (16, 32):
raise ValueError("Key must be 16 bytes (AES-128) or 32 bytes (AES-256).")
if len(iv) != 16:
raise ValueError("IV must be 16 bytes.")
self.key = key
self.iv = iv
def encrypt(self, data: bytes) -> bytes:
block_size = 16
padding_length = block_size - (len(data) % block_size)
padding = bytes([padding_length]) * padding_length
padded_data = data + padding
cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
return cipher.encrypt(padded_data)
def decrypt(self, encrypted_data: bytes) -> bytes:
cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
decrypted_data = cipher.decrypt(encrypted_data)
padding_length = decrypted_data[-1]
return decrypted_data[:-padding_length]
@@ -1,286 +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 requests
import time
from enum import Enum
from typing import Any
from openpilot.common.git import get_branch
from openpilot.common.params import Params, ParamKeyFlag
from openpilot.common.realtime import Ratekeeper
from openpilot.common.swaglog import cloudlog
from openpilot.common.version import get_version
from openpilot.cereal import messaging, custom
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
from openpilot.sunnypilot.sunnylink.backups.utils import decrypt_compressed_data, encrypt_compressed_data, SnakeCaseEncoder
from openpilot.sunnypilot.sunnylink.utils import get_param_as_byte, save_param_from_base64_encoded_string
class OperationType(Enum):
BACKUP = "backup"
RESTORE = "restore"
class BackupManagerSP:
"""Manages device configuration backups to/from sunnylink"""
def __init__(self):
self.params = Params()
self.device_id = self.params.get("SunnylinkDongleId")
self.api = SunnylinkApi(self.device_id)
self.pm = messaging.PubMaster(["backupManagerSP"])
# Status tracking
self.backup_status = custom.BackupManagerSP.Status.idle
self.restore_status = custom.BackupManagerSP.Status.idle
# Unified progress & operation type (only one operation runs at a time)
self.progress = 0.0
self.operation: OperationType | None = None
self.last_error = ""
self._session = requests.Session() # reuse session to reduce SSL handshake overhead
def _report_status(self) -> None:
"""Reports current backup manager state through the messaging system."""
msg = messaging.new_message('backupManagerSP', valid=True)
backup_state = msg.backupManagerSP
backup_state.backupStatus = self.backup_status
backup_state.restoreStatus = self.restore_status
# Both progress fields use the unified progress value
backup_state.backupProgress = self.progress
backup_state.restoreProgress = self.progress
backup_state.lastError = self.last_error
# Optionally, add a field for operation type if supported:
# backup_state.operationType = self.operation.value if self.operation else "none"
self.pm.send('backupManagerSP', msg)
def _update_progress(self, progress: float, op_type: OperationType) -> None:
"""Updates the unified progress and operation type, then reports status."""
self.progress = progress
self.operation = op_type
self._report_status()
def _collect_config_data(self) -> dict[str, Any]:
"""Collects configuration data to be backed up."""
config_data = {}
params_to_backup = [k.decode('utf-8') for k in self.params.all_keys(ParamKeyFlag.BACKUP)]
for param in params_to_backup:
value = get_param_as_byte(param)
if value is not None:
config_data[param] = base64.b64encode(value).decode('utf-8')
return config_data
def _get_metadata_value(self, metadata_list, key, default_value=None):
return next((entry.get("value") for entry in metadata_list if entry.get("key") == key), default_value)
async def create_backup(self) -> bool:
"""Creates and uploads a new backup to sunnylink."""
try:
self.backup_status = custom.BackupManagerSP.Status.inProgress
self._update_progress(0.0, OperationType.BACKUP)
# Collect configuration data
config_data = self._collect_config_data()
self._update_progress(25.0, OperationType.BACKUP)
# Serialize and encrypt config data
config_json = json.dumps(config_data)
encrypted_config = encrypt_compressed_data(config_json, use_aes_256=True)
self._update_progress(50.0, OperationType.BACKUP)
backup_info = custom.BackupManagerSP.BackupInfo()
backup_info.deviceId = self.device_id
backup_info.config = encrypted_config
backup_info.isEncrypted = True
backup_info.createdAt = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
backup_info.updatedAt = backup_info.createdAt
backup_info.sunnypilotVersion = self._get_current_version()
backup_info.backupMetadata = [
custom.BackupManagerSP.MetadataEntry(key="creator", value="BackupManagerSP"),
custom.BackupManagerSP.MetadataEntry(key="all_values_encoded", value="True"),
custom.BackupManagerSP.MetadataEntry(key="AES", value="256")
]
payload = json.loads(json.dumps(backup_info.to_dict(), cls=SnakeCaseEncoder))
self._update_progress(75.0, OperationType.BACKUP)
cloudlog.debug(f"Uploading backup with payload: {json.dumps(payload)}")
# Upload to sunnylink
result = self.api.api_get(
f"backup/{self.device_id}",
method='PUT',
access_token=self.api.get_token(),
json=payload,
session=self._session
)
if result:
self.backup_status = custom.BackupManagerSP.Status.completed
self._update_progress(100.0, OperationType.BACKUP)
cloudlog.info("Backup successfully created and uploaded")
else:
self.backup_status = custom.BackupManagerSP.Status.failed
self.last_error = "Failed to upload backup"
cloudlog.error(result)
self._report_status()
return bool(self.backup_status == custom.BackupManagerSP.Status.completed)
except Exception as e:
cloudlog.exception(f"Error creating backup: {str(e)}")
self.backup_status = custom.BackupManagerSP.Status.failed
self.last_error = str(e)
self._report_status()
return False
async def restore_backup(self, version: int | None = None) -> bool:
"""Restores a backup from sunnylink."""
try:
self.restore_status = custom.BackupManagerSP.Status.inProgress
self._update_progress(0.0, OperationType.RESTORE)
# Get backup data from API for the specified version
endpoint = f"backup/{self.device_id}" + f"/{version or ''}" + "?api-version=1"
backup_data = self.api.api_get(endpoint, access_token=self.api.get_token(), session=self._session)
if not backup_data:
raise Exception(f"No backup found for device {self.device_id}")
self._update_progress(25.0, OperationType.RESTORE)
data = backup_data.json()
backup_metadata = data.get("backup_metadata", [])
encrypted_config = data.get("config", "")
if not encrypted_config:
raise Exception("Empty backup configuration")
self._update_progress(50.0, OperationType.RESTORE)
# Decrypt config and load data
use_aes_256 = self._get_metadata_value(backup_metadata, "AES", "128") == "256"
config_json = decrypt_compressed_data(encrypted_config, use_aes_256)
if not config_json:
raise Exception("Failed to decrypt backup configuration")
config_data = json.loads(config_json)
self._update_progress(75.0, OperationType.RESTORE)
# Apply configuration
self._apply_config(config_data)
self.restore_status = custom.BackupManagerSP.Status.completed
self._update_progress(100.0, OperationType.RESTORE)
return True
except Exception as e:
cloudlog.exception(f"Error restoring backup: {str(e)}")
self.restore_status = custom.BackupManagerSP.Status.failed
self.last_error = str(e)
self._report_status()
return False
def _apply_config(self, config_data: dict[str, str]) -> None:
"""Applies configuration data from a backup, but only for parameters marked as backupable."""
backupable_params = [k.decode('utf-8') for k in self.params.all_keys(ParamKeyFlag.BACKUP)]
backupable_set_lower = {p.lower() for p in backupable_params}
restored_count = 0
skipped_count = 0
for param, encoded_value in config_data.items():
if param.lower() in backupable_set_lower:
# Find real param name (with correct casing)
real_param = next(p for p in backupable_params if p.lower() == param.lower())
try:
save_param_from_base64_encoded_string(real_param, encoded_value)
restored_count += 1
except Exception as e:
cloudlog.error(f"Failed to restore param {param}: {str(e)}")
else:
skipped_count += 1
cloudlog.info(f"Skipped restoring param {param}: not marked for backup in current version")
cloudlog.info(f"Restore complete: {restored_count} params restored, {skipped_count} params skipped")
def _get_current_version(self) -> custom.BackupManagerSP.Version:
"""Gets current sunnypilot version information."""
version_obj = custom.BackupManagerSP.Version()
version_str = get_version()
version_parts = version_str.split('-') # For when version is like "1.2.3-456"
version_nums = version_parts[0].split('.')
# Extract build number from hyphen format or as 4th version component
build = 0
if len(version_parts) > 1 and version_parts[1].isdigit():
build = int(version_parts[1])
elif len(version_nums) > 3 and version_nums[3].isdigit():
build = int(version_nums[3])
# Set version components with safer defaults
version_obj.major = int(version_nums[0]) if len(version_nums) > 0 and version_nums[0].isdigit() else 0
version_obj.minor = int(version_nums[1]) if len(version_nums) > 1 and version_nums[1].isdigit() else 0
version_obj.patch = int(version_nums[2]) if len(version_nums) > 2 and version_nums[2].isdigit() else 0
version_obj.build = build
version_obj.branch = get_branch()
return version_obj
async def main_thread(self) -> None:
"""Main thread for backup management."""
rk = Ratekeeper(1, print_delay_threshold=None)
reset_progress = False
while True:
try:
if reset_progress:
self.progress = 100.0
self.operation = None
self.restore_status = custom.BackupManagerSP.Status.idle
self.backup_status = custom.BackupManagerSP.Status.idle
# Check for backup command
if self.params.get_bool("BackupManager_CreateBackup"):
try:
if await self.create_backup():
reset_progress = True
finally:
self.params.remove("BackupManager_CreateBackup")
# Check for restore command
restore_version = self.params.get("BackupManager_RestoreVersion")
if restore_version:
try:
version = int(restore_version) if restore_version.isdigit() else None
await self.restore_backup(version)
reset_progress = True
finally:
self.params.remove("BackupManager_RestoreVersion")
self._report_status()
rk.keep_time()
except Exception as e:
cloudlog.exception(f"Error in backup manager main thread: {str(e)}")
self.last_error = str(e)
self._report_status()
rk.keep_time()
def main():
import asyncio
asyncio.run(BackupManagerSP().main_thread())
if __name__ == "__main__":
main()
@@ -1,188 +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 hashlib
import os
import zlib
import re
import json
from pathlib import Path
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from openpilot.common.api.base import KEYS
from openpilot.sunnypilot.sunnylink.backups.AESCipher import AESCipher
from openpilot.common.hardware.hw import Paths
class KeyDerivation:
@staticmethod
def _load_key(file_path: str) -> bytes:
with open(file_path, 'rb') as f:
return f.read()
@staticmethod
def derive_aes_key_iv(key_path: str, use_aes_256: bool) -> tuple[bytes, bytes]:
key_pem: bytes = KeyDerivation._load_key(key_path)
key_plain = key_pem.decode(errors="ignore")
if "private" in key_plain.lower():
private_key = serialization.load_pem_private_key(key_pem, password=None, backend=default_backend())
if isinstance(private_key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey)):
public_key = private_key.public_key()
else:
raise ValueError("Invalid key format: Unable to determine if key is public or private.")
elif "public" in key_plain.lower():
public_key = serialization.load_pem_public_key(key_pem, backend=default_backend())
if not isinstance(public_key, (rsa.RSAPublicKey, ec.EllipticCurvePublicKey)):
raise ValueError("Invalid key format: Unable to determine if key is public or private.")
else:
raise ValueError("Invalid key format: Unable to determine if key is public or private.")
if isinstance(public_key, rsa.RSAPublicKey):
der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.PKCS1)
elif isinstance(public_key, ec.EllipticCurvePublicKey):
der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo)
else:
raise ValueError("Unsupported key type.")
if use_aes_256:
# AES-256-CBC
key = hashlib.sha256(der_data).digest()
iv = hashlib.md5(der_data).digest()
else:
# AES-128-CBC
key = hashlib.md5(der_data).digest()
iv = hashlib.md5(der_data).digest() # Insecure IV reuse, kept for compatibility
return key, iv
def uncompress_dat(data):
"""
Decompress data using zlib.
Args:
data (bytes): Compressed data
Returns:
bytes: Decompressed data
"""
data_stripped_4 = data[4:]
return zlib.decompress(data_stripped_4)
def compress_dat(data):
"""
Compress data using zlib.
Args:
data (bytes): Data to compress
Returns:
bytes: Compressed data
"""
compressed_data = zlib.compress(data, level=9)
return b"ZLIB" + compressed_data
def get_key_path(use_aes_256=False) -> str:
key_path = ""
for key in KEYS:
if os.path.isfile(Paths.persist_root() + f'/comma/{key}') and os.path.isfile(Paths.persist_root() + f'/comma/{key}.pub'):
key_path = str(Path(Paths.persist_root() + f'/comma/{key}') if use_aes_256 else Path(Paths.persist_root() + f'/comma/{key}.pub'))
break
if not key_path:
raise FileNotFoundError("No valid key pair found in persist storage.")
return key_path
def decrypt_compressed_data(encrypted_base64, use_aes_256=False):
"""
Decrypt and decompress data from base64 string.
Args:
encrypted_base64 (str): Base64 encoded encrypted data
key_path (str, optional): Path to RSA public key
Returns:
str: Decrypted and decompressed string
"""
try:
# Decode base64
encrypted_data = base64.b64decode(encrypted_base64)
# Decrypt
key, iv = KeyDerivation.derive_aes_key_iv(get_key_path(use_aes_256), use_aes_256)
cipher = AESCipher(key, iv)
decrypted_data = cipher.decrypt(encrypted_data)
# Decompress
decompressed_data = uncompress_dat(decrypted_data)
# Decode UTF-8
result = decompressed_data.decode('utf-8')
return result
except Exception as e:
print(f"Decryption and decompression failed: {e}")
return ""
def encrypt_compressed_data(text, use_aes_256=True):
"""
Compress and encrypt string data to base64.
Args:
text (str): Text to compress and encrypt
key_path (str, optional): Path to RSA public key
Returns:
str: Base64 encoded encrypted data
"""
try:
# Encode to UTF-8
text_bytes = text.encode('utf-8')
# Compress
compressed_data = compress_dat(text_bytes)
# Encrypt
key, iv = KeyDerivation.derive_aes_key_iv(get_key_path(use_aes_256), use_aes_256)
cipher = AESCipher(key, iv)
encrypted_data = cipher.encrypt(compressed_data)
# Encode to base64
result = base64.b64encode(encrypted_data).decode('utf-8')
return result
except Exception as e:
print(f"Compression and encryption failed: {e}")
return ""
def camel_to_snake(name):
"""Convert camelCase to snake_case."""
name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()
def transform_dict(obj):
"""Recursively transform dictionary keys from camelCase to snake_case."""
if isinstance(obj, dict):
return {camel_to_snake(k): transform_dict(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [transform_dict(item) for item in obj]
return obj
class SnakeCaseEncoder(json.JSONEncoder):
def encode(self, o):
transformed_obj = transform_dict(o)
return super().encode(transformed_obj)
@@ -1,189 +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 json
from openpilot.cereal import custom, messaging
from opendbc.car.structs import car
from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, UNSUPPORTED_LONGITUDINAL_CAR
from opendbc.car.subaru.values import CAR as SUBARU_CAR, SubaruFlags
from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP
from opendbc.sunnypilot.car.interfaces import has_bsm
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.common.hardware import HARDWARE
# Wire-protocol version for the capabilities payload. Bump on breaking changes
# only; additive fields are backward-compatible and do not require a bump.
PROTOCOL_VERSION = 1
# All capability fields that rules may reference.
# Non-boolean fields must have defaults in CAPABILITY_DEFAULTS.
CAPABILITY_FIELDS = (
"protocol_version",
"has_longitudinal_control",
"has_icbm",
"icbm_available",
"torque_allowed",
"brand",
"pcm_cruise",
"alpha_long_available",
"steer_control_type",
"enable_bsm",
"is_release",
"is_sp_release",
"is_development",
"tesla_has_vehicle_bus",
"has_stop_and_go",
"stock_longitudinal",
"device_type",
"subaru_has_sng",
"hyundai_alpha_long_available",
)
CAPABILITY_LABELS: dict[str, str] = {
"protocol_version": "Capabilities protocol version",
"has_longitudinal_control": "sunnypilot longitudinal control",
"has_icbm": "ICBM enabled",
"icbm_available": "ICBM available",
"torque_allowed": "torque steering (not available for angle steering vehicles)",
"brand": "Vehicle brand",
"pcm_cruise": "PCM cruise",
"alpha_long_available": "Alpha Longitudinal available",
"steer_control_type": "Steer control type",
"enable_bsm": "BSM available",
"is_release": "Release branch",
"is_sp_release": "SP release branch",
"is_development": "Development branch",
"tesla_has_vehicle_bus": "Tesla vehicle bus",
"has_stop_and_go": "Stop and Go",
"stock_longitudinal": "stock longitudinal",
"device_type": "Device type",
"subaru_has_sng": "Subaru Stop-and-Go available",
"hyundai_alpha_long_available": "Hyundai Alpha Longitudinal available",
}
# Explicit defaults for non-boolean capability fields
CAPABILITY_DEFAULTS: dict[str, bool | str | int] = {
"brand": "",
"steer_control_type": "",
"device_type": "",
"protocol_version": PROTOCOL_VERSION,
}
def _bundle_field(bundle: dict | None, key: str) -> str:
return bundle.get(key, "") if isinstance(bundle, dict) else ""
def _resolve_brand_capabilities(caps: dict, bundle_platform: str, CP) -> None:
"""Set brand-specific capabilities from bundle platform or CarParams fallback.
Bundle (manual car selection) is a pre-fingerprint approximation.
CarParams (auto-fingerprint) is the authoritative post-fingerprint source.
Mirrors the per-brand update_settings() logic in device UI layouts.
"""
brand = caps["brand"]
if brand == "hyundai":
if bundle_platform:
try:
unsupported = set().union(*UNSUPPORTED_LONGITUDINAL_CAR.values())
caps["hyundai_alpha_long_available"] = HYUNDAI_CAR[bundle_platform] not in unsupported
except KeyError:
cloudlog.exception(f"capabilities: unknown hyundai platform {bundle_platform!r}")
elif CP is not None:
caps["hyundai_alpha_long_available"] = bool(CP.alphaLongitudinalAvailable)
elif brand == "subaru":
if bundle_platform:
try:
flags = SUBARU_CAR[bundle_platform].config.flags
caps["subaru_has_sng"] = not bool(flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
caps["has_stop_and_go"] = caps["subaru_has_sng"]
except KeyError:
cloudlog.exception(f"capabilities: unknown subaru platform {bundle_platform!r}")
elif CP is not None:
caps["subaru_has_sng"] = not bool(CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
caps["has_stop_and_go"] = caps["subaru_has_sng"]
def generate_capabilities(params: Params | None = None) -> dict:
"""Generate a SettingsCapabilities dict from CarParams + boolean params.
When CarPlatformBundle is present, brand and platform come from the bundle
(mirrors Raylib). CarParams* deserialization is the fallback before the bundle
is written (early after first pairing).
"""
params = params or Params()
caps: dict = {field: CAPABILITY_DEFAULTS.get(field, False) for field in CAPABILITY_FIELDS}
# Wire-protocol version is always set explicitly.
caps["protocol_version"] = PROTOCOL_VERSION
# Hardware + boolean params (no CarParams dependency)
caps["device_type"] = HARDWARE.get_device_type()
caps["is_release"] = False # params.get_bool("IsReleaseBranch")
caps["is_sp_release"] = params.get_bool("IsReleaseSpBranch")
caps["is_development"] = params.get_bool("IsDevelopmentBranch")
caps["stock_longitudinal"] = params.get_bool("ToyotaEnforceStockLongitudinal")
bundle = params.get("CarPlatformBundle")
bundle_brand = _bundle_field(bundle, "brand")
bundle_platform = _bundle_field(bundle, "platform")
# Bundle-first brand resolution; CP is fallback only.
if bundle_brand:
caps["brand"] = bundle_brand
# CarParams-derived capabilities
CP = None
CP_SP = None
CP_bytes = params.get("CarParamsPersistent")
if CP_bytes is not None:
try:
CP = messaging.log_from_bytes(CP_bytes, car.CarParams)
caps["alpha_long_available"] = bool(CP.alphaLongitudinalAvailable)
if CP.alphaLongitudinalAvailable:
caps["has_longitudinal_control"] = params.get_bool("AlphaLongitudinalEnabled")
else:
caps["has_longitudinal_control"] = bool(CP.openpilotLongitudinalControl)
# CP.steerControlType is the physical control mode (angle / torque).
# CP.lateralTuning.which() returns the tuning class (pid / torque / indi)
# which is a separate concept and is not interchangeable.
caps["steer_control_type"] = str(CP.steerControlType)
caps["torque_allowed"] = CP.steerControlType != car.CarParams.SteerControlType.angle
if not caps["brand"] and CP.brand:
caps["brand"] = str(CP.brand)
caps["pcm_cruise"] = bool(CP.pcmCruise)
# Generic SnG fallback. Brand-specific opaque flags below override.
caps["has_stop_and_go"] = bool(CP.openpilotLongitudinalControl)
except Exception:
CP = None
cloudlog.exception("capabilities: failed to deserialize CarParamsPersistent")
# CarParamsSP-derived capabilities
CP_SP_bytes = params.get("CarParamsSPPersistent")
if CP_SP_bytes is not None:
try:
CP_SP = messaging.log_from_bytes(CP_SP_bytes, custom.CarParamsSP)
caps["icbm_available"] = bool(CP_SP.intelligentCruiseButtonManagementAvailable)
caps["has_icbm"] = bool(CP_SP.intelligentCruiseButtonManagementAvailable) and params.get_bool("IntelligentCruiseButtonManagement")
caps["tesla_has_vehicle_bus"] = bool(CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS)
except Exception:
cloudlog.exception("capabilities: failed to deserialize CarParamsSPPersistent")
caps["enable_bsm"] = has_bsm(CP, CP_SP) if CP is not None else False
_resolve_brand_capabilities(caps, bundle_platform, CP)
return caps
def generate_capabilities_json(params: Params | None = None) -> str:
"""Generate SettingsCapabilities as a JSON string."""
return json.dumps(generate_capabilities(params), separators=(",", ":"))
@@ -1,585 +0,0 @@
# sunnylink Settings UI Guide
> One YAML file per page. Edit, run the compiler, commit. The sunnylink frontend updates automatically.
## What you edit (and what's generated)
| File | What | When to edit |
|------|------|-------------|
| `settings_ui_src/pages/<page>.yaml` | One YAML per page (panel). Contains panel metadata + sections + items + sub_panels inline. | Adding/changing/removing a setting. |
| `settings_ui_src/pages/vehicle.yaml` | Per-brand settings page (`kind: vehicle`). Each brand is a section. | Adding/changing a vehicle-specific setting. |
| `settings_ui_src/_macros.yaml` | Named rule fragments referenced via `{$ref: "#/macros/<name>"}`. | Adding a reusable rule (e.g. a new platform gate). |
| **`settings_ui.json`** | **Generated from src tree by `compile_settings_ui.py`. Do not edit by hand.** | Never. Compiler emits it; frontend reads it. |
Pages today: `steering, cruise, display, visuals, toggles, device, software, developer, models, vehicle` (10).
Run `python sunnypilot/sunnylink/tools/compile_settings_ui.py` after edits. Add `--check` in CI to fail on out-of-sync `settings_ui.json`.
Display metadata (titles, descriptions, options, min/max/step/unit) is inline on each item. There is no separate metadata file.
## Page file shape
A page YAML contains the whole panel: metadata at the top, then `sections`. Each section has its own `items` and (optionally) `sub_panels`. Sub-panels are nested inside the section they belong to. Items appear in the order written in the file.
```yaml
# yaml-language-server: $schema=../_schemas/page.schema.json
id: steering
label: Steering
icon: steering_wheel
order: 1
remote_configurable: true
description: Lateral control, lane changes, and steering behavior
sections:
- id: mads
title: Modular Assistive Driving System (MADS)
items:
- key: Mads
widget: toggle
title: Enable Modular Assistive Driving System (MADS)
description: |
Enable the beloved MADS feature. Disable toggle to revert back
to stock sunnypilot engagement/disengagement.
enablement:
- {$ref: "#/macros/offroad"}
sub_panels:
- id: mads_settings
label: MADS Settings
trigger_key: Mads
trigger_condition: {type: param, key: Mads, equals: true}
items:
- key: MadsMainCruiseAllowed
widget: toggle
title: Toggle with Main Cruise
description: |
Note: For vehicles without LFA/LKAS button, disabling this will
prevent lateral control engagement.
enablement:
- {$ref: "#/macros/offroad"}
- {$ref: "#/macros/mads_full_platforms"}
```
The vehicle page has the same shape but declares `kind: vehicle`; each section's `id` becomes a brand key under `vehicle_settings` in the compiled JSON.
## Macros (named rule fragments)
`_macros.yaml` declares reusable rule lists. Reference them from any rules array via `{$ref: "#/macros/<name>"}`.
```yaml
macros:
offroad: [{type: offroad_only}]
longitudinal: [{type: capability, field: has_longitudinal_control, equals: true}]
mads_full_platforms:
- type: not
condition:
type: any
conditions:
- {type: capability, field: brand, equals: rivian}
- type: all
conditions:
- {type: capability, field: brand, equals: tesla}
- type: not
condition: {type: capability, field: tesla_has_vehicle_bus, equals: true}
```
In an item:
```yaml
enablement:
- {$ref: "#/macros/offroad"}
- {$ref: "#/macros/mads_full_platforms"}
```
The compiler splices a list-context `$ref` into its parent list. Macros may reference other macros up to depth 3; cycles are an error.
## Compiler workflow
```
1. common/params_keys.h — add/remove the C++ param key
2. settings_ui_src/pages/<page>.yaml — add/edit/remove the item in the right section
3. python sunnypilot/sunnylink/tools/compile_settings_ui.py
4. python sunnypilot/sunnylink/tools/validate_settings_ui.py (or: --check on the compiler)
5. uv run python -m pytest sunnypilot/sunnylink/tests/ # run regression + compiler tests
6. commit
```
CI runs `compile_settings_ui.py --check` to fail on hand-edited `settings_ui.json`.
## Compiled output reference (schema contract)
The tables below describe the **compiled** `settings_ui.json` schema — what the frontend consumes at runtime. JSON snippets show the wire shape; in the src tree you author YAML that compiles to the same shape. Use these as a contract reference for valid fields, their meanings, and rule types.
## Quick reference: widget types
| Widget | Use for | Fields needed |
|--------|---------|---------------|
| `toggle` | On/off boolean | `title` |
| `multiple_button` | 2-4 discrete options | `title` + `options` array |
| `option` | Numeric range or dropdown | `title` + `min/max/step` or `options` |
| `info` | Read-only display | `title` |
## Quick reference: item fields
| Field | Required | Description |
|-------|----------|-------------|
| `key` | Yes | Param key name (must exist in `params_keys.h`) |
| `widget` | Yes | `toggle`, `option`, `multiple_button`, `button`, `info` |
| `title` | Yes | Display name shown to the user |
| `description` | No | Inline explanatory text below the title. May be empty when only `details` is used. |
| `details` | No | Extended help text shown in a modal when the user taps an "i" button on the row. Independent of `description`: either, both, or neither may be present. |
| `options` | For selectors | Array of `{"value": 0, "label": "Off"}` objects (see per-option enablement below) |
| `min`, `max`, `step` | For sliders | Numeric range constraints |
| `unit` | No | Unit label. Static: `"seconds"`. Dynamic: `{"metric": "km/h", "imperial": "mph"}` (resolved by IsMetric) |
| `visibility` | No | Rules for show/hide. Settings are never hidden, always dimmed with UNAVAILABLE badge when rules fail |
| `enablement` | No | Rules for enabled/disabled (all must pass). Dimmed with badge when rules fail |
| `blocked` | No | `true` for device-only settings that cannot be modified remotely. Frontend shows as read-only |
| `title_param_suffix` | No | Dynamic title suffix. Example: `{"param": "IsMetric", "values": {"0": "mph", "1": "km/h"}}` |
| `sub_items` | No | Nested child items |
| `needs_onroad_cycle` | No | `true` if changing this param triggers a system restart. Frontend shows a "Restart" badge. See [REFERENCE.md - Remote Onroad Cycle](REFERENCE.md#remote-onroad-cycle) |
## Quick reference: rule types
| Rule | Example | Use for |
|------|---------|---------|
| `offroad_only` | `{"type": "offroad_only"}` | Grey out while driving |
| `not_engaged` | `{"type": "not_engaged"}` | Grey out only while engaged (started + selfdrive/MADS active) |
| `capability` | `{"type": "capability", "field": "has_longitudinal_control", "equals": true}` | Car-dependent visibility |
| `param` | `{"type": "param", "key": "Mads", "equals": true}` | Show/enable based on another setting |
| `param_compare` | `{"type": "param_compare", "key": "SpeedLimitMode", "op": ">", "value": 0}` | Numeric comparison |
| `not` | `{"type": "not", "condition": {...}}` | Negate a rule |
| `any` | `{"type": "any", "conditions": [...]}` | OR logic |
| `all` | `{"type": "all", "conditions": [...]}` | AND logic (for nesting inside `any`/`not`) |
| `$ref` | `{"$ref": "#/macros/offroad"}` | Reference a named rule fragment in `_macros.yaml` |
**Visibility design**: Settings are always visible. When visibility rules fail, the setting is dimmed with an UNAVAILABLE badge, so users know it exists but is not applicable.
**Enablement rules**: Grayed out (disabled) when rules fail. Frontend shows a contextual badge explaining why.
**Capability fields** (referenced in rules): `has_longitudinal_control`, `has_icbm`, `icbm_available`, `torque_allowed`, `brand`, `pcm_cruise`, `alpha_long_available`, `steer_control_type`, `enable_bsm`, `is_release`, `is_sp_release`, `is_development`, `tesla_has_vehicle_bus`, `has_stop_and_go`, `stock_longitudinal`
---
## How to
### Pick a writability rule (offroad / not_engaged / param-based)
| Use this | When | Why |
|---|---|---|
| `offroad_only` | Param can only be safely changed when the car is parked. Most user-facing toggles. | Strictest. Frontend shows "device is driving" badge and disables the row. |
| `not_engaged` | Param can be changed while the car is started but only when sunnypilot/MADS is **not** actively driving. | Less strict than offroad. Matches Raylib `engaged = started AND (selfdriveState.enabled OR mads.enabled)`. Use for items the device must apply mid-drive (e.g. test maneuvers, longitudinal stock-vs-OP toggle). |
| `param`-based | Behavior depends on another setting's value (parent toggle, mode selector, etc.). | Composes with `not`/`any`/`all` for arbitrary logic. |
| `capability`-based | Behavior depends on the connected car or device (brand, longitudinal, hardware). | Resolved on the device from `CarParams` / hardware. See [`capabilities.py`](../capabilities.py) for the full field list. |
| (no rule) | Param is always writable, no gating. | Rare. Prefer at least `offroad_only` unless the param is genuinely safe to flip mid-drive. |
Default for new toggles: `enablement: [{$ref: "#/macros/offroad"}]`. Drop down to `not_engaged` only if you've confirmed mid-drive write is safe in the controls/UI code path.
### Use `details` for safety notes / extended help
Inline `description` shows under the title. For longer caveats, safety notes, or "learn more" content, use `details` — the frontend renders an info button that opens a modal. Either field may be present alone or both together.
```yaml
- key: AutoLaneChangeTimer
widget: option
title: Auto Lane Change by Blinker
description: |-
Set a timer to delay the auto lane change operation when the blinker is used.
No nudge on the steering wheel is required to auto lane change if a timer is set.
Default is Nudge.
details: |-
Please use caution when using this feature. Only use the blinker when traffic
and road conditions permit.
options: [...]
```
For an item that is intentionally minimal inline (no inline body, only the modal):
```yaml
- key: SomeAdvancedToggle
widget: toggle
title: Some Advanced Feature
details: |-
Long-form rationale, caveats, links, etc. — kept entirely behind the info button.
```
### Add a toggle
1. Register in `common/params_keys.h`:
```cpp
{"MyToggle", {PERSISTENT | BACKUP, BOOL}},
```
2. Open `settings_ui_src/pages/<page>.yaml`. Add the item to the right section:
```yaml
- key: MyToggle
widget: toggle
title: My Feature
description: What this feature does.
enablement:
- {$ref: "#/macros/offroad"}
```
If changing the param requires an onroad cycle to take effect, add `needs_onroad_cycle: true`.
3. Compile + validate + test:
```
python sunnypilot/sunnylink/tools/compile_settings_ui.py
python sunnypilot/sunnylink/tools/validate_settings_ui.py
uv run python -m pytest sunnypilot/sunnylink/tests/
```
### Add a multi-button option
```yaml
- key: MySelector
widget: multiple_button
title: Mode
options:
- {value: 0, label: Off}
- {value: 1, label: On}
- {value: 2, label: Auto}
```
### Add a slider or range
```yaml
- key: MyRange
widget: option
title: Follow Distance
description: Time gap to lead vehicle.
min: 0.5
max: 3.0
step: 0.1
unit: seconds
```
### Add a slider with metric/imperial units
```yaml
- key: MinSpeed
widget: option
title: Minimum Speed
min: 0
max: 100
step: 5
unit: {metric: km/h, imperial: mph}
```
Frontend resolves the unit string based on the device's `IsMetric` param. Static units (e.g. `seconds`, `m/s²`) stay plain strings.
### Add a dynamic title suffix
```yaml
- key: FollowDistance
widget: option
title: Follow Distance
title_param_suffix:
param: IsMetric
values: {'0': mph, '1': km/h}
min: 0.5
max: 3.0
step: 0.1
```
Renders as "Follow Distance: mph" / "Follow Distance: km/h".
### Add a device-only read-only setting
```yaml
- key: OnroadCyclePendingRemote
widget: info
title: Pending Remote Cycle
blocked: true
```
Frontend treats `blocked: true` items as read-only.
### Add a dropdown option
```yaml
- key: MyDropdown
widget: option
title: Recording Quality
options:
- {value: 0, label: Low (720p)}
- {value: 1, label: Medium (1080p)}
- {value: 2, label: High (4K)}
```
### Per-option enablement rules
```yaml
- key: MadsSteeringMode
widget: multiple_button
title: Steering Mode on Brake Pedal
options:
- value: 0
label: Remain Active
enablement:
- {$ref: "#/macros/mads_full_platforms"}
- value: 1
label: Pause
enablement:
- {$ref: "#/macros/mads_full_platforms"}
- value: 2
label: Disengage
enablement:
- {$ref: "#/macros/offroad"}
```
When an option's enablement fails, that option is grayed out but still visible.
### Show only when another setting is on
```yaml
- key: ChildSetting
widget: toggle
title: Child Feature
visibility:
- {type: param, key: ParentToggle, equals: true}
```
(With the "dim instead of hide" design, this setting is dimmed, not hidden, when the rule fails.)
### Show only for specific brands
```yaml
- key: LongFeature
widget: toggle
title: Longitudinal Feature
visibility:
- {$ref: "#/macros/longitudinal"}
```
### Combine multiple conditions
The `enablement` array is implicit-AND: every entry must pass. Use `any` for OR, `all` for nested AND, `not` for negation. Wrap repeated combinations in a macro so future you doesn't re-derive the logic.
**AND across two params** (writable only when both Mads is on AND ICBM is enabled):
```yaml
enablement:
- {type: param, key: Mads, equals: true}
- {type: param, key: IntelligentCruiseButtonManagement, equals: true}
```
**OR across two params** (writable when either is on):
```yaml
enablement:
- type: any
conditions:
- {type: param, key: SmartCruiseControlVision, equals: true}
- {type: param, key: SmartCruiseControlMap, equals: true}
```
**Mixed: capability AND param** (only on longitudinal cars when ShowAdvancedControls is on):
```yaml
enablement:
- {$ref: "#/macros/longitudinal"}
- {$ref: "#/macros/advanced_only"}
```
**Three-way: offroad AND torque-allowed AND not-NNLC** (real example: `EnforceTorqueControl`):
```yaml
enablement:
- {$ref: "#/macros/offroad"}
- {type: capability, field: torque_allowed, equals: true}
- {type: param, key: NeuralNetworkLateralControl, equals: false}
```
**Negation across multiple platforms** (everything except Rivian + Tesla-no-bus):
```yaml
enablement:
- {$ref: "#/macros/offroad"}
- {$ref: "#/macros/mads_full_platforms"} # macro encapsulates the not(any(rivian, all(tesla, not(bus)))) logic
```
If the same multi-condition block appears in 2+ items, **promote it to a macro** in `_macros.yaml`. Re-run `python sunnypilot/sunnylink/tools/apply_macros.py` to substitute existing inlined matches automatically.
### Mutual exclusion
```yaml
- key: FeatureAlpha
widget: toggle
title: Feature Alpha
enablement:
- {type: param, key: FeatureBeta, equals: false}
- key: FeatureBeta
widget: toggle
title: Feature Beta
enablement:
- {type: param, key: FeatureAlpha, equals: false}
```
### Add a section
In the page YAML, add an entry to the `sections` list:
```yaml
sections:
- id: my_section
title: My Section
description: Optional subtitle
enablement:
- {$ref: "#/macros/longitudinal"}
items:
- {key: ..., widget: toggle, title: ...}
```
Sections support `visibility`, `enablement`, and `attestation_required`. When section-level rules fail, all items within are dimmed.
### Add a sub-panel
Sub-panels nest inside the section they belong to:
```yaml
sections:
- id: parent_section
title: Parent
items: [...]
sub_panels:
- id: my_sub
label: Advanced Settings
trigger_key: ParentParam
trigger_condition: {type: param, key: ParentParam, equals: true}
items:
- {key: ..., widget: toggle, title: ...}
```
### Add vehicle-brand settings
Edit `pages/vehicle.yaml`. Each section is a brand:
```yaml
id: vehicle
kind: vehicle
sections:
- id: rivian
title: Rivian Settings
description: ''
items:
- key: RivianFeature
widget: toggle
title: Rivian One Pedal
enablement:
- {$ref: "#/macros/offroad"}
```
`kind: vehicle` tells the compiler to emit this page as `vehicle_settings.<brand>` in the wire JSON.
### Add a feature with toggles, sub-panel, and macro
Example: "Smart Wipers" with a master toggle, intensity selector, and sub-panel for advanced tuning, gated to torque-steering Hyundais on offroad.
1. **Param keys** — register all 4 in `common/params_keys.h`.
2. **Decide on a macro** — if "torque Hyundai" gating is reused, add to `_macros.yaml`:
```yaml
torque_hyundai:
- {$ref: "#/macros/offroad"}
- {type: capability, field: brand, equals: hyundai}
- {type: capability, field: torque_allowed, equals: true}
```
3. **Edit the relevant page** — `pages/visuals.yaml` (or wherever the feature lives). Add a new section + sub_panel:
```yaml
sections:
- id: smart_wipers
title: Smart Wipers
description: Camera-driven wiper control (Hyundai/Kia, torque only)
items:
- key: SmartWipersEnabled
widget: toggle
title: Enable Smart Wipers
enablement:
- {$ref: "#/macros/torque_hyundai"}
- key: SmartWipersIntensity
widget: multiple_button
title: Sensitivity
options:
- {value: 0, label: Low}
- {value: 1, label: Medium}
- {value: 2, label: High}
visibility:
- {type: param, key: SmartWipersEnabled, equals: true}
enablement:
- {$ref: "#/macros/torque_hyundai"}
sub_panels:
- id: smart_wipers_tuning
label: Smart Wipers Tuning
trigger_key: SmartWipersEnabled
trigger_condition: {type: param, key: SmartWipersEnabled, equals: true}
items:
- key: SmartWipersHysteresis
widget: option
title: Hysteresis (frames)
min: 1
max: 30
step: 1
enablement:
- {$ref: "#/macros/offroad"}
- {$ref: "#/macros/advanced_only"}
```
4. **Compile / validate / test**:
```
python sunnypilot/sunnylink/tools/compile_settings_ui.py
python sunnypilot/sunnylink/tools/validate_settings_ui.py
uv run python -m pytest sunnypilot/sunnylink/tests/
```
`apply_macros.py` is automatic for newly-added items only if you wrote the rule list inline; for greenfield items, you'd write `$ref` directly.
### Change a toggle's behavior
1. Find the item in `pages/<page>.yaml`.
2. Edit `visibility`/`enablement`/`options[].enablement` directly. Use macros where possible.
3. **Add a regression test** in `sunnypilot/sunnylink/tests/test_settings_changes.py` that asserts the new gate exists. Use existing tests (e.g. `TestMadsBrandGates`, `TestNotEngagedReplacement`) as templates: lookup item by key, assert `_references_capability_field(rules, "...")` or `_flatten_rule_types(rules)` contains/excludes a type. This freezes the new behavior so a future edit won't silently revert it.
4. Compile + run the full suite. Per-bug test should pass; structural tests should remain green.
### Change a widget type or options
Editing `widget:` from `toggle` to `multiple_button` is a frontend behavior change. Whenever you change widget shape:
- The param's underlying type (bool / int / string) must match what the new widget writes. `toggle` writes bool; `multiple_button`/`option` write int/string. Update `params_keys.h` if the type changes.
- Add an `options:` list when switching to `multiple_button` or `option`.
- Old values stored on devices may not be valid for the new widget. Consider a migration in `sunnypilot/system/updated/` if users have stale values.
### Deprecate or remove a setting
1. Remove the item from `pages/<page>.yaml`.
2. Remove the param key from `common/params_keys.h` **only after** confirming nothing in `selfdrive/`, `sunnypilot/`, or any controls code reads it.
3. If the param has been on user devices, drop it via a migration (see `sunnypilot/system/updated/`) so stale values don't linger.
4. Compile + validate + test. The validator's "no duplicate keys" + structural checks will fail if anything still references the removed key.
### Move a setting to another page
Cut the item block from one page YAML, paste into the target page's section. Compile + validate. The "no duplicate keys" check catches forgotten copies.
### Change display text
Edit `title:` or `description:` in the page YAML and recompile to regenerate `settings_ui.json`.
### Reorder sections, sub-panels, and items
Reorder them within their parent list in the YAML. The compiler preserves authored order — no `order:` field required at the section/sub_panel/item level (panel-level `order:` controls which page comes first in the side nav).
---
### Capability labels and tooltips
The schema response includes `capability_labels`, which map capability field names to descriptions. The frontend uses these to show contextual tooltips when a capability rule prevents a setting from being used.
The device defines these labels in `capabilities.py:CAPABILITY_LABELS`. Examples:
- `has_longitudinal_control` → "sunnypilot longitudinal control"
- `torque_allowed` → "torque steering (not available for angle steering vehicles)"
- `brand` → "Vehicle brand"
### Centralized param enforcement
The device-side UI enforces capability constraints in `selfdrive/ui/sunnypilot/ui_state.py:_enforce_constraints()`, which removes incompatible params based on car capabilities. This is the single source of truth for such constraints.
Settings layouts should not duplicate these params.remove() calls. Instead, rely on schema rules and centralized enforcement to prevent duplicate logic and ensure consistency.
Example constraints in `_enforce_constraints()`:
- Angle steering cars: remove `EnforceTorqueControl` and `NeuralNetworkLateralControl`
- No CarParams: remove all car-dependent params
- No longitudinal: remove `ExperimentalMode`
- No ICBM: remove `IntelligentCruiseButtonManagement`
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
import time
from openpilot.common.params import Params
from openpilot.common.realtime import Ratekeeper
from openpilot.common.swaglog import cloudlog
from openpilot.cereal import log, messaging
from openpilot.sunnypilot.sunnylink.utils import register_sunnylink
NetworkType = log.DeviceState.NetworkType
def main():
"""The main method is expected to be called by the manager when the device boots up."""
try:
rk = Ratekeeper(.5)
sm = messaging.SubMaster(['deviceState'], poll='deviceState')
while True:
sm.update(1000)
if sm['deviceState'].networkType != NetworkType.none:
break
cloudlog.info(f"Waiting to become online... {time.monotonic()}")
rk.keep_time()
register_sunnylink()
except Exception:
cloudlog.exception("Sunnylink registration failed")
Params().put_bool("SunnylinkTempFault", True, block=True)
raise
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -1,516 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sunnypilot.com/schemas/settings_ui.schema.json",
"title": "sunnypilot Settings UI Schema",
"description": "Defines the structure of the sunnypilot settings UI panels, items, rules, and vehicle-specific settings.",
"type": "object",
"required": ["schema_version", "panels", "vehicle_settings"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference for editor support."
},
"schema_version": {
"type": "string",
"description": "Version of the settings UI schema format.",
"examples": ["1.0"]
},
"panels": {
"type": "array",
"description": "Top-level settings panels displayed in the UI.",
"items": {
"$ref": "#/$defs/Panel"
}
},
"vehicle_settings": {
"type": "object",
"description": "Brand-keyed vehicle-specific settings. Each key is a car brand (e.g. 'hyundai', 'toyota').",
"additionalProperties": {
"$ref": "#/$defs/VehicleBrandSettings"
}
}
},
"$defs": {
"Panel": {
"type": "object",
"description": "A top-level settings panel (tab) in the UI.",
"required": ["id", "label", "icon", "order"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for this panel."
},
"label": {
"type": "string",
"description": "Display label shown in the UI."
},
"icon": {
"type": "string",
"description": "Icon identifier for this panel."
},
"order": {
"type": "integer",
"description": "Sort order for panel display.",
"minimum": 0
},
"description": {
"type": "string",
"description": "Optional description shown below the panel label."
},
"remote_configurable": {
"type": "boolean",
"description": "Whether this panel's settings can be changed remotely via sunnylink.",
"default": false
},
"sections": {
"type": "array",
"description": "Grouped sections within this panel.",
"items": {
"$ref": "#/$defs/PanelSection"
}
},
"items": {
"type": "array",
"description": "Settings items directly in this panel (no section grouping).",
"items": {
"$ref": "#/$defs/SchemaItem"
}
},
"sub_panels": {
"type": "array",
"description": "Nested sub-panels triggered by a setting.",
"items": {
"$ref": "#/$defs/SubPanel"
}
}
}
},
"PanelSection": {
"type": "object",
"description": "A grouped section within a panel.",
"required": ["id", "title"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for this section."
},
"title": {
"type": "string",
"description": "Display title for this section."
},
"description": {
"type": "string",
"description": "Optional description shown below the section title."
},
"order": {
"type": "integer",
"description": "Sort order within the parent panel.",
"minimum": 0
},
"visibility": {
"type": "array",
"description": "Rules that determine whether this section is visible. All rules must pass.",
"items": {
"$ref": "#/$defs/Rule"
}
},
"enablement": {
"type": "array",
"description": "Rules that determine whether items in this section are enabled. All rules must pass.",
"items": {
"$ref": "#/$defs/Rule"
}
},
"attestation_required": {
"type": "boolean",
"description": "When true, the UI must show an attestation modal before any write to items in this section.",
"default": false
},
"items": {
"type": "array",
"description": "Settings items within this section.",
"items": {
"$ref": "#/$defs/SchemaItem"
}
},
"sub_panels": {
"type": "array",
"description": "Nested sub-panels within this section.",
"items": {
"$ref": "#/$defs/SubPanel"
}
}
}
},
"VehicleBrandSettings": {
"type": "object",
"description": "Brand-specific settings group inside vehicle_settings.",
"required": ["items"],
"additionalProperties": false,
"properties": {
"title": {
"type": "string",
"description": "Display title for this brand's settings group."
},
"description": {
"type": "string",
"description": "Optional description shown below the brand title."
},
"items": {
"type": "array",
"description": "Settings items for this brand.",
"items": {
"$ref": "#/$defs/SchemaItem"
}
}
}
},
"SchemaItem": {
"type": "object",
"description": "A single settings item (toggle, option selector, button group, etc.).",
"required": ["key", "widget"],
"additionalProperties": false,
"properties": {
"key": {
"type": "string",
"description": "The param key this item reads/writes."
},
"widget": {
"type": "string",
"description": "The UI widget type to render.",
"enum": ["toggle", "option", "multiple_button", "button", "info"]
},
"title": {
"type": "string",
"description": "Override display title (defaults to metadata lookup by key)."
},
"description": {
"type": "string",
"description": "Override description text. Rendered inline below the title. May be empty when only `details` is used."
},
"details": {
"type": "string",
"description": "Extended help text shown in a popover/modal when the user taps an info ('i') button on the row. Independent of `description`: either, both, or neither may be present."
},
"options": {
"type": "array",
"description": "Available options for 'option' or 'multiple_button' widgets.",
"items": {
"$ref": "#/$defs/SchemaOption"
}
},
"min": {
"type": "number",
"description": "Minimum value for numeric option widgets."
},
"max": {
"type": "number",
"description": "Maximum value for numeric option widgets."
},
"step": {
"type": "number",
"description": "Step increment for numeric option widgets."
},
"unit": {
"oneOf": [
{
"type": "string",
"description": "Static unit label (e.g. 'seconds', 'm/s²')."
},
{
"type": "object",
"description": "Dynamic unit that changes based on IsMetric param.",
"required": ["metric", "imperial"],
"additionalProperties": false,
"properties": {
"metric": {
"type": "string",
"description": "Unit label when IsMetric is true (e.g. 'km/h')."
},
"imperial": {
"type": "string",
"description": "Unit label when IsMetric is false (e.g. 'mph')."
}
}
}
],
"description": "Unit label for numeric values. Use a string for static units or an object with metric/imperial variants for units that depend on the IsMetric param."
},
"value_map": {
"type": "object",
"description": "Maps stored values to display labels.",
"additionalProperties": {
"type": "string"
}
},
"visibility": {
"type": "array",
"description": "Rules that determine whether this item is visible. All rules must pass.",
"items": {
"$ref": "#/$defs/Rule"
}
},
"enablement": {
"type": "array",
"description": "Rules that determine whether this item is enabled/interactive. All rules must pass.",
"items": {
"$ref": "#/$defs/Rule"
}
},
"sub_items": {
"type": "array",
"description": "Child items nested under this item (e.g. options revealed by a toggle).",
"items": {
"$ref": "#/$defs/SchemaItem"
}
},
"action": {
"type": "string",
"description": "Action identifier for button widgets."
},
"title_param_suffix": {
"type": "object",
"description": "Renders an extra suffix in the item title chosen by the value of another param.",
"required": ["param", "values"],
"additionalProperties": false,
"properties": {
"param": {
"type": "string",
"description": "Param key whose value selects the suffix label."
},
"values": {
"type": "object",
"description": "Map from stringified param value to suffix label.",
"additionalProperties": {
"type": "string"
}
}
}
},
"needs_onroad_cycle": {
"type": "boolean",
"description": "When true, the device must cycle onroad/offroad for the new value to take effect.",
"default": false
},
"blocked": {
"type": "boolean",
"description": "When true, this item is treated as DEVICE_ONLY and the dashboard must not write it remotely.",
"default": false
},
"requires_attestation": {
"type": "boolean",
"description": "When true, writes to this item require an explicit per-write confirmation modal.",
"default": false
}
}
},
"SubPanel": {
"type": "object",
"description": "A nested panel that opens when triggered by a parent item.",
"required": ["id", "label", "trigger_key"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for this sub-panel."
},
"label": {
"type": "string",
"description": "Display label for the sub-panel header."
},
"trigger_key": {
"type": "string",
"description": "The param key that triggers opening this sub-panel."
},
"trigger_condition": {
"$ref": "#/$defs/Rule",
"description": "Optional rule that must evaluate to true for the sub-panel trigger to be active."
},
"items": {
"type": "array",
"description": "Settings items within this sub-panel.",
"items": {
"$ref": "#/$defs/SchemaItem"
}
}
}
},
"SchemaOption": {
"type": "object",
"description": "A selectable option for option/multiple_button widgets.",
"required": ["value", "label"],
"additionalProperties": false,
"properties": {
"value": {
"oneOf": [
{ "type": "number" },
{ "type": "string" }
],
"description": "The stored value when this option is selected."
},
"label": {
"type": "string",
"description": "The display label for this option."
},
"enablement": {
"type": "array",
"description": "Rules that determine whether this option is selectable. All rules must pass.",
"items": {
"$ref": "#/$defs/Rule"
}
}
}
},
"Rule": {
"description": "A visibility or enablement rule. Discriminated union on the 'type' field.",
"oneOf": [
{ "$ref": "#/$defs/RuleOffroadOnly" },
{ "$ref": "#/$defs/RuleNotEngaged" },
{ "$ref": "#/$defs/RuleCapability" },
{ "$ref": "#/$defs/RuleParam" },
{ "$ref": "#/$defs/RuleParamCompare" },
{ "$ref": "#/$defs/RuleNot" },
{ "$ref": "#/$defs/RuleAny" },
{ "$ref": "#/$defs/RuleAll" }
]
},
"RuleOffroadOnly": {
"type": "object",
"description": "Rule that passes only when the device is offroad.",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": {
"const": "offroad_only"
}
}
},
"RuleNotEngaged": {
"type": "object",
"description": "Rule that passes when the vehicle is not engaged (matches Raylib `engaged = started AND (selfdriveState.enabled OR selfdriveStateSP.mads.enabled)`).",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": {
"const": "not_engaged"
}
}
},
"RuleCapability": {
"type": "object",
"description": "Rule that checks a vehicle capability field against an expected value.",
"required": ["type", "field", "equals"],
"additionalProperties": false,
"properties": {
"type": {
"const": "capability"
},
"field": {
"type": "string",
"description": "The capability field name to check."
},
"equals": {
"description": "The expected value to match against."
}
}
},
"RuleParam": {
"type": "object",
"description": "Rule that checks a param value against an expected value.",
"required": ["type", "key", "equals"],
"additionalProperties": false,
"properties": {
"type": {
"const": "param"
},
"key": {
"type": "string",
"description": "The param key to read."
},
"equals": {
"description": "The expected value to match against."
}
}
},
"RuleParamCompare": {
"type": "object",
"description": "Rule that compares a numeric param value using a comparison operator.",
"required": ["type", "key", "op", "value"],
"additionalProperties": false,
"properties": {
"type": {
"const": "param_compare"
},
"key": {
"type": "string",
"description": "The param key to read."
},
"op": {
"type": "string",
"description": "Comparison operator.",
"enum": [">", "<", ">=", "<="]
},
"value": {
"type": "number",
"description": "The numeric value to compare against."
}
}
},
"RuleNot": {
"type": "object",
"description": "Rule that negates a single child condition.",
"required": ["type", "condition"],
"additionalProperties": false,
"properties": {
"type": {
"const": "not"
},
"condition": {
"$ref": "#/$defs/Rule",
"description": "The rule to negate."
}
}
},
"RuleAny": {
"type": "object",
"description": "Rule that passes if ANY of the child conditions pass (logical OR).",
"required": ["type", "conditions"],
"additionalProperties": false,
"properties": {
"type": {
"const": "any"
},
"conditions": {
"type": "array",
"description": "Child rules; at least one must pass.",
"items": {
"$ref": "#/$defs/Rule"
},
"minItems": 1
}
}
},
"RuleAll": {
"type": "object",
"description": "Rule that passes only if ALL child conditions pass (logical AND).",
"required": ["type", "conditions"],
"additionalProperties": false,
"properties": {
"type": {
"const": "all"
},
"conditions": {
"type": "array",
"description": "Child rules; all must pass.",
"items": {
"$ref": "#/$defs/Rule"
},
"minItems": 1
}
}
}
}
}
@@ -1,65 +0,0 @@
# Named rule fragments. Reference from items/sections via {$ref: "#/macros/<name>"}.
# Macros may $ref other macros (max depth 3 — see compile_settings_ui.py). No template logic.
#
# Adding a macro: define here once, then reference everywhere. The compiler
# resolves $refs into the canonical settings_ui.json output the frontend reads.
macros:
# Most-used: only writable when the device is offroad.
offroad:
- {type: offroad_only}
# Writable while not engaged (started, but selfdrive/MADS not active).
not_engaged:
- {type: not_engaged}
# sunnypilot longitudinal control is active.
longitudinal:
- {type: capability, field: has_longitudinal_control, equals: true}
# Longitudinal + ICBM both available.
longitudinal_and_icbm:
- {type: capability, field: has_longitudinal_control, equals: true}
- {type: capability, field: has_icbm, equals: true}
# Item only meaningful when "Show Advanced Controls" is enabled by the user.
advanced_only:
- {type: param, key: ShowAdvancedControls, equals: true}
# Hide on MICI hardware (no analog HUD support yet).
hide_on_mici:
- type: not
condition: {type: capability, field: device_type, equals: mici}
# Mirrors selfdrive/ui/sunnypilot/layouts/.../mads_settings.py:_mads_limited_settings()
# Rivian + Tesla-without-vehicle-bus get the limited MADS UI (3-toggle subset).
# On those platforms these toggles are disabled — full MADS settings are
# only writable on platforms NOT in the limited-set.
mads_full_platforms:
- type: not
condition:
type: any
conditions:
- {type: capability, field: brand, equals: rivian}
- type: all
conditions:
- {type: capability, field: brand, equals: tesla}
- type: not
condition: {type: capability, field: tesla_has_vehicle_bus, equals: true}
# Inverse of mads_full_platforms: present only on the limited platforms.
# Useful for "show this only on rivian/tesla-no-bus" toggles.
mads_limited_platforms:
- type: any
conditions:
- {type: capability, field: brand, equals: rivian}
- type: all
conditions:
- {type: capability, field: brand, equals: tesla}
- type: not
condition: {type: capability, field: tesla_has_vehicle_bus, equals: true}
# Hide on sunnypilot release branches (is_release is hardcoded False everywhere; is_sp_release is the active gate).
release_branches_hide:
- type: not
condition: {type: capability, field: is_sp_release, equals: true}
@@ -1,23 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sunnypilot.com/schemas/sdui/macros.schema.json",
"title": "Settings UI macros (named rule fragments)",
"type": "object",
"additionalProperties": false,
"required": ["macros"],
"properties": {
"macros": {
"type": "object",
"description": "Named rule fragments. Each value is either a list of rules (typical) or a single rule object. Reference from items/layout via {$ref: '#/macros/<name>'}.",
"patternProperties": {
"^[A-Za-z_][A-Za-z0-9_]*$": {
"oneOf": [
{"type": "array", "items": {"$ref": "rule.schema.json"}},
{"$ref": "rule.schema.json"}
]
}
},
"additionalProperties": false
}
}
}
@@ -1,113 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sunnypilot.com/schemas/sdui/page.schema.json",
"title": "Settings UI page (panel) YAML",
"description": "Validates pages/<id>.yaml. Each page describes one settings panel (or the vehicle namespace via kind: vehicle).",
"type": "object",
"required": ["id"],
"additionalProperties": false,
"properties": {
"id": {"type": "string"},
"label": {"type": "string"},
"icon": {"type": "string"},
"order": {"type": "integer"},
"remote_configurable": {"type": "boolean"},
"description": {"type": "string"},
"kind": {
"type": "string",
"enum": ["panel", "vehicle"],
"description": "panel (default) or vehicle (compiles to settings_ui.json#vehicle_settings)."
},
"sections": {
"type": "array",
"items": {"$ref": "#/$defs/Section"}
},
"items": {
"type": "array",
"items": {"$ref": "#/$defs/Item"}
},
"sub_panels": {
"type": "array",
"items": {"$ref": "#/$defs/SubPanel"}
}
},
"$defs": {
"Section": {
"type": "object",
"required": ["id", "title"],
"additionalProperties": false,
"properties": {
"id": {"type": "string"},
"title": {"type": "string"},
"description": {"type": "string"},
"order": {"type": "integer"},
"attestation_required": {"type": "boolean"},
"visibility": {"type": "array", "items": {"$ref": "rule.schema.json"}},
"enablement": {"type": "array", "items": {"$ref": "rule.schema.json"}},
"items": {"type": "array", "items": {"$ref": "#/$defs/Item"}},
"sub_panels": {"type": "array", "items": {"$ref": "#/$defs/SubPanel"}}
}
},
"SubPanel": {
"type": "object",
"required": ["id", "label"],
"additionalProperties": false,
"properties": {
"id": {"type": "string"},
"label": {"type": "string"},
"trigger_key": {"type": ["string", "null"]},
"trigger_condition": {"$ref": "rule.schema.json"},
"items": {"type": "array", "items": {"$ref": "#/$defs/Item"}}
}
},
"Item": {
"type": "object",
"required": ["key", "widget"],
"additionalProperties": false,
"properties": {
"key": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"},
"widget": {"type": "string", "enum": ["toggle", "option", "multiple_button", "button", "info"]},
"title": {"type": "string"},
"description": {"type": "string", "description": "Inline body text under the title. May be omitted when only details is used."},
"details": {"type": "string", "description": "Extended help shown in a modal when the user taps the info (i) button. Independent of description; either, both, or neither may be present."},
"title_param_suffix": {"type": "object"},
"min": {"type": "number"},
"max": {"type": "number"},
"step": {"type": "number"},
"unit": {
"oneOf": [
{"type": "string"},
{
"type": "object",
"additionalProperties": false,
"required": ["metric", "imperial"],
"properties": {
"metric": {"type": "string"},
"imperial": {"type": "string"}
}
}
]
},
"needs_onroad_cycle": {"type": "boolean"},
"requires_attestation": {"type": "boolean"},
"blocked": {"type": "boolean"},
"options": {
"type": "array",
"items": {
"type": "object",
"required": ["value", "label"],
"additionalProperties": false,
"properties": {
"value": {},
"label": {"type": "string"},
"enablement": {"type": "array", "items": {"$ref": "rule.schema.json"}}
}
}
},
"visibility": {"type": "array", "items": {"$ref": "rule.schema.json"}},
"enablement": {"type": "array", "items": {"$ref": "rule.schema.json"}},
"sub_items": {"type": "array", "items": {"$ref": "#/$defs/Item"}}
}
}
}
}
@@ -1,101 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sunnypilot.com/schemas/sdui/rule.schema.json",
"title": "Rule",
"description": "Visibility/enablement rule. Discriminated union on 'type'. Macro reference is also accepted via {$ref: '#/macros/<name>'}.",
"oneOf": [
{"$ref": "#/$defs/MacroRef"},
{"$ref": "#/$defs/RuleOffroadOnly"},
{"$ref": "#/$defs/RuleNotEngaged"},
{"$ref": "#/$defs/RuleCapability"},
{"$ref": "#/$defs/RuleParam"},
{"$ref": "#/$defs/RuleParamCompare"},
{"$ref": "#/$defs/RuleNot"},
{"$ref": "#/$defs/RuleAny"},
{"$ref": "#/$defs/RuleAll"}
],
"$defs": {
"MacroRef": {
"type": "object",
"required": ["$ref"],
"additionalProperties": false,
"properties": {
"$ref": {
"type": "string",
"pattern": "^#/macros/[A-Za-z_][A-Za-z0-9_]*$",
"description": "Reference to a macro defined in _macros.yaml under #/macros/<name>."
}
}
},
"RuleOffroadOnly": {
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {"type": {"const": "offroad_only"}}
},
"RuleNotEngaged": {
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {"type": {"const": "not_engaged"}}
},
"RuleCapability": {
"type": "object",
"required": ["type", "field", "equals"],
"additionalProperties": false,
"properties": {
"type": {"const": "capability"},
"field": {"type": "string"},
"equals": {}
}
},
"RuleParam": {
"type": "object",
"required": ["type", "key", "equals"],
"additionalProperties": false,
"properties": {
"type": {"const": "param"},
"key": {"type": "string"},
"equals": {}
}
},
"RuleParamCompare": {
"type": "object",
"required": ["type", "key", "op", "value"],
"additionalProperties": false,
"properties": {
"type": {"const": "param_compare"},
"key": {"type": "string"},
"op": {"type": "string", "enum": [">", "<", ">=", "<="]},
"value": {"type": "number"}
}
},
"RuleNot": {
"type": "object",
"required": ["type", "condition"],
"additionalProperties": false,
"properties": {
"type": {"const": "not"},
"condition": {"$ref": "#"}
}
},
"RuleAny": {
"type": "object",
"required": ["type", "conditions"],
"additionalProperties": false,
"properties": {
"type": {"const": "any"},
"conditions": {"type": "array", "items": {"$ref": "#"}, "minItems": 1}
}
},
"RuleAll": {
"type": "object",
"required": ["type", "conditions"],
"additionalProperties": false,
"properties": {
"type": {"const": "all"},
"conditions": {"type": "array", "items": {"$ref": "#"}, "minItems": 1}
}
}
}
}
@@ -1,261 +0,0 @@
# Page: cruise
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
id: cruise
label: Cruise
icon: cruise_control
order: 2
remote_configurable: true
description: Longitudinal control, speed limits, and cruise behavior
sections:
- id: core_cruise_features
title: ''
description: ''
items:
- key: ExperimentalMode
widget: toggle
title: Experimental Mode
enablement:
- $ref: '#/macros/longitudinal'
- key: DisengageOnAccelerator
widget: toggle
title: Disengage Cruise on Accelerator Pedal
description: When enabled, pressing the accelerator pedal will disengage longitudinal control.
- key: LongitudinalPersonality
widget: multiple_button
title: Driving Personality
description: Standard is recommended. In aggressive mode, sunnypilot will follow lead cars closer and be more aggressive
with the gas and brake. In relaxed mode sunnypilot will stay further away from lead cars. On supported cars, you can
cycle through these personalities with your steering wheel distance button.
options:
- value: 0
label: Aggressive
- value: 1
label: Standard
- value: 2
label: Relaxed
enablement:
- $ref: '#/macros/longitudinal'
- key: IntelligentCruiseButtonManagement
widget: toggle
title: Intelligent Cruise Button Management (ICBM) (Alpha)
visibility:
- type: capability
field: icbm_available
equals: true
enablement:
- $ref: '#/macros/offroad'
- type: not
condition:
type: capability
field: has_longitudinal_control
equals: true
- id: custom_acc_increments
title: Custom ACC Speed Intervals
description: ''
enablement:
- $ref: '#/macros/offroad'
- type: any
conditions:
- type: all
conditions:
- type: capability
field: has_longitudinal_control
equals: true
- type: not
condition:
type: capability
field: pcm_cruise
equals: true
- type: capability
field: has_icbm
equals: true
items:
- key: CustomAccIncrementsEnabled
widget: toggle
title: Enable Custom ACC Speed Intervals
enablement:
- $ref: '#/macros/offroad'
- type: any
conditions:
- type: all
conditions:
- type: capability
field: has_longitudinal_control
equals: true
- type: not
condition:
type: capability
field: pcm_cruise
equals: true
- type: capability
field: has_icbm
equals: true
sub_panels:
- id: custom_acc_intervals
label: Custom ACC Speed Intervals Settings
trigger_key: CustomAccIncrementsEnabled
trigger_condition:
type: param
key: CustomAccIncrementsEnabled
equals: true
items:
- key: CustomAccShortPressIncrement
widget: option
title: Short Press Increment
min: 1
max: 10
step: 1
enablement:
- type: param
key: CustomAccIncrementsEnabled
equals: true
- key: CustomAccLongPressIncrement
widget: option
title: Long Press Increment
min: 1
max: 10
step: 1
enablement:
- type: param
key: CustomAccIncrementsEnabled
equals: true
- id: speed_limits
title: Speed Limits
description: Speed limit detection and offset behavior
items: []
sub_panels:
- id: speed_limit_settings
label: Speed Limit Settings
trigger_key: SpeedLimitMode
items:
- key: SpeedLimitMode
widget: multiple_button
title: Speed Limit Assist Mode
options:
- value: 0
label: 'Off'
- value: 1
label: Information
- value: 2
label: Warning
- value: 3
label: Assist
enablement:
- type: any
conditions:
- type: capability
field: has_longitudinal_control
equals: true
- type: capability
field: has_icbm
equals: true
- type: not
condition:
type: capability
field: brand
equals: rivian
- type: not
condition:
type: all
conditions:
- type: capability
field: brand
equals: tesla
- type: capability
field: is_sp_release
equals: true
- key: SpeedLimitPolicy
widget: multiple_button
title: Speed Limit Source
options:
- value: 0
label: Car State Only
- value: 1
label: Map Data Only
- value: 2
label: Car State Priority
- value: 3
label: Map Data Priority
- value: 4
label: Combined
- key: SpeedLimitOffsetType
widget: multiple_button
title: Speed Limit Offset Type
options:
- value: 0
label: 'Off'
- value: 1
label: Fixed
- value: 2
label: Percentage
- key: SpeedLimitValueOffset
widget: option
title: Speed Limit Offset Value
min: -30
max: 30
step: 1
unit:
metric: km/h
imperial: mph
visibility:
- type: param_compare
key: SpeedLimitOffsetType
op: '>'
value: 0
- id: smart_cruise
title: Smart Cruise Control
description: ''
enablement:
- type: any
conditions:
- type: capability
field: has_longitudinal_control
equals: true
- type: capability
field: has_icbm
equals: true
items:
- key: SmartCruiseControlVision
widget: toggle
title: Vision
description: Use vision path predictions to estimate the appropriate speed to drive through turns ahead.
visibility:
- type: any
conditions:
- type: capability
field: has_longitudinal_control
equals: true
- type: capability
field: has_icbm
equals: true
enablement:
- type: any
conditions:
- type: capability
field: has_longitudinal_control
equals: true
- type: capability
field: has_icbm
equals: true
- key: SmartCruiseControlMap
widget: toggle
title: Map
description: Use map data to estimate the appropriate speed to drive through turns ahead.
visibility:
- type: any
conditions:
- type: capability
field: has_longitudinal_control
equals: true
- type: capability
field: has_icbm
equals: true
enablement:
- type: any
conditions:
- type: capability
field: has_longitudinal_control
equals: true
- type: capability
field: has_icbm
equals: true
@@ -1,137 +0,0 @@
# Page: developer
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
id: developer
label: Developer
icon: developer
order: 9
remote_configurable: true
description: Debug tools, remote access, and advanced services
sections:
- id: connectivity
title: Connectivity
description: Remote access and debugging interfaces
items:
- key: AdbEnabled
widget: toggle
blocked: true
title: Enable ADB
description: ADB (Android Debug Bridge) allows connecting to your device over USB or over the network. See https://docs.comma.ai/how-to/connect-to-comma
for more info.
enablement:
- $ref: '#/macros/offroad'
- key: SshEnabled
widget: toggle
blocked: true
title: Enable SSH
- key: JoystickDebugMode
widget: toggle
title: Joystick Debug Mode
enablement:
- $ref: '#/macros/offroad'
- key: AlphaLongitudinalEnabled
widget: toggle
needs_onroad_cycle: true
title: sunnypilot Longitudinal Control (Alpha)
description: 'WARNING: sunnypilot longitudinal control is in alpha for this car and will disable Automatic Emergency Braking
(AEB). On this car, sunnypilot defaults to the car''s built-in ACC instead of sunnypilot''s longitudinal control. Enable
this to switch to sunnypilot longitudinal control. Enabling Experimental mode is recommended when enabling sunnypilot
longitudinal control alpha. Changing this setting will restart sunnypilot if the car is powered on.'
visibility:
- type: all
conditions:
- type: capability
field: alpha_long_available
equals: true
- type: not
condition:
type: capability
field: has_icbm
equals: true
enablement:
- $ref: '#/macros/not_engaged'
- key: ShowDebugInfo
widget: toggle
title: UI Debug Mode
- id: test_maneuvers
title: Test Maneuvers
description: 'DANGER: enabling these maneuvers replaces normal driving behavior with deterministic test sequences. Each
toggle requires explicit confirmation per write. Use only in a closed environment.'
visibility:
- type: any
conditions:
- type: capability
field: is_development
equals: true
- type: capability
field: is_sp_release
equals: true
enablement:
- type: any
conditions:
- type: capability
field: is_development
equals: true
- type: param
key: ShowAdvancedControls
equals: true
attestation_required: true
items:
- key: LateralManeuverMode
widget: toggle
title: '[TEST] Lateral Maneuver Mode'
description: Replaces normal lateral control with a deterministic test sequence. NOT for road use.
enablement:
- $ref: '#/macros/not_engaged'
- type: capability
field: torque_allowed
equals: true
- key: LongitudinalManeuverMode
widget: toggle
title: '[TEST] Longitudinal Maneuver Mode'
description: Replaces normal longitudinal control with a deterministic test sequence. NOT for road use.
enablement:
- $ref: '#/macros/not_engaged'
- $ref: '#/macros/longitudinal'
- id: advanced_services
title: Advanced Settings
description: ''
items:
- key: ShowAdvancedControls
widget: toggle
title: Show Advanced Controls
description: Toggle visibility of advanced sunnypilot controls. This only changes the visibility of the toggles; it does
not change the actual enabled/disabled state.
- key: EnableGithubRunner
widget: toggle
title: GitHub Runner Service
description: Enables or disables the GitHub runner service.
visibility:
- $ref: '#/macros/release_branches_hide'
enablement:
- $ref: '#/macros/advanced_only'
- key: EnableCopyparty
widget: toggle
title: copyparty Service
description: copyparty is a very capable file server, you can use it to download your routes, view your logs and even
make some edits on some files from your browser. Requires you to connect to your comma locally via its IP address.
enablement:
- $ref: '#/macros/advanced_only'
- key: QuickBootToggle
widget: toggle
title: Quickboot Mode
visibility:
- type: not
condition:
type: any
conditions:
- type: capability
field: is_sp_release
equals: true
- type: capability
field: is_development
equals: true
enablement:
- type: param
key: DisableUpdates
equals: true
- $ref: '#/macros/advanced_only'
@@ -1,67 +0,0 @@
# Page: device
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
id: device
label: Device
icon: device
order: 6
remote_configurable: true
description: Device behavior, units, and recording settings
sections:
- id: general
title: General
description: Power, boot, and unit preferences
items:
- key: OffroadMode
widget: toggle
title: Force Offroad Mode
- key: DeviceBootMode
widget: option
title: Wake Up Behavior
description: 'Controls state of the device after boot/sleep. Default: Device will boot/wake-up normally and will be ready
to engage. Offroad: Device will be in Always Offroad mode after boot/wake-up.'
options:
- value: 0
label: Standard
- value: 1
label: Always Offroad
- key: QuietMode
widget: toggle
title: Quiet Mode
- key: OnroadUploads
widget: toggle
title: Onroad Uploads
- key: MaxTimeOffroad
widget: option
title: Max Time Offroad
description: Device will automatically shutdown after set time once the engine is turned off. 30h is the default.
options:
- value: 0
label: Always On
- value: 5
label: 5m
- value: 10
label: 10m
- value: 15
label: 15m
- value: 30
label: 30m
- value: 60
label: 1h
- value: 120
label: 2h
- value: 180
label: 3h
- value: 300
label: 5h
- value: 600
label: 10h
- value: 1440
label: 24h
- value: 1800
label: 30h (Default)
- id: language
title: Language
items:
- key: LanguageSetting
widget: info
title: Language
@@ -1,163 +0,0 @@
# Page: display
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
id: display
label: Display
icon: display
order: 3
remote_configurable: true
description: Screen brightness, timeout, and interactivity settings
sections:
- id: brightness_timeout
title: Brightness & Timeout
description: Screen dimming and sleep behavior while driving
items:
- key: OnroadScreenOffBrightness
widget: multiple_button
title: Onroad Brightness
options:
- value: 0
label: Auto (Default)
- value: 1
label: Auto (Dark)
- value: 2
label: Screen Off
- value: 3
label: 5 %
- value: 4
label: 10 %
- value: 5
label: 15 %
- value: 6
label: 20 %
- value: 7
label: 25 %
- value: 8
label: 30 %
- value: 9
label: 35 %
- value: 10
label: 40 %
- value: 11
label: 45 %
- value: 12
label: 50 %
- value: 13
label: 55 %
- value: 14
label: 60 %
- value: 15
label: 65 %
- value: 16
label: 70 %
- value: 17
label: 75 %
- value: 18
label: 80 %
- value: 19
label: 85 %
- value: 20
label: 90 %
- value: 21
label: 95 %
- value: 22
label: 100 %
- key: OnroadScreenOffTimer
widget: multiple_button
title: Onroad Brightness Delay
options:
- value: 0
label: Always On
- value: 3
label: 3s
- value: 5
label: 5s
- value: 10
label: 10s
- value: 15
label: 15s
- value: 30
label: 30s
- value: 60
label: 1m
- value: 180
label: 3m
- value: 300
label: 5m
- value: 600
label: 10m
enablement:
- type: not
condition:
type: any
conditions:
- type: param
key: OnroadScreenOffBrightness
equals: 0
- type: param
key: OnroadScreenOffBrightness
equals: 1
- key: InteractivityTimeout
widget: multiple_button
title: Interactivity Timeout
description: Apply a custom timeout for settings UI. This is the time after which settings UI closes automatically if
user is not interacting with the screen.
options:
- value: 0
label: Default
- value: 10
label: 10 s
- value: 20
label: 20 s
- value: 30
label: 30 s
- value: 40
label: 40 s
- value: 50
label: 50 s
- value: 60
label: 1 m
- value: 70
label: 1 m
- value: 80
label: 1 m
- value: 90
label: 1 m
- value: 100
label: 1 m
- value: 110
label: 1 m
- value: 120
label: 2 m
- key: ScreenSaverEnabled
widget: toggle
title: Screen Saver
description: Show a screen saver when the device is offroad and idle, instead of turning the screen off.
- key: ScreenSaverTimeout
widget: multiple_button
title: Screen Saver Duration
description: How long the screen saver runs before the screen turns off.
options:
- value: 60
label: 1 m
- value: 120
label: 2 m
- value: 180
label: 3 m
- value: 240
label: 4 m
- value: 300
label: 5 m
- value: 360
label: 6 m
- value: 420
label: 7 m
- value: 480
label: 8 m
- value: 540
label: 9 m
- value: 600
label: 10 m
enablement:
- type: param
key: ScreenSaverEnabled
equals: true
@@ -1,92 +0,0 @@
# Page: models
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
id: models
label: Models
icon: models
order: 10
remote_configurable: false
description: Driving model behavior and camera calibration
sections:
- id: model_behavior
title: Model Behavior
description: Lane desire and lead-vehicle awareness tuning
items:
- key: LaneTurnDesire
widget: toggle
title: Use Lane Turn Desires
description: If you are driving at 20 mph (32 km/h) or below and have your blinker on, the car will plan a turn in that
direction at the nearest drivable path. This prevents situations (like at red lights) where the car might plan the wrong
turn direction.
- key: LaneTurnValue
widget: option
title: Adjust Lane Turn Speed
description: Set the maximum speed for lane turn desires.
min: 0
max: 20
step: 1
unit:
metric: km/h
imperial: mph
enablement:
- type: param
key: LaneTurnDesire
equals: true
- $ref: '#/macros/advanced_only'
- key: LagdToggle
widget: toggle
title: Live Learning Steer Delay
description: Allow device to learn and adapt car's steering response time
- key: LagdToggleDelay
widget: option
title: Adjust Software Delay
description: Adjust the software delay when Live Learning Steer Delay is toggled off. The default software delay value
is 0.2
min: 0.05
max: 0.5
step: 0.01
enablement:
- type: not
condition:
type: param
key: LagdToggle
equals: true
- $ref: '#/macros/advanced_only'
- id: lateral_control
title: Lateral Control
description: Neural network lateral control for supported models
items:
- key: NeuralNetworkLateralControl
widget: toggle
title: Neural Network Lateral Control (NNLC)
description: Use a neural network for lateral control instead of the default torque controller.
visibility:
- type: not
condition:
type: capability
field: steer_control_type
equals: angle
enablement:
- $ref: '#/macros/offroad'
- type: capability
field: torque_allowed
equals: true
- type: param
key: EnforceTorqueControl
equals: false
- type: param
key: LateralJerkTorqueController
equals: false
- id: camera
title: Camera
description: Camera position and calibration
items:
- key: CameraOffset
widget: option
title: Adjust Camera Offset
description: Virtually shift camera's perspective to move model's center to Left(+ values) or Right (- values)
min: -0.35
max: 0.35
step: 0.01
unit: meters
enablement:
- $ref: '#/macros/advanced_only'
@@ -1,20 +0,0 @@
# Page: software
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
id: software
label: Software
icon: software
order: 7
remote_configurable: true
description: Software update preferences
sections:
- id: updates
title: Updates
description: Control software updates
items:
- key: DisableUpdates
widget: toggle
title: Disable Updates
description: When enabled, software updates will be off. This requires a reboot to take effect.
enablement:
- $ref: '#/macros/offroad'
- $ref: '#/macros/advanced_only'
@@ -1,276 +0,0 @@
# Page: steering
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
id: steering
label: Steering
icon: steering_wheel
order: 1
remote_configurable: true
description: Lateral control, lane changes, and steering behavior
sections:
- id: mads
title: Modular Assistive Driving System (MADS)
description: ''
items:
- key: Mads
widget: toggle
title: Enable Modular Assistive Driving System (MADS)
description: Enable MADS. Disable toggle to revert back to stock sunnypilot engagement/disengagement.
enablement:
- $ref: '#/macros/offroad'
sub_panels:
- id: mads_settings
label: MADS Settings
trigger_key: Mads
trigger_condition:
type: param
key: Mads
equals: true
items:
- key: MadsMainCruiseAllowed
widget: toggle
title: Toggle with Main Cruise
description: 'Note: For vehicles without LFA/LKAS button, disabling this will prevent lateral control engagement.'
enablement:
- $ref: '#/macros/offroad'
- $ref: '#/macros/mads_full_platforms'
- key: MadsUnifiedEngagementMode
widget: toggle
title: Unified Engagement Mode (UEM)
description: 'Engage lateral and longitudinal control with cruise control engagement. Note: Once lateral control is
engaged via UEM, it will remain engaged until it is manually disabled via the MADS button or car shut off.'
enablement:
- $ref: '#/macros/offroad'
- $ref: '#/macros/mads_full_platforms'
- key: MadsSteeringMode
widget: multiple_button
title: Steering Mode on Brake Pedal
description: Choose how Automatic Lane Centering (ALC) behaves after the brake pedal is manually pressed in sunnypilot.
options:
- value: 0
label: Remain Active
enablement:
- $ref: '#/macros/mads_full_platforms'
- value: 1
label: Pause
enablement:
- $ref: '#/macros/mads_full_platforms'
- value: 2
label: Disengage
enablement:
- $ref: '#/macros/offroad'
- id: blinker
title: Blinker Control
description: Lateral pause behavior during turn signals
items:
- key: BlinkerPauseLateralControl
widget: toggle
title: Pause Lateral Control with Blinker
description: Pause lateral control with blinker when traveling below the desired speed selected.
sub_items:
- key: BlinkerMinLateralControlSpeed
widget: option
title: Minimum Speed to Pause Lateral Control
min: 0
max: 255
step: 5
unit:
metric: km/h
imperial: mph
enablement:
- type: param
key: BlinkerPauseLateralControl
equals: true
- key: BlinkerLateralReengageDelay
widget: option
title: Post-Blinker Delay
description: Delay before lateral control resumes after the turn signal ends.
min: 0
max: 10
step: 1
unit: second
enablement:
- type: param
key: BlinkerPauseLateralControl
equals: true
- id: torque
title: Torque Control
description: Steering torque tuning and lateral control method
enablement:
- type: capability
field: torque_allowed
equals: true
items:
- key: EnforceTorqueControl
widget: toggle
title: Enforce Torque Lateral Control
description: Enable this to enforce sunnypilot to steer with Torque lateral control.
visibility:
- type: not
condition:
type: capability
field: steer_control_type
equals: angle
enablement:
- $ref: '#/macros/offroad'
- type: capability
field: torque_allowed
equals: true
- type: param
key: NeuralNetworkLateralControl
equals: false
sub_panels:
- id: torque_settings
label: Torque Settings
trigger_key: EnforceTorqueControl
trigger_condition:
type: param
key: EnforceTorqueControl
equals: true
items:
- key: LateralJerkTorqueController
widget: toggle
title: Lateral Jerk Torque Controller
description: Looks ahead at planned steering to reduce sudden corrections, so the wheel moves more smoothly through turns. Works with Self-Tune and custom tuning. Thanks to @twilsonco for the implementation.
visibility:
- type: not
condition:
type: capability
field: steer_control_type
equals: angle
enablement:
- $ref: '#/macros/offroad'
- type: param
key: NeuralNetworkLateralControl
equals: false
- key: LiveTorqueParamsToggle
widget: toggle
title: Self-Tune
description: Enables self-tune for Torque lateral control for platforms that do not use Torque lateral control by default.
enablement:
- $ref: '#/macros/offroad'
- key: LiveTorqueParamsRelaxedToggle
widget: toggle
title: Less Restrict Settings for Self-Tune (Beta)
description: Less strict settings when using Self-Tune. This allows torqued to be more forgiving when learning values.
enablement:
- $ref: '#/macros/offroad'
- type: param
key: LiveTorqueParamsToggle
equals: true
- key: CustomTorqueParams
widget: toggle
title: Enable Custom Tuning
description: Enables custom tuning for Torque lateral control. Modifying Lateral Acceleration Factor and Friction below
will override the offline values indicated in the YAML files within "opendbc/car/torque_data". The values will also
be used live when "Manual Real-Time Tuning" toggle is enabled.
enablement:
- $ref: '#/macros/offroad'
- key: TorqueParamsOverrideEnabled
widget: toggle
title: Manual Real-Time Tuning
description: Enforces the torque lateral controller to use the fixed values instead of the learned values from Self-Tune.
Enabling this toggle overrides Self-Tune values.
enablement:
- $ref: '#/macros/offroad'
- type: param
key: CustomTorqueParams
equals: true
- key: TorqueParamsOverrideLatAccelFactor
widget: option
title: Lateral Acceleration Factor
title_param_suffix:
param: TorqueParamsOverrideEnabled
values:
'true': (Real-Time & Offline)
'false': (Offline Only)
min: 0.1
max: 5.0
step: 0.1
unit: m/s²
enablement:
- type: param
key: CustomTorqueParams
equals: true
- type: any
conditions:
- type: param
key: TorqueParamsOverrideEnabled
equals: true
- type: offroad_only
- key: TorqueParamsOverrideFriction
widget: option
title: Friction
title_param_suffix:
param: TorqueParamsOverrideEnabled
values:
'true': (Real-Time & Offline)
'false': (Offline Only)
min: 0.0
max: 1.0
step: 0.01
enablement:
- type: param
key: CustomTorqueParams
equals: true
- type: any
conditions:
- type: param
key: TorqueParamsOverrideEnabled
equals: true
- type: offroad_only
- key: TorqueControlTune
widget: multiple_button
title: Torque Control Tune Version
description: Select the version of Torque Control Tune to use.
options:
- value: ''
label: Default
- value: 1.0
label: v1.0
- value: 0.0
label: v0.0
enablement:
- $ref: '#/macros/offroad'
- id: lane_change
title: Lane Change
description: Automatic lane change timing and behavior
items:
- key: AutoLaneChangeTimer
widget: option
title: Auto Lane Change by Blinker
description: |-
Set a timer to delay the auto lane change operation when the blinker is used. No nudge on the steering wheel is required to auto lane change if a timer is set. Default is Nudge.
details: |-
Please use caution when using this feature. Only use the blinker when traffic and road conditions permit.
options:
- value: -1
label: 'Off'
- value: 0
label: Nudge
- value: 1
label: Nudgeless
- value: 2
label: 0.5 second
- value: 3
label: 1 second
- value: 4
label: 2 seconds
- value: 5
label: 3 seconds
- key: RoadEdgeLaneChangeEnabled
widget: toggle
title: 'Block Lane Change: Road Edge Detection'
description: Blocks lane change when the model sees a road edge on the side you signal.
- key: AutoLaneChangeBsmDelay
widget: toggle
title: 'Auto Lane Change: Delay with Blind Spot'
description: Toggle to enable a delay timer for lane changes when blind spot monitoring (BSM) detects a vehicle in your blind
spot.
enablement:
- type: capability
field: enable_bsm
equals: true
- type: param_compare
key: AutoLaneChangeTimer
op: '>'
value: 0
@@ -1,49 +0,0 @@
# Page: toggles
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
id: toggles
label: Toggles
icon: toggles
order: 5
remote_configurable: true
description: Core openpilot feature toggles
sections:
- id: core_toggles
title: ''
description: ''
items:
- key: OpenpilotEnabledToggle
widget: toggle
needs_onroad_cycle: true
title: Enable sunnypilot
description: Use the sunnypilot system for adaptive cruise control and lane keep driver assistance. Your attention is
required at all times to use this feature.
enablement:
- $ref: '#/macros/offroad'
- key: IsLdwEnabled
widget: toggle
title: Enable Lane Departure Warnings
description: Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line without a turn
signal activated while driving over 31 mph (50 km/h).
- key: AlwaysOnDM
widget: toggle
title: Always-On Driver Monitoring
description: Enable driver monitoring even when sunnypilot is not engaged.
- key: IsMetric
widget: toggle
title: Use Metric System
description: Display speed in km/h instead of mph.
- id: recording
title: Recording
description: Camera and audio recording during drives
items:
- key: RecordFront
widget: toggle
needs_onroad_cycle: true
title: Record and Upload Driver Camera
description: Upload data from the driver facing camera and help improve the driver monitoring algorithm.
- key: RecordAudio
widget: toggle
needs_onroad_cycle: true
title: Record and Upload Microphone Audio
description: Record and store microphone audio while driving. The audio will be included in the dashcam video in comma
connect.
@@ -1,103 +0,0 @@
# Page: vehicle (per-brand settings)
# Compiles to settings_ui.json#vehicle_settings (brand = section id).
id: vehicle
label: Vehicle
icon: vehicle
order: 99
kind: vehicle
sections:
- id: hyundai
title: Hyundai / Kia / Genesis Settings
description: ''
items:
- key: HyundaiLongitudinalTuning
widget: multiple_button
title: Custom Longitudinal Tuning
options:
- value: 0
label: 'Off'
- value: 1
label: Dynamic
- value: 2
label: Predictive
visibility:
- type: capability
field: hyundai_alpha_long_available
equals: true
enablement:
- $ref: '#/macros/offroad'
- $ref: '#/macros/longitudinal'
- id: subaru
title: Subaru Settings
description: ''
items:
- key: SubaruStopAndGo
widget: toggle
title: Stop and Go (Beta)
enablement:
- $ref: '#/macros/offroad'
- type: capability
field: has_stop_and_go
equals: true
- key: SubaruStopAndGoManualParkingBrake
widget: toggle
title: Stop and Go for Manual Parking Brake (Beta)
enablement:
- $ref: '#/macros/offroad'
- type: capability
field: has_stop_and_go
equals: true
- id: tesla
title: Tesla Settings
description: ''
items:
- key: TeslaCoopSteering
widget: toggle
title: Cooperative Steering (Beta)
enablement:
- $ref: '#/macros/offroad'
- key: TeslaMadsScreenButton
widget: multiple_button
title: MADS Screen Activation
description: 'Use a multi-finger press on the infotainment screen to toggle MADS.
This allows the use of full MADS functionality when enabled. Selecting a higher
finger count may reduce accidental activations. Note: Setting this to Off will
reset your MADS settings to default.'
options:
- value: 0
label: 'Off'
- value: 1
label: 3-Finger
- value: 2
label: 4-Finger
- value: 3
label: 5-Finger
visibility:
- type: capability
field: tesla_has_vehicle_bus
equals: true
enablement:
- $ref: '#/macros/offroad'
- id: toyota
title: Toyota / Lexus Settings
description: ''
items:
- key: ToyotaEnforceStockLongitudinal
widget: toggle
needs_onroad_cycle: true
title: Enforce Factory Longitudinal Control
description: sunnypilot will not take over control of gas and brakes. Factory Toyota longitudinal control will be used.
enablement:
- $ref: '#/macros/not_engaged'
- key: ToyotaStopAndGoHack
widget: toggle
needs_onroad_cycle: true
title: Stop and Go Hack (Alpha)
description: sunnypilot will allow some Toyota/Lexus cars to auto resume during stop and go traffic. This feature is only
applicable to certain models that are able to use longitudinal control. This is an alpha feature. Use at your own risk.
enablement:
- $ref: '#/macros/not_engaged'
- $ref: '#/macros/longitudinal'
- type: param
key: ToyotaEnforceStockLongitudinal
equals: false
@@ -1,111 +0,0 @@
# Page: visuals
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
id: visuals
label: Visuals
icon: visuals
order: 4
remote_configurable: true
description: HUD overlays, alerts, and on-screen display elements
sections:
- id: hud_elements
title: HUD Elements
description: Overlays shown on the driving screen
items:
- key: BlindSpot
widget: toggle
title: Show Blind Spot Warnings
description: Enabling this will display warnings when a vehicle is detected in your blind spot as long as your car has
BSM supported.
- key: TorqueBar
widget: toggle
title: Steering Arc
description: Display steering arc on the driving screen when lateral control is enabled.
- key: ShowTurnSignals
widget: toggle
title: Display Turn Signals
description: When enabled, visual turn indicators are drawn on the HUD.
- key: RoadNameToggle
widget: toggle
title: Display Road Name
description: Displays the name of the road the car is traveling on. The OpenStreetMap database of the location must be
downloaded to fetch the road name.
visibility:
- $ref: '#/macros/hide_on_mici'
- key: StandstillTimer
widget: toggle
title: Standstill Timer
description: Show a timer on the HUD when the car is at a standstill.
visibility:
- $ref: '#/macros/hide_on_mici'
- key: RocketFuel
widget: toggle
title: Real-time Acceleration Bar
description: Show an indicator on the left side of the screen to display real-time vehicle acceleration and deceleration.
This displays what the car is currently doing, not what the planner is requesting.
visibility:
- $ref: '#/macros/hide_on_mici'
- key: ChevronInfo
widget: option
title: Display Metrics Below Chevron
options:
- value: 0
label: 'Off'
- value: 1
label: Distance
- value: 2
label: Speed
- value: 3
label: Time
- value: 4
label: All
visibility:
- $ref: '#/macros/hide_on_mici'
enablement:
- $ref: '#/macros/longitudinal'
- id: developer_ui
title: Developer UI Info
description: Speedometer and debug display options
visibility:
- $ref: '#/macros/hide_on_mici'
items:
- key: DevUIInfo
widget: option
title: Developer UI
description: Display real-time parameters and metrics from various sources.
options:
- value: 0
label: 'Off'
- value: 1
label: Bottom
- value: 2
label: Right
- value: 3
label: Right & Bottom
- key: TrueVEgoUI
widget: toggle
title: 'Speedometer: Always Display True Speed'
description: For applicable vehicles, always display the true vehicle current speed from wheel speed sensors.
- key: HideVEgoUI
widget: toggle
title: 'Speedometer: Hide from Onroad Screen'
description: When enabled, the speedometer on the onroad screen is not displayed.
- id: alerts_extras
title: Alerts & Extras
description: Traffic light alerts and visual flair
items:
- key: GreenLightAlert
widget: toggle
title: Green Traffic Light Alert (Beta)
description: 'A chime and on-screen alert will play when the traffic light you are waiting for turns green and you have
no vehicle in front of you. On-screen visual alert is only available on comma 3X. Note: This chime is only designed
as a notification. It is the driver''s responsibility to observe their environment and make decisions accordingly.'
- key: LeadDepartAlert
widget: toggle
title: Lead Departure Alert (Beta)
description: 'A chime and on-screen alert will play when you are stopped, and the vehicle in front of you start moving.
On-screen visual alert is only available on comma 3X. Note: This chime is only designed as a notification. It is the
driver''s responsibility to observe their environment and make decisions accordingly.'
- key: RainbowMode
widget: toggle
title: Tesla Rainbow Mode
description: Display a rainbow effect on the path the model wants to take. It does not affect driving in any way.
-279
View File
@@ -1,279 +0,0 @@
#!/usr/bin/env python3
import base64
import json
import os
import threading
import traceback
import zmq
import time
import uuid
from pathlib import Path
from collections import defaultdict
from datetime import datetime, UTC
from openpilot.common.params import Params
from openpilot.cereal.messaging import SubMaster
from openpilot.common.hardware.hw import Paths
from openpilot.common.swaglog import cloudlog
from openpilot.common.hardware import HARDWARE
from openpilot.common.utils import atomic_write
from openpilot.common.version import get_build_metadata
from openpilot.system.loggerd.config import STATS_DIR_FILE_LIMIT, STATS_SOCKET, STATS_FLUSH_TIME_S
from openpilot.sunnypilot.system.statsd import METRIC_TYPE, StatLogSP
from openpilot.common.realtime import Ratekeeper
STATSLOGSP = StatLogSP(intercept=False)
def sp_stats(end_event):
"""Collect sunnypilot-specific statistics and send as raw metrics."""
rk = Ratekeeper(.1, print_delay_threshold=None)
statlogsp = STATSLOGSP
params = Params()
def flatten_dict(d, parent_key='', sep='.'):
items = {}
if isinstance(d, dict):
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
items.update(flatten_dict(v, new_key, sep=sep))
elif isinstance(d, (list, tuple)):
for i, v in enumerate(d):
new_key = f"{parent_key}[{i}]"
items.update(flatten_dict(v, new_key, sep=sep))
else:
items[parent_key] = d
return items
# Collect sunnypilot parameters
stats_dict = {}
param_keys = [
'SunnylinkEnabled',
'AutoLaneChangeBsmDelay',
'AutoLaneChangeTimer',
'CarPlatformBundle',
'CurrentRoute',
'DevUIInfo',
'EnableCopyparty',
'IntelligentCruiseButtonManagement',
'QuietMode',
'RainbowMode',
'ShowAdvancedControls',
'Mads',
'MadsMainCruiseAllowed',
'MadsSteeringMode',
'MadsUnifiedEngagementMode',
'ModelManager_ActiveBundle',
'ModelManager_ActiveBundleChestnut',
'ModelManager_Favs',
'EnableSunnylinkUploader',
'SunnylinkEnabled',
'InstallDate',
'UptimeOffroad',
'UptimeOnroad',
]
while not end_event.is_set():
try:
for key in param_keys:
try:
value = params.get(key)
except Exception as e:
stats_dict[key] = e
continue
if value is None:
continue
if isinstance(value, (dict, list, tuple)):
stats_dict.update(flatten_dict(value, key))
else:
stats_dict[key] = value
if stats_dict:
statlogsp.raw('sunnypilot.device_params', stats_dict)
except Exception as e:
cloudlog.error(f"Exception {e}")
finally:
rk.keep_time()
def stats_main(end_event):
comma_dongle_id = Params().get("DongleId")
sunnylink_dongle_id = Params().get("SunnylinkDongleId")
def get_influxdb_line(measurement: str, value: float | dict[str, float], timestamp: datetime, tags: dict) -> str:
res = f"{measurement}"
for k, v in tags.items():
res += f",{k}={str(v)}"
res += " "
if isinstance(value, float):
value = {'value': value}
for k, v in value.items():
res += f"{k}={str(v)},"
res += f"sunnylink_dongle_id=\"{sunnylink_dongle_id}\",comma_dongle_id=\"{comma_dongle_id}\" {int(timestamp.timestamp() * 1e9)}\n"
return res
def get_influxdb_line_raw(measurement: str, value: dict, timestamp: datetime, tags: dict) -> str:
res = f"{measurement}"
try:
custom_tags = ""
for k, v in tags.items():
custom_tags += f",{k}={str(v)}"
res += custom_tags
fields = ""
for k, v in value.items():
# Skip complex types - only keep simple scalar values
if isinstance(v, (dict, list, bytes, bytearray)):
continue
fields += f"{k}={json.dumps(v)},"
res += f" {fields}"
except Exception as e:
cloudlog.error(f"Unable to get influxdb line for: {value}")
res += f",invalid=1 reason={e},"
res += f"sunnylink_dongle_id=\"{sunnylink_dongle_id}\",comma_dongle_id=\"{comma_dongle_id}\" {int(timestamp.timestamp() * 1e9)}\n"
return res
# open statistics socket
ctx = zmq.Context.instance()
sock = ctx.socket(zmq.PULL)
sock.bind(f"{STATS_SOCKET}_sp")
STATS_DIR = Paths.stats_sp_root()
# initialize stats directory
Path(STATS_DIR).mkdir(parents=True, exist_ok=True)
build_metadata = get_build_metadata()
# initialize tags
tags = {
'started': False,
'version': build_metadata.openpilot.version,
'branch': build_metadata.channel,
'dirty': build_metadata.openpilot.is_dirty,
'origin': build_metadata.openpilot.git_normalized_origin,
'deviceType': HARDWARE.get_device_type(),
}
# subscribe to deviceState for started state
sm = SubMaster(['deviceState'])
idx = 0
boot_uid = str(uuid.uuid4())[:8]
last_flush_time = time.monotonic()
gauges = {}
samples: dict[str, list[float]] = defaultdict(list)
raws: dict = defaultdict()
try:
while not end_event.is_set():
started_prev = sm['deviceState'].started
sm.update()
# Update metrics
while True:
try:
metric = sock.recv_string(zmq.NOBLOCK)
try:
metric_type = metric.split('|')[1]
metric_name = metric.split(':')[0]
metric_value_raw = metric.split('|')[0].split(':')[1]
if metric_type == METRIC_TYPE.GAUGE:
metric_value = float(metric_value_raw)
gauges[metric_name] = metric_value
elif metric_type == METRIC_TYPE.SAMPLE:
metric_value = float(metric_value_raw)
samples[metric_name].append(metric_value)
elif metric_type == METRIC_TYPE.RAW:
raws[metric_name] = metric_value_raw
else:
cloudlog.event("unknown metric type", metric_type=metric_type)
except Exception:
print(traceback.format_exc())
cloudlog.event("malformed metric", metric=metric)
except zmq.error.Again:
break
# flush when started state changes or after FLUSH_TIME_S
if (time.monotonic() > last_flush_time + STATS_FLUSH_TIME_S) or (sm['deviceState'].started != started_prev):
result = ""
current_time = datetime.now(UTC)
tags['started'] = sm['deviceState'].started
for key, value in raws.items():
decoded_value = json.loads(base64.b64decode(value).decode('utf-8'))
result += get_influxdb_line_raw(key, decoded_value, current_time, tags)
for key, value in gauges.items():
result += get_influxdb_line(f"gauge.{key}", value, current_time, tags)
for key, values in samples.items():
values.sort()
sample_count = len(values)
sample_sum = sum(values)
stats = {
'count': sample_count,
'min': values[0],
'max': values[-1],
'mean': sample_sum / sample_count,
}
for percentile in [0.05, 0.5, 0.95]:
value = values[int(round(percentile * (sample_count - 1)))]
stats[f"p{int(percentile * 100)}"] = value
result += get_influxdb_line(f"sample.{key}", stats, current_time, tags)
# clear intermediate data
gauges.clear()
samples.clear()
last_flush_time = time.monotonic()
# check that we aren't filling up the drive
if len(os.listdir(STATS_DIR)) < STATS_DIR_FILE_LIMIT:
if len(result) > 0:
stats_path = os.path.join(STATS_DIR, f"{boot_uid}_{idx}")
with atomic_write(stats_path) as f:
f.write(result)
idx += 1
else:
cloudlog.error("stats dir full")
finally:
sock.close()
ctx.term()
def main():
rk = Ratekeeper(1, print_delay_threshold=None)
end_event = threading.Event()
threads = [
threading.Thread(target=stats_main, args=(end_event,)),
threading.Thread(target=sp_stats, args=(end_event,)),
]
for t in threads:
t.start()
try:
while all(t.is_alive() for t in threads):
rk.keep_time()
finally:
end_event.set()
for t in threads:
t.join()
if __name__ == "__main__":
main()
@@ -1,235 +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.
"""
from enum import IntEnum
import threading
import requests
import time
import json
import pyray as rl
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID, SunnylinkApi
from openpilot.system.ui.sunnypilot.lib.styles import style
class RoleType(IntEnum):
READONLY = 0
SPONSOR = 1
ADMIN = 2
class SponsorTier(IntEnum):
FREE = 0
NOVICE = 1
SUPPORTER = 2
CONTRIBUTOR = 3
BENEFACTOR = 4
GUARDIAN = 5
class User:
device_id: str
user_id: str
created_at: int
updated_at: int
token_hash: str
def __init__(self, json_data):
self.device_id = json_data.get("device_id")
self.user_id = json_data.get("user_id")
self.created_at = json_data.get("created_at")
self.updated_at = json_data.get("updated_at")
self.token_hash = json_data.get("token_hash")
class Role:
role_type: str
role_tier: str
def __init__(self, json_data):
self.role_type = json_data.get("role_type")
self.role_tier = json_data.get("role_tier")
def _parse_roles(roles: str) -> list[Role]:
lst_roles = []
try:
roles_list = json.loads(roles)
for r in roles_list:
try:
role = Role(r)
lst_roles.append(role)
except Exception as e:
cloudlog.exception(f"Failed to parse role {r}: {e}")
return lst_roles
except Exception as e:
cloudlog.exception(f"Error parsing roles: {e}")
return []
def _parse_users(users: str) -> list[User]:
lst_users = []
try:
users_list = json.loads(users)
for u in users_list:
try:
user = User(u)
lst_users.append(user)
except Exception as e:
cloudlog.exception(f"Failed to parse user {u}: {e}")
return lst_users
except Exception as e:
cloudlog.exception(f"Error parsing users: {e}")
return []
class SunnylinkState:
FETCH_INTERVAL = 5.0 # seconds between API calls
API_TIMEOUT = 10.0 # seconds for API requests
SLEEP_INTERVAL = 0.5 # seconds to sleep between checks in the worker thread
NOT_PAIRED_USERNAMES = ["unregisteredsponsor", "temporarysponsor"]
def __init__(self):
self._params = Params()
self._lock = threading.Lock()
self._session = requests.Session() # reuse session to reduce SSL handshake overhead
self._running = False
self._thread = None
self._sm = messaging.SubMaster(['deviceState'])
self._roles: list[Role] = []
self._users: list[User] = []
self.sponsor_tier: SponsorTier = SponsorTier.FREE
self.sunnylink_dongle_id = self._params.get("SunnylinkDongleId")
self._api = SunnylinkApi(self.sunnylink_dongle_id)
self._panel_open = False
self._load_initial_state()
def _load_initial_state(self) -> None:
roles_cache = self._params.get("SunnylinkCache_Roles")
users_cache = self._params.get("SunnylinkCache_Users")
if roles_cache is not None:
self._roles = _parse_roles(roles_cache)
self.sponsor_tier = self._get_highest_tier()
if users_cache is not None:
self._users = _parse_users(users_cache)
def _get_highest_tier(self) -> SponsorTier:
role_tier = SponsorTier.FREE
for role in self._roles:
try:
if RoleType[role.role_type.upper()] == RoleType.SPONSOR:
role_tier = max(role_tier, SponsorTier[role.role_tier.upper()])
except Exception as e:
cloudlog.exception(f"Error parsing role {role}: {e} for dongle id {self.sunnylink_dongle_id}")
return role_tier
def _fetch_roles(self) -> None:
if not self.sunnylink_dongle_id or self.sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID:
return
try:
token = self._api.get_token()
response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/roles", method='GET', access_token=token, session=self._session)
if response.status_code == 200:
roles = response.text
self._params.put("SunnylinkCache_Roles", roles, block=True)
with self._lock:
self._roles = _parse_roles(roles)
sponsor_tier = self._get_highest_tier()
if sponsor_tier != self.sponsor_tier:
self.sponsor_tier = sponsor_tier
cloudlog.info(f"Sunnylink sponsor tier updated to {sponsor_tier.name}")
except Exception as e:
cloudlog.exception(f"Failed to fetch sunnylink roles: {e} for dongle id {self.sunnylink_dongle_id}")
def _fetch_users(self) -> None:
if not self.sunnylink_dongle_id or self.sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID:
return
try:
token = self._api.get_token()
response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/users", method='GET', access_token=token, session=self._session)
if response.status_code == 200:
users = response.text
self._params.put("SunnylinkCache_Users", users, block=True)
with self._lock:
self._users = _parse_users(users)
except Exception as e:
cloudlog.exception(f"Failed to fetch sunnylink users: {e} for dongle id {self.sunnylink_dongle_id}")
def _worker_thread(self) -> None:
while self._running:
with self._lock:
panel_open = self._panel_open
if panel_open:
self._sm.update()
if self.is_connected():
self._fetch_roles()
self._fetch_users()
for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)):
if not self._running:
break
time.sleep(self.SLEEP_INTERVAL)
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._running = True
self._thread = threading.Thread(target=self._worker_thread, daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=1.0)
def get_sponsor_tier(self) -> SponsorTier:
with self._lock:
return self.sponsor_tier
def is_sponsor(self) -> bool:
with self._lock:
is_sponsor = any(role.role_type.upper() == RoleType.SPONSOR.name and role.role_tier.upper() != SponsorTier.FREE.name
for role in self._roles)
return is_sponsor
def is_paired(self) -> bool:
with self._lock:
is_paired = any(user.user_id not in self.NOT_PAIRED_USERNAMES for user in self._users)
return is_paired
def is_connected(self) -> bool:
network_type = self._sm["deviceState"].networkType
return bool(network_type != 0)
def get_sponsor_tier_color(self) -> rl.Color:
tier = self.get_sponsor_tier()
if tier == SponsorTier.GUARDIAN:
return rl.Color(255, 215, 0, 255)
elif tier == SponsorTier.BENEFACTOR:
return rl.Color(60, 179, 113, 255)
elif tier == SponsorTier.CONTRIBUTOR:
return rl.Color(70, 130, 180, 255)
elif tier == SponsorTier.SUPPORTER:
return rl.Color(147, 112, 219, 255)
else:
return style.ITEM_TEXT_VALUE_COLOR
def set_settings_open(self, _open: bool) -> None:
with self._lock:
self._panel_open = _open
def __del__(self):
self.stop()
@@ -1,91 +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.
Sentinel tests for the capabilities payload contract. PROTOCOL_VERSION is the
wire-protocol version observable by the dashboard; bumping it is a breaking
change and must be intentional. KNOWN_PROTOCOL_VERSIONS pins the set we
explicitly support — when the constant is bumped, this list must be edited in
the same commit so the bump shows up in code review.
"""
from __future__ import annotations
from openpilot.sunnypilot.sunnylink.capabilities import (
CAPABILITY_DEFAULTS,
CAPABILITY_FIELDS,
CAPABILITY_LABELS,
PROTOCOL_VERSION,
generate_capabilities,
)
from openpilot.common.test import OpenpilotTestCase
KNOWN_PROTOCOL_VERSIONS = (1,)
LATEST_KNOWN = max(KNOWN_PROTOCOL_VERSIONS)
def caps():
return generate_capabilities()
class TestProtocolVersion(OpenpilotTestCase):
def test_protocol_version_in_capability_fields(self):
assert "protocol_version" in CAPABILITY_FIELDS
def test_protocol_version_has_label(self):
assert "protocol_version" in CAPABILITY_LABELS
def test_protocol_version_default_is_set(self):
assert CAPABILITY_DEFAULTS.get("protocol_version") == PROTOCOL_VERSION
def test_protocol_version_emitted(self, caps):
assert "protocol_version" in caps
assert isinstance(caps["protocol_version"], int)
assert caps["protocol_version"] >= 1
def test_protocol_version_matches_constant(self, caps):
assert caps["protocol_version"] == PROTOCOL_VERSION
def test_protocol_version_is_known(self):
"""Sentinel against accidental bumps. Edit KNOWN_PROTOCOL_VERSIONS if intentional."""
assert PROTOCOL_VERSION in KNOWN_PROTOCOL_VERSIONS, (
f"PROTOCOL_VERSION={PROTOCOL_VERSION} is not in KNOWN_PROTOCOL_VERSIONS={KNOWN_PROTOCOL_VERSIONS}. " +
"If this bump is intentional, add it to KNOWN_PROTOCOL_VERSIONS."
)
def test_protocol_version_matches_latest_known(self):
assert PROTOCOL_VERSION == LATEST_KNOWN, (
"Test invariant: PROTOCOL_VERSION must equal max(KNOWN_PROTOCOL_VERSIONS)."
)
class TestOpaquePerBrandFlags(OpenpilotTestCase):
def test_subaru_has_sng_field_present(self):
assert "subaru_has_sng" in CAPABILITY_FIELDS
def test_hyundai_alpha_long_available_field_present(self):
assert "hyundai_alpha_long_available" in CAPABILITY_FIELDS
def test_subaru_has_sng_default_false(self, caps):
assert caps["subaru_has_sng"] is False
def test_hyundai_alpha_long_available_default_false(self, caps):
assert caps["hyundai_alpha_long_available"] is False
class TestCapabilitiesShape(OpenpilotTestCase):
def test_all_fields_present(self, caps):
for field in CAPABILITY_FIELDS:
assert field in caps, f"capabilities missing {field}"
def test_all_fields_have_labels(self):
for field in CAPABILITY_FIELDS:
assert field in CAPABILITY_LABELS, f"CAPABILITY_LABELS missing {field}"
def test_string_defaults_are_strings(self, caps):
assert isinstance(caps["brand"], str)
assert isinstance(caps["steer_control_type"], str)
assert isinstance(caps["device_type"], str)
@@ -1,192 +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.
Tests for the settings_ui_src/ -> settings_ui.json compiler. Covers:
- Roundtrip: compiled output matches the checked-in settings_ui.json
- $ref macro resolution semantics (list-splice, scalar-substitute, depth, cycles)
- Per-page tree integrity (every page has id; vehicle page emits to vehicle_settings)
Does not cover device-side generator (test_settings_schema.py) or per-bug
regression (test_settings_changes.py); those continue to validate the
compiled output once the compiler has produced it.
"""
from __future__ import annotations
import difflib
import json
import os
import yaml
from openpilot.sunnypilot.sunnylink.tools.compile_settings_ui import (
CompileError,
DEFAULT_OUT,
DEFAULT_SRC,
_resolve_refs,
compile_schema,
)
from openpilot.common.test import OpenpilotTestCase
def compiled() -> dict:
return compile_schema(DEFAULT_SRC)
def committed() -> dict:
with open(DEFAULT_OUT) as f:
return json.load(f)
class TestRoundtrip(OpenpilotTestCase):
def test_compiled_matches_committed(self, compiled, committed):
"""Compiled output must match the checked-in JSON."""
if compiled == committed:
return
diff = "\n".join(difflib.unified_diff(
json.dumps(committed, indent=2).splitlines(),
json.dumps(compiled, indent=2).splitlines(),
fromfile="settings_ui.json (committed)",
tofile="settings_ui.json (freshly compiled)",
lineterm="",
))
self.fail(f"settings_ui.json schema mismatch — run compile_settings_ui.py\n\n{diff}")
def test_committed_file_is_canonical(self):
"""Compiled output must byte-match the checked-in file (including trailing newline).
Drift means someone edited settings_ui.json by hand instead of editing settings_ui_src/."""
schema = compile_schema(DEFAULT_SRC)
rendered = json.dumps(schema, indent=2) + "\n"
with open(DEFAULT_OUT) as f:
current = f.read()
if current == rendered:
return
diff = "\n".join(difflib.unified_diff(
current.splitlines(),
rendered.splitlines(),
fromfile="settings_ui.json (on disk)",
tofile="settings_ui.json (freshly compiled)",
lineterm="",
))
self.fail(f"settings_ui.json out of sync — run compile_settings_ui.py\n\n{diff}")
class TestRefResolution(OpenpilotTestCase):
def test_list_context_splices(self):
macros = {"a": [{"type": "offroad_only"}], "b": [{"type": "not_engaged"}]}
out = _resolve_refs([{"$ref": "#/macros/a"}, {"$ref": "#/macros/b"}], macros)
assert out == [{"type": "offroad_only"}, {"type": "not_engaged"}]
def test_scalar_context_substitutes(self):
macros = {"x": {"type": "capability", "field": "brand", "equals": "tesla"}}
out = _resolve_refs({"condition": {"$ref": "#/macros/x"}}, macros)
assert out == {"condition": {"type": "capability", "field": "brand", "equals": "tesla"}}
def test_chained_ref_resolves(self):
macros = {
"leaf": [{"type": "offroad_only"}],
"middle": [{"$ref": "#/macros/leaf"}],
}
out = _resolve_refs([{"$ref": "#/macros/middle"}], macros)
assert out == [{"type": "offroad_only"}]
def test_unknown_macro_raises(self):
with self.assertRaisesRegex(CompileError, "unknown macro"):
_resolve_refs([{"$ref": "#/macros/missing"}], {})
def test_cycle_raises(self):
macros = {"a": [{"$ref": "#/macros/b"}], "b": [{"$ref": "#/macros/a"}]}
with self.assertRaisesRegex(CompileError, "cycle"):
_resolve_refs([{"$ref": "#/macros/a"}], macros)
def test_depth_limit(self):
# Depth 4 chain should fail (limit is 3).
macros = {
"l1": [{"$ref": "#/macros/l2"}],
"l2": [{"$ref": "#/macros/l3"}],
"l3": [{"$ref": "#/macros/l4"}],
"l4": [{"type": "offroad_only"}],
}
with self.assertRaisesRegex(CompileError, "depth"):
_resolve_refs([{"$ref": "#/macros/l1"}], macros)
def test_invalid_ref_scheme(self):
with self.assertRaisesRegex(CompileError, "unsupported"):
_resolve_refs([{"$ref": "https://example.com/x"}], {})
def test_scalar_macro_in_list_context_raises(self):
macros = {"x": {"type": "offroad_only"}} # macro is a single rule (dict), not a list
with self.assertRaisesRegex(CompileError, "must resolve to a list"):
_resolve_refs([{"$ref": "#/macros/x"}], macros)
class TestCompiledShape(OpenpilotTestCase):
def test_panels_present(self, compiled):
assert isinstance(compiled["panels"], list)
assert len(compiled["panels"]) == 9
panel_ids = {p["id"] for p in compiled["panels"]}
assert {"steering", "cruise", "display", "visuals", "toggles",
"device", "software", "developer", "models"} <= panel_ids
def test_vehicle_settings_consistent_shape(self, compiled):
"""Each brand in vehicle_settings must have {title, description, items}."""
for brand, data in compiled["vehicle_settings"].items():
assert isinstance(data, dict), f"{brand}: expected object, got {type(data).__name__}"
assert "title" in data, f"{brand}: missing title"
assert "description" in data, f"{brand}: missing description"
assert "items" in data, f"{brand}: missing items"
def test_no_dangling_refs_after_compile(self, compiled):
"""All $ref objects must be resolved during compilation."""
def walk(node):
if isinstance(node, dict):
if "$ref" in node:
self.fail(f"unresolved $ref: {node}")
for v in node.values():
walk(v)
elif isinstance(node, list):
for x in node:
walk(x)
walk(compiled)
class TestSourceTreeIntegrity(OpenpilotTestCase):
def test_macros_yaml_well_formed(self):
with open(os.path.join(DEFAULT_SRC, "_macros.yaml")) as f:
doc = yaml.safe_load(f)
assert "macros" in doc
for name, body in doc["macros"].items():
assert name.replace("_", "").isalnum(), f"macro name '{name}' must be alphanumeric_"
assert body, f"macro '{name}' empty"
def test_pages_dir_well_formed(self):
pages_dir = os.path.join(DEFAULT_SRC, "pages")
assert os.path.isdir(pages_dir), "pages/ directory missing"
page_files = sorted(fn for fn in os.listdir(pages_dir) if fn.endswith(".yaml"))
# 9 panels + 1 vehicle = 10
assert len(page_files) == 10, f"expected 10 pages, found {len(page_files)}: {page_files}"
def test_every_page_has_id(self):
pages_dir = os.path.join(DEFAULT_SRC, "pages")
for fn in sorted(os.listdir(pages_dir)):
if not fn.endswith(".yaml"):
continue
path = os.path.join(pages_dir, fn)
with open(path) as f:
doc = yaml.safe_load(f)
assert isinstance(doc, dict), f"{path}: top-level must be a mapping"
assert "id" in doc, f"{path}: page missing 'id'"
# File basename should match page id (modulo .yaml extension).
expected_id = os.path.splitext(fn)[0]
assert doc["id"] == expected_id, (
f"{path}: page id '{doc['id']}' must match filename '{expected_id}'"
)
def test_vehicle_page_kind(self):
"""vehicle.yaml must declare kind: vehicle so it routes to vehicle_settings."""
path = os.path.join(DEFAULT_SRC, "pages", "vehicle.yaml")
with open(path) as f:
doc = yaml.safe_load(f)
assert doc.get("kind") == "vehicle", "vehicle.yaml must declare kind: vehicle"
@@ -1,217 +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.
Per-bug regression tests for the Raylib-vs-schema parity audit. Each test
isolates one of the gating bugs that the design-overhaul branch fixes so a
future regression is loud and obvious. These tests are intentionally narrow
and additive — they do not replace the broader test_settings_schema.py.
"""
from __future__ import annotations
import json
import os
from typing import Any
from openpilot.common.parameterized import parameterized
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import (
DEFINITION_PATH,
TORQUE_VERSIONS_PATH,
_build_torque_options,
_load_torque_versions,
generate_schema,
)
from openpilot.common.test import OpenpilotTestCase
SCHEMA_VALIDATOR_PATH = os.path.join(os.path.dirname(DEFINITION_PATH), "settings_ui.schema.json")
def _walk_items(schema: dict[str, Any]):
"""Yield every item dict from the schema."""
def _yield(item: dict[str, Any]):
yield item
for sub in item.get("sub_items", []):
yield from _yield(sub)
for panel in schema.get("panels", []):
for section in panel.get("sections", []):
for item in section.get("items", []):
yield from _yield(item)
for sp in section.get("sub_panels", []):
for item in sp.get("items", []):
yield from _yield(item)
for item in panel.get("items", []):
yield from _yield(item)
for sp in panel.get("sub_panels", []):
for item in sp.get("items", []):
yield from _yield(item)
for brand in schema.get("vehicle_settings", {}).values():
items = brand.get("items", []) if isinstance(brand, dict) else brand
for item in items:
yield from _yield(item)
def _find_item(schema: dict[str, Any], key: str) -> dict[str, Any] | None:
for item in _walk_items(schema):
if item.get("key") == key:
return item
return None
def _find_section(schema: dict[str, Any], panel_id: str, section_id: str) -> dict[str, Any] | None:
for panel in schema.get("panels", []):
if panel.get("id") != panel_id:
continue
for section in panel.get("sections", []):
if section.get("id") == section_id:
return section
return None
def _flatten_rule_types(rules: list[dict[str, Any]] | None) -> set[str]:
out: set[str] = set()
def _walk(rule: dict[str, Any]) -> None:
out.add(rule.get("type", ""))
if rule.get("type") == "not" and "condition" in rule:
_walk(rule["condition"])
elif rule.get("type") in ("any", "all"):
for c in rule.get("conditions", []):
_walk(c)
for rule in rules or []:
_walk(rule)
return out
def _references_capability_field(rules: list[dict[str, Any]] | None, field: str) -> bool:
found = False
def _walk(rule: dict[str, Any]) -> None:
nonlocal found
if rule.get("type") == "capability" and rule.get("field") == field:
found = True
elif rule.get("type") == "not" and "condition" in rule:
_walk(rule["condition"])
elif rule.get("type") in ("any", "all"):
for c in rule.get("conditions", []):
_walk(c)
for rule in rules or []:
_walk(rule)
return found
def schema():
return generate_schema()
class TestMadsBrandGates(OpenpilotTestCase):
def test_mads_main_cruise_has_brand_gate(self, schema):
"""MadsMainCruiseAllowed must gate on brand and tesla_has_vehicle_bus."""
item = _find_item(schema, "MadsMainCruiseAllowed")
assert item is not None
assert _references_capability_field(item.get("enablement"), "brand")
assert _references_capability_field(item.get("enablement"), "tesla_has_vehicle_bus")
def test_mads_unified_engagement_has_brand_gate(self, schema):
"""MadsUnifiedEngagementMode must mirror MadsMainCruiseAllowed brand-gate."""
item = _find_item(schema, "MadsUnifiedEngagementMode")
assert item is not None
assert _references_capability_field(item.get("enablement"), "brand")
assert _references_capability_field(item.get("enablement"), "tesla_has_vehicle_bus")
class TestTestManeuversSection(OpenpilotTestCase):
def test_lateral_maneuver_mode_in_test_maneuvers(self, schema):
section = _find_section(schema, "developer", "test_maneuvers")
assert section is not None, "developer.test_maneuvers section missing"
keys = {item["key"] for item in section.get("items", [])}
assert "LateralManeuverMode" in keys
assert "LongitudinalManeuverMode" in keys
def test_test_maneuvers_section_requires_attestation(self, schema):
section = _find_section(schema, "developer", "test_maneuvers")
assert section is not None
assert section.get("attestation_required") is True
def test_test_maneuvers_section_visibility_gate(self, schema):
section = _find_section(schema, "developer", "test_maneuvers")
assert section is not None
visibility = section.get("visibility")
assert visibility, "test_maneuvers must have visibility gate"
vis_refs = json.dumps(visibility)
assert "is_development" in vis_refs
assert "is_sp_release" in vis_refs
enablement = section.get("enablement") or []
enable_refs = json.dumps(enablement)
assert "ShowAdvancedControls" in enable_refs, \
"test_maneuvers must gate ShowAdvancedControls via enablement"
class TestValidator(OpenpilotTestCase):
def test_validator_accepts_real_json(self):
"""settings_ui.json validates against settings_ui.schema.json."""
try:
import jsonschema
except ImportError:
self.skipTest("jsonschema not installed")
with open(DEFINITION_PATH) as f:
data = json.load(f)
with open(SCHEMA_VALIDATOR_PATH) as f:
validator = json.load(f)
jsonschema.validate(instance=data, schema=validator)
class TestTorqueOptionGeneration(OpenpilotTestCase):
def test_torque_versions_match_generated_options(self, schema):
versions = _load_torque_versions()
assert versions, "latcontrol_torque_versions.json must have at least one version"
expected = _build_torque_options(versions)
item = _find_item(schema, "TorqueControlTune")
assert item is not None, "TorqueControlTune item must be present"
assert item.get("options") == expected
def test_torque_versions_path_resolves(self):
assert os.path.exists(TORQUE_VERSIONS_PATH), (
f"latcontrol_torque_versions.json not found at {TORQUE_VERSIONS_PATH}"
)
class TestReleaseBranchGates(OpenpilotTestCase):
@parameterized.expand([
"EnableGithubRunner",
"QuickBootToggle",
], names=["key"])
def test_sp_dev_items_gate_on_is_sp_release(self, schema, key):
"""sunnypilot dev items must hide on sunnypilot release branches (is_sp_release gate)."""
item = _find_item(schema, key)
assert item is not None, f"{key} not found in schema"
rules = (item.get("visibility") or []) + (item.get("enablement") or [])
assert _references_capability_field(rules, "is_sp_release"), f"{key} missing is_sp_release gate"
class TestSpuriousOffroadGatesDropped(OpenpilotTestCase):
def test_disengage_on_accelerator_has_no_offroad_only(self, schema):
item = _find_item(schema, "DisengageOnAccelerator")
assert item is not None
assert "offroad_only" not in _flatten_rule_types(item.get("enablement"))
class TestNotEngagedReplacement(OpenpilotTestCase):
@parameterized.expand([
"AlphaLongitudinalEnabled",
"ToyotaEnforceStockLongitudinal",
"ToyotaStopAndGoHack",
], names=["key"])
def test_offroad_only_replaced_with_not_engaged(self, schema, key):
"""These items should use not_engaged, not offroad_only."""
item = _find_item(schema, key)
assert item is not None, f"{key} not found"
rule_types = _flatten_rule_types(item.get("enablement"))
assert "offroad_only" not in rule_types, f"{key} still uses offroad_only"
assert "not_engaged" in rule_types, f"{key} missing not_engaged"
@@ -1,357 +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 json
from openpilot.common.params import Params
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import (
SCHEMA_VERSION,
generate_schema,
generate_schema_json,
collect_all_keys,
collect_capability_refs,
)
from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS
from openpilot.common.test import OpenpilotTestCase
VALID_WIDGET_TYPES = {"toggle", "option", "multiple_button", "button", "info"}
VALID_RULE_TYPES = {"offroad_only", "not_engaged", "capability", "param", "param_compare", "not", "any", "all"}
VALID_COMPARE_OPS = {">", "<", ">=", "<="}
MAX_ALLOWED_MISSING_TITLES = 0 # All items must have titles (metadata is inline in settings_ui.json)
def _iter_panel_items(panel: dict):
"""Yield top-level items from a panel (non-recursive)."""
for item in panel.get("items", []):
yield item
for sp in panel.get("sub_panels", []):
for item in sp.get("items", []):
yield item
for section in panel.get("sections", []):
for item in section.get("items", []):
yield item
for sp in section.get("sub_panels", []):
for item in sp.get("items", []):
yield item
def _iter_all_sub_panels(panel: dict):
"""Yield all sub_panels from a panel and its sections."""
yield from panel.get("sub_panels", [])
for section in panel.get("sections", []):
yield from section.get("sub_panels", [])
def _brand_items(brand_data) -> list[dict]:
"""Extract items from vehicle_settings[brand] (handles dict or list)."""
if isinstance(brand_data, dict):
return brand_data.get("items", [])
if isinstance(brand_data, list):
return brand_data
return []
def schema():
return generate_schema()
def all_param_keys():
"""All keys registered in the device param store."""
return {k.decode("utf-8") for k in Params().all_keys()}
class TestSchemaStructure(OpenpilotTestCase):
def test_schema_is_valid_json(self):
"""Schema serializes to valid JSON."""
raw = generate_schema_json()
parsed = json.loads(raw)
assert isinstance(parsed, dict)
def test_has_required_top_level_fields(self, schema):
assert "schema_version" in schema
assert schema["schema_version"] == SCHEMA_VERSION
assert "generated_at" in schema
assert "panels" in schema
assert "vehicle_settings" in schema
assert "capability_fields" in schema
def test_panels_are_list(self, schema):
assert isinstance(schema["panels"], list)
assert len(schema["panels"]) > 0
def test_all_panels_have_required_fields(self, schema):
for panel in schema["panels"]:
assert "id" in panel, f"Panel missing 'id': {panel}"
assert "label" in panel, f"Panel {panel.get('id')} missing 'label'"
assert "order" in panel, f"Panel {panel.get('id')} missing 'order'"
has_sections = "sections" in panel
has_items = "items" in panel
assert has_sections or has_items, \
f"Panel {panel['id']} must have 'sections' or 'items'"
if has_sections:
assert isinstance(panel["sections"], list)
for sec in panel["sections"]:
assert "id" in sec, f"Section in panel {panel['id']} missing 'id'"
assert "items" in sec, f"Section {panel['id']}.{sec.get('id')} missing 'items'"
assert isinstance(sec["items"], list)
if has_items:
assert isinstance(panel["items"], list)
def test_all_items_have_key_and_widget(self, schema):
for panel in schema["panels"]:
for item in _iter_panel_items(panel):
assert "key" in item, f"Item in panel {panel['id']} missing 'key'"
assert "widget" in item, f"Item {item.get('key')} missing 'widget'"
assert item["widget"] in VALID_WIDGET_TYPES, \
f"Item {item['key']} has invalid widget type: {item['widget']}"
def test_sub_panel_items_have_key_and_widget(self, schema):
for panel in schema["panels"]:
for sp in _iter_all_sub_panels(panel):
assert "id" in sp
assert "items" in sp
for item in sp["items"]:
assert "key" in item
assert "widget" in item
assert item["widget"] in VALID_WIDGET_TYPES
def test_vehicle_settings_structure(self, schema):
vs = schema["vehicle_settings"]
assert isinstance(vs, dict)
for brand, data in vs.items():
assert isinstance(brand, str)
assert isinstance(data, dict), \
f"vehicle_settings[{brand}] must be a dict {{title, items, ...}}"
assert "items" in data, f"vehicle_settings[{brand}] missing 'items'"
assert isinstance(data["items"], list)
for item in data["items"]:
assert "key" in item, f"Vehicle item for {brand} missing 'key'"
assert "widget" in item, f"Vehicle item {item.get('key')} missing 'widget'"
def test_no_duplicate_keys_across_panels(self, schema):
"""Param keys should appear in at most one panel."""
seen: dict[str, str] = {} # key -> panel_id
for panel in schema["panels"]:
for item in _iter_panel_items(panel):
key = item["key"]
if key in seen:
self.fail(f"Key '{key}' appears in both panel '{seen[key]}' and '{panel['id']}'")
seen[key] = panel["id"]
for sub in item.get("sub_items", []):
sub_key = sub["key"]
if sub_key in seen:
self.fail(f"Sub-item key '{sub_key}' appears in both '{seen[sub_key]}' and '{panel['id']}'")
seen[sub_key] = panel["id"]
class TestSchemaCoverage(OpenpilotTestCase):
def test_all_schema_keys_exist_in_params(self, schema, all_param_keys):
"""Schema keys must exist in Params().all_keys()."""
schema_keys = collect_all_keys(schema)
missing = schema_keys - all_param_keys
assert not missing, f"Schema references keys not in Params: {missing}"
def test_all_capability_fields_are_declared(self, schema):
"""Capability fields used in rules must be declared."""
declared = set(schema["capability_fields"])
referenced = collect_capability_refs(schema)
undeclared = referenced - declared
assert not undeclared, f"Rules reference undeclared capability fields: {undeclared}"
def test_capability_fields_match_constant(self, schema):
"""Schema capability_fields must match CAPABILITY_FIELDS constant."""
assert set(schema["capability_fields"]) == set(CAPABILITY_FIELDS)
class TestRuleWellFormedness(OpenpilotTestCase):
def _validate_rule(self, rule: dict, context: str = ""):
"""Recursively validate a single rule dict."""
assert "type" in rule, f"Rule missing 'type' in {context}"
rtype = rule["type"]
assert rtype in VALID_RULE_TYPES, f"Invalid rule type '{rtype}' in {context}"
if rtype == "capability":
assert "field" in rule, f"Capability rule missing 'field' in {context}"
assert "equals" in rule, f"Capability rule missing 'equals' in {context}"
elif rtype == "param":
assert "key" in rule, f"Param rule missing 'key' in {context}"
assert "equals" in rule, f"Param rule missing 'equals' in {context}"
elif rtype == "param_compare":
assert "key" in rule, f"Param compare rule missing 'key' in {context}"
assert "op" in rule, f"Param compare rule missing 'op' in {context}"
assert rule["op"] in VALID_COMPARE_OPS, f"Invalid op '{rule['op']}' in {context}"
assert "value" in rule, f"Param compare rule missing 'value' in {context}"
elif rtype == "not":
assert "condition" in rule, f"Not rule missing 'condition' in {context}"
self._validate_rule(rule["condition"], context=f"{context} > not")
elif rtype in ("any", "all"):
assert "conditions" in rule, f"{rtype} rule missing 'conditions' in {context}"
assert isinstance(rule["conditions"], list)
for c in rule["conditions"]:
self._validate_rule(c, context=f"{context} > {rtype}")
def _validate_items(self, items: list[dict], context: str):
for item in items:
key = item.get("key", "unknown")
for rules_field in ("visibility", "enablement"):
rules = item.get(rules_field)
if rules:
assert isinstance(rules, list), f"{key}.{rules_field} must be a list"
for rule in rules:
self._validate_rule(rule, context=f"{context}.{key}.{rules_field}")
for sub in item.get("sub_items", []):
self._validate_items([sub], context=f"{context}.{key}")
def _validate_section_rules(self, section: dict, context: str):
for rules_field in ("visibility", "enablement"):
rules = section.get(rules_field) or []
for rule in rules:
self._validate_rule(rule, context=f"{context}.{rules_field}")
def test_all_panel_rules_well_formed(self, schema):
for panel in schema["panels"]:
self._validate_items(list(_iter_panel_items(panel)), context=f"panel:{panel['id']}")
for sp in _iter_all_sub_panels(panel):
self._validate_items(sp["items"], context=f"subpanel:{sp['id']}")
for section in panel.get("sections", []):
self._validate_section_rules(section, context=f"section:{panel['id']}.{section['id']}")
def test_all_vehicle_rules_well_formed(self, schema):
for brand, data in schema["vehicle_settings"].items():
self._validate_items(_brand_items(data), context=f"vehicle:{brand}")
def test_no_self_referencing_visibility(self, schema):
"""An item's visibility/enablement rules should not depend on its own key."""
def _check_self_ref(item: dict, rules_field: str):
key = item.get("key")
for rule in item.get(rules_field, []):
if rule.get("type") == "param" and rule.get("key") == key:
self.fail(f"Item {key} has self-referencing {rules_field} rule")
for panel in schema["panels"]:
for item in _iter_panel_items(panel):
_check_self_ref(item, "visibility")
_check_self_ref(item, "enablement")
for brand_data in schema.get("vehicle_settings", {}).values():
for item in _brand_items(brand_data):
_check_self_ref(item, "visibility")
_check_self_ref(item, "enablement")
class TestKnownPanels(OpenpilotTestCase):
def test_expected_panels_exist(self, schema):
panel_ids = {p["id"] for p in schema["panels"]}
expected = {"steering", "cruise", "display", "visuals", "device", "software", "developer"}
assert expected.issubset(panel_ids), f"Missing panels: {expected - panel_ids}"
def test_mads_sub_panel_exists(self, schema):
steering = next(p for p in schema["panels"] if p["id"] == "steering")
sub_ids = {sp["id"] for sp in _iter_all_sub_panels(steering)}
assert "mads_settings" in sub_ids
def test_mutual_exclusion_torque_nnlc(self, schema):
"""EnforceTorqueControl, EnhancedLatAccel, and NNLC must reference each other in enablement."""
torque = nnlc = enhanced = None
for panel in schema["panels"]:
for item in _iter_panel_items(panel):
if item["key"] == "EnforceTorqueControl":
torque = item
elif item["key"] == "NeuralNetworkLateralControl":
nnlc = item
elif item["key"] == "LateralJerkTorqueController":
enhanced = item
assert torque is not None, "EnforceTorqueControl item missing"
assert nnlc is not None, "NeuralNetworkLateralControl item missing"
assert enhanced is not None, "LateralJerkTorqueController item missing"
torque_enable_keys = {r.get("key") for r in torque.get("enablement", []) if r.get("type") == "param"}
assert "NeuralNetworkLateralControl" in torque_enable_keys
nnlc_enable_keys = {r.get("key") for r in nnlc.get("enablement", []) if r.get("type") == "param"}
assert "EnforceTorqueControl" in nnlc_enable_keys
assert "LateralJerkTorqueController" in nnlc_enable_keys
enhanced_enable_keys = {r.get("key") for r in enhanced.get("enablement", []) if r.get("type") == "param"}
assert "NeuralNetworkLateralControl" in enhanced_enable_keys
class TestKnownVehicleSettings(OpenpilotTestCase):
def test_hyundai_has_longitudinal_tuning(self, schema):
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("hyundai"))}
assert "HyundaiLongitudinalTuning" in keys
def test_toyota_has_enforce_stock_and_stop_go(self, schema):
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("toyota"))}
assert "ToyotaEnforceStockLongitudinal" in keys
assert "ToyotaStopAndGoHack" in keys
def test_tesla_has_coop_steering(self, schema):
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("tesla"))}
assert "TeslaCoopSteering" in keys
def test_subaru_has_stop_and_go(self, schema):
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("subaru"))}
assert "SubaruStopAndGo" in keys
assert "SubaruStopAndGoManualParkingBrake" in keys
class TestItemCompleteness(OpenpilotTestCase):
def _collect_all_items(self, schema):
"""Collect all items and sub_items from panels and vehicle_settings."""
items = []
for panel in schema["panels"]:
for item in _iter_panel_items(panel):
items.append(item)
for sub in item.get("sub_items", []):
items.append(sub)
for brand_data in schema.get("vehicle_settings", {}).values():
for item in _brand_items(brand_data):
items.append(item)
for sub in item.get("sub_items", []):
items.append(sub)
return items
def test_all_items_have_titles(self, schema):
"""All items must have titles."""
missing = [i["key"] for i in self._collect_all_items(schema) if "title" not in i]
if len(missing) > MAX_ALLOWED_MISSING_TITLES:
self.fail(f"Items without titles ({len(missing)}): {missing[:10]}")
def test_no_default_titles(self, schema):
"""Item titles must differ from keys."""
defaults = [i["key"] for i in self._collect_all_items(schema) if i.get("title") == i["key"]]
assert not defaults, f"Items with default titles (title == key): {defaults}"
def test_options_structure(self, schema):
"""Options must be a list of {value, label} dicts."""
for item in self._collect_all_items(schema):
opts = item.get("options")
if opts is None:
continue
assert isinstance(opts, list), f"{item['key']}: options must be a list"
for opt in opts:
assert isinstance(opt, dict), f"{item['key']}: each option must be a dict"
assert "value" in opt, f"{item['key']}: option missing 'value': {opt}"
assert "label" in opt, f"{item['key']}: option missing 'label': {opt}"
def test_numeric_constraints(self, schema):
"""If any of min/max/step is present, all three must be, and min < max."""
for item in self._collect_all_items(schema):
has_min = "min" in item
has_max = "max" in item
has_step = "step" in item
if has_min or has_max or has_step:
assert has_min and has_max and has_step, \
f"{item['key']}: must have all of min/max/step or none"
assert item["min"] < item["max"], \
f"{item['key']}: min ({item['min']}) must be < max ({item['max']})"
def test_known_param_has_options(self, schema):
"""LongitudinalPersonality should have 3 options."""
cruise = next(p for p in schema["panels"] if p["id"] == "cruise")
lp = next((i for i in _iter_panel_items(cruise) if i["key"] == "LongitudinalPersonality"), None)
assert lp is not None
assert "options" in lp
assert len(lp["options"]) == 3
@@ -1,211 +0,0 @@
#!/usr/bin/env python3
"""
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.
Scans all per-page YAML files in settings_ui_src/pages/ and replaces inlined
rule blocks that exactly match a macro definition in _macros.yaml with $ref
references.
Match policy:
- Whole-list match (exact): replace the entire list with [{$ref}].
- Single-rule match: any individual rule whose canonical form equals macro[0]
(when macro is exactly one rule) is replaced with {$ref}.
Re-run is idempotent (already-substituted $refs are skipped).
Usage:
python apply_macros.py [--src DIR] [--dry-run]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import yaml
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
class _BlockDumper(yaml.SafeDumper):
pass
def _represent_dict(dumper, data):
return dumper.represent_mapping("tag:yaml.org,2002:map", data.items(), flow_style=False)
def _represent_list(dumper, data):
flow = all(not isinstance(x, (dict, list)) for x in data) and len(data) <= 8
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=flow)
_BlockDumper.add_representer(dict, _represent_dict)
_BlockDumper.add_representer(list, _represent_list)
def _dump_yaml(data) -> str:
return yaml.dump(data, Dumper=_BlockDumper, sort_keys=False, allow_unicode=True, width=120)
def _canon(node) -> str:
return json.dumps(node, sort_keys=True, separators=(",", ":"))
def _is_ref(node) -> bool:
return isinstance(node, dict) and len(node) == 1 and "$ref" in node
def _make_ref(name: str) -> dict:
return {"$ref": f"#/macros/{name}"}
def _substitute_rules(rules: list, whole_list: dict, single_rule: dict) -> list:
if not isinstance(rules, list) or not rules:
return rules
full_canon = _canon(rules)
if full_canon in whole_list:
return [_make_ref(whole_list[full_canon])]
out = []
for r in rules:
if _is_ref(r):
out.append(r)
continue
rc = _canon(r)
if rc in single_rule:
out.append(_make_ref(single_rule[rc]))
else:
out.append(r)
return out
def _walk_item(item: dict, whole_list: dict, single_rule: dict) -> bool:
changed = False
for ctx in ("visibility", "enablement"):
if ctx in item:
new = _substitute_rules(item[ctx], whole_list, single_rule)
if new != item[ctx]:
item[ctx] = new
changed = True
if "options" in item:
for opt in item["options"]:
if not isinstance(opt, dict):
continue
for ctx in ("visibility", "enablement"):
if ctx in opt:
new = _substitute_rules(opt[ctx], whole_list, single_rule)
if new != opt[ctx]:
opt[ctx] = new
changed = True
if "sub_items" in item:
for sub in item["sub_items"]:
if _walk_item(sub, whole_list, single_rule):
changed = True
return changed
def _walk_page(page: dict, whole_list: dict, single_rule: dict) -> bool:
changed = False
for sec in page.get("sections", []) or []:
for ctx in ("visibility", "enablement"):
if ctx in sec:
new = _substitute_rules(sec[ctx], whole_list, single_rule)
if new != sec[ctx]:
sec[ctx] = new
changed = True
for it in sec.get("items", []) or []:
if _walk_item(it, whole_list, single_rule):
changed = True
for sp in sec.get("sub_panels", []) or []:
for it in sp.get("items", []) or []:
if _walk_item(it, whole_list, single_rule):
changed = True
for it in page.get("items", []) or []:
if _walk_item(it, whole_list, single_rule):
changed = True
for sp in page.get("sub_panels", []) or []:
for it in sp.get("items", []) or []:
if _walk_item(it, whole_list, single_rule):
changed = True
return changed
def _build_macro_indices(macros: dict) -> tuple[dict, dict]:
whole_list = {}
single_rule = {}
for name, body in macros.items():
if isinstance(body, list):
whole_list[_canon(body)] = name
if len(body) == 1 and isinstance(body[0], dict):
single_rule[_canon(body[0])] = name
elif isinstance(body, dict):
single_rule[_canon(body)] = name
return whole_list, single_rule
def _read_yaml_with_header(path: str) -> tuple[list[str], dict]:
with open(path) as f:
text = f.read()
header_lines = []
for line in text.splitlines(keepends=True):
if line.startswith("#"):
header_lines.append(line)
elif line.strip() == "":
header_lines.append(line)
else:
break
doc = yaml.safe_load(text) or {}
return header_lines, doc
def _write_yaml_with_header(path: str, header: list[str], doc: dict) -> None:
with open(path, "w") as f:
if header:
# Keep at most one trailing blank line in header
while len(header) > 1 and header[-1].strip() == "" and header[-2].strip() == "":
header.pop()
for line in header:
f.write(line)
f.write(_dump_yaml(doc))
def apply(src_dir: str, dry_run: bool) -> int:
with open(os.path.join(src_dir, "_macros.yaml")) as f:
macros_doc = yaml.safe_load(f) or {}
macros = (macros_doc.get("macros") or {}) if isinstance(macros_doc, dict) else {}
whole_list, single_rule = _build_macro_indices(macros)
changed_files = 0
pages_dir = os.path.join(src_dir, "pages")
if os.path.isdir(pages_dir):
for fn in sorted(os.listdir(pages_dir)):
if not fn.endswith((".yaml", ".yml")) or fn.startswith("_"):
continue
path = os.path.join(pages_dir, fn)
header, doc = _read_yaml_with_header(path)
if _walk_page(doc, whole_list, single_rule):
changed_files += 1
if not dry_run:
_write_yaml_with_header(path, header, doc)
else:
print(f"would-rewrite: {path}")
print(f"{'Would rewrite' if dry_run else 'Rewrote'} {changed_files} files")
return 0
def _main() -> int:
parser = argparse.ArgumentParser(description="Substitute inlined rule blocks with $ref macros across pages/.")
parser.add_argument("--src", default=DEFAULT_SRC)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
return apply(args.src, args.dry_run)
if __name__ == "__main__":
sys.exit(_main())
@@ -1,300 +0,0 @@
#!/usr/bin/env python3
"""
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.
Reads settings_ui_src/ (the dev-friendly authoring tree) and emits the
canonical settings_ui.json that the device generator + frontend consume.
Source layout:
_macros.yaml # named rule fragments
pages/<page_id>.yaml # one file per panel/page
pages/vehicle.yaml # special: emits vehicle_settings
Each page.yaml contains the full panel: metadata + sections + items + sub_panels
inline. Sub-panels are nested inside the section they belong to. Items appear
in the order written in the file.
Macro references use JSON-Schema-style $ref pointers:
enablement:
- {$ref: "#/macros/offroad"}
- {$ref: "#/macros/mads_full_platforms"}
Macros may reference other macros (max depth 3). Cycles raise an error.
Usage:
python compile_settings_ui.py [--src DIR] [--out PATH] [--check]
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import sys
import yaml
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
DEFAULT_OUT = os.path.join(DIR, "settings_ui.json")
SCHEMA_VERSION = "1.0"
MAX_MACRO_DEPTH = 3
class CompileError(Exception):
pass
def _load_yaml(path: str):
with open(path) as f:
return yaml.safe_load(f) or {}
def _is_ref(node) -> bool:
return isinstance(node, dict) and len(node) == 1 and "$ref" in node
def _ref_name(node: dict) -> str:
ref = node["$ref"]
if not isinstance(ref, str) or not ref.startswith("#/macros/"):
raise CompileError(f"unsupported $ref: {ref!r} (must start with '#/macros/')")
return ref[len("#/macros/"):]
def _resolve_refs(node, macros: dict, visiting: tuple[str, ...] = ()):
"""Resolve $ref nodes against macros. Recursive; depth-limited; cycle-safe.
- $ref in list-context: macro's list spliced into parent list.
- $ref in scalar-context (object value): macro's value substitutes.
- Non-list macro values may not be referenced from list contexts.
"""
if _is_ref(node):
name = _ref_name(node)
if name in visiting:
raise CompileError(f"$ref cycle: {' -> '.join(visiting + (name,))}")
if len(visiting) >= MAX_MACRO_DEPTH:
raise CompileError(f"$ref nesting exceeds depth {MAX_MACRO_DEPTH}: {' -> '.join(visiting + (name,))}")
if name not in macros:
raise CompileError(f"unknown macro: {name}")
return _resolve_refs(copy.deepcopy(macros[name]), macros, visiting + (name,))
if isinstance(node, dict):
return {k: _resolve_refs(v, macros, visiting) for k, v in node.items()}
if isinstance(node, list):
out = []
for item in node:
if _is_ref(item):
resolved = _resolve_refs(item, macros, visiting)
if not isinstance(resolved, list):
raise CompileError(
f"macro '{_ref_name(item)}' must resolve to a list when used in a list context"
)
out.extend(resolved)
else:
out.append(_resolve_refs(item, macros, visiting))
return out
return node
# Output JSON key order. Mirrors the conventions in the original hand-written
# settings_ui.json so structural diffs after extraction are minimal.
_ITEM_KEY_ORDER = [
"key",
"widget",
"needs_onroad_cycle",
"requires_attestation",
"blocked",
"title",
"description",
"details",
"title_param_suffix",
"min",
"max",
"step",
"unit",
"options",
"visibility",
"enablement",
"sub_items",
]
def _canon_item(item: dict, macros: dict) -> dict:
resolved = dict(item)
for ctx in ("visibility", "enablement"):
if ctx in resolved:
resolved[ctx] = _resolve_refs(resolved[ctx], macros)
if "options" in resolved:
new_opts = []
for opt in resolved["options"]:
if isinstance(opt, dict):
opt = dict(opt)
for ctx in ("visibility", "enablement"):
if ctx in opt:
opt[ctx] = _resolve_refs(opt[ctx], macros)
new_opts.append(opt)
resolved["options"] = new_opts
if "sub_items" in resolved:
resolved["sub_items"] = [_canon_item(s, macros) for s in resolved["sub_items"]]
out: dict = {}
for k in _ITEM_KEY_ORDER:
if k in resolved:
out[k] = resolved[k]
for k, v in resolved.items():
if k not in out:
out[k] = v
return out
def _canon_section(section: dict, macros: dict) -> dict:
out: dict = {"id": section["id"], "title": section["title"]}
if "description" in section:
out["description"] = section["description"]
for k in ("visibility", "enablement"):
if k in section:
out[k] = _resolve_refs(section[k], macros)
if "attestation_required" in section:
out["attestation_required"] = section["attestation_required"]
out["items"] = [_canon_item(i, macros) for i in section.get("items", [])]
if "sub_panels" in section and section["sub_panels"]:
out["sub_panels"] = [_canon_sub_panel(sp, macros) for sp in section["sub_panels"]]
for k, v in section.items():
if k not in out and k != "order":
out[k] = v
return out
def _canon_sub_panel(sp: dict, macros: dict) -> dict:
out: dict = {"id": sp["id"], "label": sp["label"], "trigger_key": sp["trigger_key"]}
if "trigger_condition" in sp:
out["trigger_condition"] = _resolve_refs(sp["trigger_condition"], macros)
out["items"] = [_canon_item(i, macros) for i in sp.get("items", [])]
for k, v in sp.items():
if k not in out:
out[k] = v
return out
def _canon_panel(page: dict, macros: dict) -> dict:
out: dict = {
"id": page["id"],
"label": page["label"],
"icon": page["icon"],
"order": page["order"],
}
if "remote_configurable" in page:
out["remote_configurable"] = page["remote_configurable"]
if "description" in page:
out["description"] = page["description"]
if "sections" in page and page["sections"]:
out["sections"] = [_canon_section(s, macros) for s in page["sections"]]
if "items" in page and page["items"]:
out["items"] = [_canon_item(i, macros) for i in page["items"]]
if "sub_panels" in page and page["sub_panels"]:
out["sub_panels"] = [_canon_sub_panel(sp, macros) for sp in page["sub_panels"]]
return out
def _canon_vehicle(page: dict, macros: dict) -> dict:
"""Convert page-shape vehicle.yaml to wire-format vehicle_settings dict."""
out: dict = {}
for sec in page.get("sections", []):
brand = sec["id"]
brand_out: dict = {"title": sec.get("title", "")}
if "description" in sec:
brand_out["description"] = sec["description"]
brand_out["items"] = [_canon_item(i, macros) for i in sec.get("items", [])]
out[brand] = brand_out
return out
def _load_pages(src: str) -> list[dict]:
pages_dir = os.path.join(src, "pages")
if not os.path.isdir(pages_dir):
return []
pages = []
for fn in sorted(os.listdir(pages_dir)):
if not fn.endswith((".yaml", ".yml")):
continue
if fn.startswith("_"):
continue
path = os.path.join(pages_dir, fn)
page = _load_yaml(path)
if not isinstance(page, dict):
raise CompileError(f"{path}: page YAML must be an object")
if "id" not in page:
raise CompileError(f"{path}: page missing 'id'")
page["__source"] = path
pages.append(page)
return pages
def compile_schema(src: str) -> dict:
macros_doc = _load_yaml(os.path.join(src, "_macros.yaml"))
macros = (macros_doc.get("macros") or {}) if isinstance(macros_doc, dict) else {}
pages = _load_pages(src)
panels_out = []
vehicle_out: dict = {}
# Order panels by `order` field (falling back to file position).
panel_pages = [p for p in pages if p.get("kind") != "vehicle"]
panel_pages.sort(key=lambda p: (p.get("order", 999), p["id"]))
for page in panel_pages:
panels_out.append(_canon_panel(page, macros))
for page in pages:
if page.get("kind") == "vehicle":
vehicle_out = _canon_vehicle(page, macros)
return {
"$schema": "./settings_ui.schema.json",
"schema_version": SCHEMA_VERSION,
"panels": panels_out,
"vehicle_settings": vehicle_out,
}
def _main() -> int:
parser = argparse.ArgumentParser(description="Compile settings_ui_src/ -> settings_ui.json")
parser.add_argument("--src", default=DEFAULT_SRC)
parser.add_argument("--out", default=DEFAULT_OUT)
parser.add_argument("--check", action="store_true",
help="Compile and diff against existing settings_ui.json; exit non-zero on diff.")
args = parser.parse_args()
schema = compile_schema(args.src)
rendered = json.dumps(schema, indent=2) + "\n"
if args.check:
if not os.path.exists(args.out):
print(f"--check: {args.out} does not exist", file=sys.stderr)
return 1
with open(args.out) as f:
current = f.read()
if current.strip() == rendered.strip():
print(f"--check: {args.out} matches compiled output")
return 0
print(f"--check: {args.out} differs from compiled output", file=sys.stderr)
cur_obj = json.loads(current)
if cur_obj == schema:
print("(structurally equal; only formatting differs)", file=sys.stderr)
return 1
with open(args.out, "w") as f:
f.write(rendered)
print(f"Wrote {args.out}")
return 0
if __name__ == "__main__":
sys.exit(_main())
@@ -1,214 +0,0 @@
#!/usr/bin/env python3
"""
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.
One-shot extractor: settings_ui.json -> settings_ui_src/pages/*.yaml.
Reads the existing monolithic settings_ui.json and produces one YAML file per
page (panel) plus pages/vehicle.yaml for the per-brand settings. Macros remain
unwritten by extract; populate _macros.yaml separately.
Usage:
python extract_settings_ui.py [--src DIR] [--definition PATH]
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
import yaml
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_DEFINITION = os.path.join(DIR, "settings_ui.json")
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
class _BlockDumper(yaml.SafeDumper):
pass
def _represent_dict(dumper, data):
return dumper.represent_mapping("tag:yaml.org,2002:map", data.items(), flow_style=False)
def _represent_list(dumper, data):
flow = all(not isinstance(x, (dict, list)) for x in data) and len(data) <= 8
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=flow)
_BlockDumper.add_representer(dict, _represent_dict)
_BlockDumper.add_representer(list, _represent_list)
def _dump_yaml(data) -> str:
return yaml.dump(data, Dumper=_BlockDumper, sort_keys=False, allow_unicode=True, width=120)
# Item field order in per-page YAML (mirrors settings_ui.json conventions).
_ITEM_ORDER = [
"key",
"widget",
"needs_onroad_cycle",
"requires_attestation",
"blocked",
"title",
"description",
"details",
"title_param_suffix",
"min",
"max",
"step",
"unit",
"options",
"visibility",
"enablement",
"sub_items",
]
def _ordered_item(item: dict) -> dict:
out: dict = {}
for k in _ITEM_ORDER:
if k in item:
v = item[k]
if k == "sub_items":
v = [_ordered_item(s) for s in v]
out[k] = v
for k, v in item.items():
if k not in out:
out[k] = v
return out
def _ordered_section(section: dict) -> dict:
out: dict = {"id": section["id"], "title": section["title"]}
for f in ("description", "order", "visibility", "enablement", "attestation_required"):
if f in section:
out[f] = section[f]
if "items" in section:
out["items"] = [_ordered_item(i) for i in section["items"]]
if "sub_panels" in section and section["sub_panels"]:
out["sub_panels"] = [_ordered_sub_panel(sp) for sp in section["sub_panels"]]
for k, v in section.items():
if k not in out:
out[k] = v
return out
def _ordered_sub_panel(sp: dict) -> dict:
out: dict = {"id": sp["id"], "label": sp["label"], "trigger_key": sp["trigger_key"]}
if "trigger_condition" in sp:
out["trigger_condition"] = sp["trigger_condition"]
if "items" in sp:
out["items"] = [_ordered_item(i) for i in sp["items"]]
for k, v in sp.items():
if k not in out:
out[k] = v
return out
def _ordered_page(panel: dict) -> dict:
out: dict = {
"id": panel["id"],
"label": panel["label"],
"icon": panel["icon"],
"order": panel["order"],
}
for f in ("remote_configurable", "description"):
if f in panel:
out[f] = panel[f]
if "sections" in panel and panel["sections"]:
out["sections"] = [_ordered_section(s) for s in panel["sections"]]
if "items" in panel and panel["items"]:
out["items"] = [_ordered_item(i) for i in panel["items"]]
if "sub_panels" in panel and panel["sub_panels"]:
out["sub_panels"] = [_ordered_sub_panel(sp) for sp in panel["sub_panels"]]
for k, v in panel.items():
if k not in out:
out[k] = v
return out
def _ordered_vehicle_page(vehicle_settings: dict) -> dict:
"""Convert wire-format vehicle_settings dict into a page-shape YAML.
Brand becomes a section id. Each brand's items become section items.
"""
sections = []
for brand in sorted(vehicle_settings.keys()):
bd = vehicle_settings[brand]
items = bd.get("items", []) if isinstance(bd, dict) else bd
sec: dict = {
"id": brand,
"title": bd.get("title", "") if isinstance(bd, dict) else "",
}
if isinstance(bd, dict) and "description" in bd:
sec["description"] = bd["description"]
sec["items"] = [_ordered_item(i) for i in items]
sections.append(sec)
return {
"id": "vehicle",
"label": "Vehicle",
"icon": "vehicle",
"order": 99,
"kind": "vehicle", # signals to compiler: emit as vehicle_settings, not panels
"sections": sections,
}
def extract(definition_path: str, src_dir: str) -> None:
with open(definition_path) as f:
data = json.load(f)
pages_dir = os.path.join(src_dir, "pages")
if os.path.isdir(pages_dir):
shutil.rmtree(pages_dir)
os.makedirs(pages_dir)
count = 0
for panel in data.get("panels", []):
page = _ordered_page(panel)
path = os.path.join(pages_dir, f"{panel['id']}.yaml")
with open(path, "w") as f:
f.write(f"# Page: {panel['id']}\n")
f.write("# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.\n")
f.write(_dump_yaml(page))
count += 1
vehicle_settings = data.get("vehicle_settings", {})
if vehicle_settings:
vehicle_page = _ordered_vehicle_page(vehicle_settings)
path = os.path.join(pages_dir, "vehicle.yaml")
with open(path, "w") as f:
f.write("# Page: vehicle (per-brand settings)\n")
f.write("# Compiles to settings_ui.json#vehicle_settings (brand = section id).\n")
f.write(_dump_yaml(vehicle_page))
count += 1
# Ensure _macros.yaml exists
macros_path = os.path.join(src_dir, "_macros.yaml")
if not os.path.exists(macros_path):
with open(macros_path, "w") as f:
f.write("# Named rule fragments. Reference from pages via {$ref: \"#/macros/<name>\"}.\n")
f.write(_dump_yaml({"macros": {}}))
print(f"Extracted: {count} pages -> {pages_dir}")
def _main() -> int:
parser = argparse.ArgumentParser(description="Extract settings_ui.json into per-page YAML files.")
parser.add_argument("--definition", default=DEFAULT_DEFINITION, help="path to settings_ui.json")
parser.add_argument("--src", default=DEFAULT_SRC, help="path to settings_ui_src/ output dir")
args = parser.parse_args()
extract(args.definition, args.src)
return 0
if __name__ == "__main__":
sys.exit(_main())
@@ -1,194 +0,0 @@
#!/usr/bin/env python3
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from __future__ import annotations
import base64
import datetime
import gzip
import json
import os
from collections.abc import Callable
from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS, CAPABILITY_LABELS
SCHEMA_VERSION = "1.0"
_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFINITION_PATH = os.path.join(_DIR, "settings_ui.json")
TORQUE_VERSIONS_PATH = os.path.normpath(
os.path.join(_DIR, "..", "selfdrive", "controls", "lib", "latcontrol_torque_versions.json")
)
def _load_torque_versions() -> dict:
"""Load latcontrol_torque_versions.json so TorqueControlTune options stay in sync."""
try:
with open(TORQUE_VERSIONS_PATH) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _build_torque_options(versions: dict) -> list[dict]:
options: list[dict] = [{"value": "", "label": "Default"}]
parsed: list[tuple[float, str]] = []
for label, info in versions.items():
try:
parsed.append((float(info["version"]), label))
except (KeyError, TypeError, ValueError):
continue
for version, label in sorted(parsed, key=lambda kv: kv[0], reverse=True):
options.append({"value": version, "label": label})
return options
def _inject_dynamic_options(schema: dict) -> None:
versions = _load_torque_versions()
if not versions:
return
options = _build_torque_options(versions)
def visitor(item: dict) -> None:
if item.get("key") == "TorqueControlTune":
item["options"] = options
_walk_all_items(schema, visitor)
def _load_definition() -> dict:
"""Load settings_ui.json and inject dynamic options sourced from runtime data files."""
with open(DEFINITION_PATH) as f:
schema = json.load(f)
_inject_dynamic_options(schema)
return schema
# Public API
def generate_schema() -> dict:
"""Return the settings_ui.json content augmented with runtime metadata.
Adds three top-level fields the frontend consumes:
- generated_at: ISO timestamp (drives schema-cache freshness checks)
- capability_fields: declared CAPABILITY_FIELDS, used for rule validation
- capability_labels: human-readable labels for capability_fields
"""
schema = _load_definition()
schema["generated_at"] = datetime.datetime.now(datetime.UTC).isoformat()
schema["capability_fields"] = list(CAPABILITY_FIELDS)
schema["capability_labels"] = dict(CAPABILITY_LABELS)
return schema
def generate_schema_json() -> str:
"""Generate SettingsSchema as a compact JSON string."""
return json.dumps(generate_schema(), separators=(",", ":"))
def generate_schema_compressed() -> str:
"""Generate SettingsSchema as gzip-compressed, base64-encoded string.
Compression pipeline:
1. JSON serialize (compact, no whitespace)
2. UTF-8 encode
3. gzip compress
4. base64 encode
"""
raw = json.dumps(generate_schema(), separators=(",", ":")).encode("utf-8")
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
# Schema introspection utilities
def _walk_rules(rules: list[dict] | None, visitor: Callable[[dict], None]) -> None:
"""Recursively walk all rules, calling visitor on each leaf rule."""
if not rules:
return
for rule in rules:
visitor(rule)
if rule.get("type") == "not" and "condition" in rule:
_walk_rules([rule["condition"]], visitor)
elif rule.get("type") in ("any", "all") and "conditions" in rule:
_walk_rules(rule["conditions"], visitor)
def _walk_all_items(schema: dict, visitor: Callable[[dict], None]) -> None:
"""Walk every item in the schema (panels, sections, sub_panels, sub_items, vehicle_settings)."""
def _visit_item(item: dict) -> None:
visitor(item)
for sub in item.get("sub_items", []):
_visit_item(sub)
for panel in schema.get("panels", []):
# Walk section items (V2)
for section in panel.get("sections", []):
for item in section.get("items", []):
_visit_item(item)
for sp in section.get("sub_panels", []):
for item in sp.get("items", []):
_visit_item(item)
# Walk flat items (V1)
for item in panel.get("items", []):
_visit_item(item)
for sp in panel.get("sub_panels", []):
for item in sp.get("items", []):
_visit_item(item)
for brand_data in schema.get("vehicle_settings", {}).values():
items = brand_data.get("items", []) if isinstance(brand_data, dict) else brand_data
for item in items:
_visit_item(item)
def collect_all_keys(schema: dict) -> set[str]:
"""Collect all param keys referenced in the schema (items + rules)."""
keys: set[str] = set()
def _visit_rule(rule: dict) -> None:
if rule.get("type") in ("param", "param_compare") and "key" in rule:
keys.add(rule["key"])
def _visit_item(item: dict) -> None:
if "key" in item:
keys.add(item["key"])
_walk_rules(item.get("visibility"), _visit_rule)
_walk_rules(item.get("enablement"), _visit_rule)
_walk_all_items(schema, _visit_item)
return keys
def collect_capability_refs(schema: dict) -> set[str]:
"""Collect all capability field names referenced in rules."""
refs: set[str] = set()
def _visit_rule(rule: dict) -> None:
if rule.get("type") == "capability" and "field" in rule:
refs.add(rule["field"])
def _visit_item(item: dict) -> None:
_walk_rules(item.get("visibility"), _visit_rule)
_walk_rules(item.get("enablement"), _visit_rule)
_walk_all_items(schema, _visit_item)
return refs
if __name__ == "__main__":
# CLI: print schema for inspection
schema = generate_schema()
print(json.dumps(schema, indent=2))
print(f"\nTotal panels: {len(schema.get('panels', []))}")
print(f"Total vehicle brands: {len(schema.get('vehicle_settings', {}))}")
keys = collect_all_keys(schema)
print(f"Total unique param keys: {len(keys)}")
# Show compression stats
raw_json = json.dumps(schema, separators=(",", ":")).encode("utf-8")
compressed = gzip.compress(raw_json)
print(f"\nRaw JSON size: {len(raw_json):,} bytes")
print(f"Compressed size: {len(compressed):,} bytes")
print(f"Compression ratio: {len(compressed)/len(raw_json):.1%}")
@@ -1,530 +0,0 @@
#!/usr/bin/env python3
"""
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.
Validates settings_ui.json against structural, semantic, and referential integrity constraints.
Usage:
python validate_settings_ui.py
python validate_settings_ui.py /path/to/settings_ui.json
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS
VALID_WIDGETS = {"toggle", "option", "multiple_button", "button", "info"}
VALID_COMPARE_OPS = {">", "<", ">=", "<="}
DEFAULT_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"settings_ui.json",
)
class ValidationResult:
"""Tracks pass/fail for each named check."""
def __init__(self) -> None:
self.passed: list[str] = []
self.failed: list[tuple[str, str]] = []
self.warnings: list[str] = []
def ok(self, name: str) -> None:
self.passed.append(name)
print(f"OK: {name}")
def error(self, name: str, details: str) -> None:
self.failed.append((name, details))
print(f"ERROR: {name}: {details}")
def warn(self, msg: str) -> None:
self.warnings.append(msg)
print(f"WARNING: {msg}")
@property
def success(self) -> bool:
return len(self.failed) == 0
def summary(self) -> None:
total_passed = len(self.passed)
total_failed = len(self.failed)
print(f"\n{'='*60}")
print(f"Summary: {total_passed} checks passed, {total_failed} checks failed")
if self.warnings:
print(f" {len(self.warnings)} warnings")
if self.success:
print("Result: PASS")
else:
print("Result: FAIL")
def validate_rule(rule: dict, path: str, result: ValidationResult,
capability_fields: tuple[str, ...]) -> bool:
"""Validate a single rule dict. Returns True if valid."""
if not isinstance(rule, dict):
result.error("rule well-formedness", f"{path}: rule is not a dict: {rule!r}")
return False
rule_type = rule.get("type")
if rule_type is None:
result.error("rule well-formedness", f"{path}: rule missing 'type' field")
return False
if rule_type in ("offroad_only", "not_engaged"):
# Only type required
return True
if rule_type == "capability":
valid = True
if "field" not in rule or not isinstance(rule["field"], str):
result.error("rule well-formedness", f"{path}: capability rule missing/invalid 'field'")
valid = False
if "equals" not in rule:
result.error("rule well-formedness", f"{path}: capability rule missing 'equals'")
valid = False
return valid
if rule_type == "param":
valid = True
if "key" not in rule or not isinstance(rule["key"], str):
result.error("rule well-formedness", f"{path}: param rule missing/invalid 'key'")
valid = False
if "equals" not in rule:
result.error("rule well-formedness", f"{path}: param rule missing 'equals'")
valid = False
return valid
if rule_type == "param_compare":
valid = True
if "key" not in rule or not isinstance(rule["key"], str):
result.error("rule well-formedness", f"{path}: param_compare rule missing/invalid 'key'")
valid = False
if "op" not in rule or rule["op"] not in VALID_COMPARE_OPS:
result.error("rule well-formedness",
f"{path}: param_compare rule missing/invalid 'op' (must be one of {VALID_COMPARE_OPS})")
valid = False
if "value" not in rule or not isinstance(rule["value"], (int, float)):
result.error("rule well-formedness", f"{path}: param_compare rule missing/invalid 'value' (must be number)")
valid = False
return valid
if rule_type == "not":
if "condition" not in rule or not isinstance(rule["condition"], dict):
result.error("rule well-formedness", f"{path}: 'not' rule missing/invalid 'condition'")
return False
return validate_rule(rule["condition"], f"{path}.not", result, capability_fields)
if rule_type in ("any", "all"):
if "conditions" not in rule or not isinstance(rule["conditions"], list):
result.error("rule well-formedness", f"{path}: '{rule_type}' rule missing/invalid 'conditions' array")
return False
valid = True
for i, cond in enumerate(rule["conditions"]):
if not isinstance(cond, dict):
result.error("rule well-formedness", f"{path}.{rule_type}[{i}]: condition must be a dict")
valid = False
continue
if not validate_rule(cond, f"{path}.{rule_type}[{i}]", result, capability_fields):
valid = False
return valid
result.error("rule well-formedness", f"{path}: unknown rule type '{rule_type}'")
return False
def collect_rules_from_item(item: dict) -> list[tuple[str, list[dict]]]:
"""Return list of (context, rules_list) for an item's visibility + enablement."""
result = []
key = item.get("key", "?")
if "visibility" in item:
result.append((f"item '{key}' visibility", item["visibility"]))
if "enablement" in item:
result.append((f"item '{key}' enablement", item["enablement"]))
return result
def walk_rules_flat(rules: list[dict]) -> list[dict]:
"""Flatten all rules recursively into a single list."""
flat: list[dict] = []
for rule in rules:
flat.append(rule)
if rule.get("type") == "not" and "condition" in rule:
flat.extend(walk_rules_flat([rule["condition"]]))
elif rule.get("type") in ("any", "all") and "conditions" in rule:
flat.extend(walk_rules_flat(rule["conditions"]))
return flat
def collect_all_items(data: dict) -> list[tuple[str, dict]]:
"""Collect all items with their location path from the schema.
Returns (path, item_dict) tuples. Traverses sections, sub_panels, sub_items,
and vehicle_settings.
"""
items: list[tuple[str, dict]] = []
for panel in data.get("panels", []):
pid = panel.get("id", "?")
# Flat items on panel
for item in panel.get("items", []):
_collect_item(f"panel '{pid}'", item, items)
# Sections
for section in panel.get("sections", []):
sid = section.get("id", "?")
for item in section.get("items", []):
_collect_item(f"panel '{pid}' > section '{sid}'", item, items)
for sp in section.get("sub_panels", []):
spid = sp.get("id", "?")
for item in sp.get("items", []):
_collect_item(f"panel '{pid}' > section '{sid}' > sub_panel '{spid}'", item, items)
# Top-level sub_panels on panel (no section)
for sp in panel.get("sub_panels", []):
spid = sp.get("id", "?")
for item in sp.get("items", []):
_collect_item(f"panel '{pid}' > sub_panel '{spid}'", item, items)
# Vehicle settings (supports both flat list and { title, items } structure)
for brand, brand_data in data.get("vehicle_settings", {}).items():
brand_items = brand_data.get("items", []) if isinstance(brand_data, dict) else brand_data
for item in brand_items:
_collect_item(f"vehicle_settings '{brand}'", item, items)
return items
def _collect_item(path: str, item: dict, items: list[tuple[str, dict]]) -> None:
"""Recursively collect an item and its sub_items."""
items.append((path, item))
for sub in item.get("sub_items", []):
_collect_item(f"{path} > sub_item", sub, items)
def collect_panel_keys(panel: dict) -> set[str]:
"""Collect all item keys within a single panel (sections, sub_panels, sub_items)."""
keys: set[str] = set()
def _add(item: dict) -> None:
if "key" in item:
keys.add(item["key"])
for sub in item.get("sub_items", []):
_add(sub)
for item in panel.get("items", []):
_add(item)
for section in panel.get("sections", []):
for item in section.get("items", []):
_add(item)
for sp in section.get("sub_panels", []):
for item in sp.get("items", []):
_add(item)
for sp in panel.get("sub_panels", []):
for item in sp.get("items", []):
_add(item)
return keys
def check_json_parseable(path: str, result: ValidationResult) -> dict | None:
"""Check 1: JSON parseable."""
try:
with open(path) as f:
data = json.load(f)
result.ok("JSON parseable")
return data
except json.JSONDecodeError as e:
result.error("JSON parseable", str(e))
return None
except FileNotFoundError:
result.error("JSON parseable", f"file not found: {path}")
return None
def check_structural(data: dict, result: ValidationResult) -> None:
"""Check 2: Required fields on panels, sections, items, sub_panels."""
errors: list[str] = []
for i, panel in enumerate(data.get("panels", [])):
for field in ("id", "label", "icon", "order"):
if field not in panel:
errors.append(f"panels[{i}]: missing required field '{field}'")
for j, section in enumerate(panel.get("sections", [])):
for field in ("id", "title"):
if field not in section:
errors.append(f"panels[{i}].sections[{j}]: missing required field '{field}'")
for k, sp in enumerate(section.get("sub_panels", [])):
for field in ("id", "label", "trigger_key"):
if field not in sp:
errors.append(f"panels[{i}].sections[{j}].sub_panels[{k}]: missing required field '{field}'")
for k, sp in enumerate(panel.get("sub_panels", [])):
for field in ("id", "label", "trigger_key"):
if field not in sp:
errors.append(f"panels[{i}].sub_panels[{k}]: missing required field '{field}'")
# Validate items
all_items = collect_all_items(data)
for path, item in all_items:
if "key" not in item:
errors.append(f"{path}: item missing required field 'key'")
if "widget" not in item:
errors.append(f"{path}: item missing required field 'widget'")
elif item["widget"] not in VALID_WIDGETS:
errors.append(
f"{path}: item '{item.get('key', '?')}' has invalid widget '{item['widget']}'"
+ f" (must be one of {VALID_WIDGETS})"
)
if errors:
result.error("structural", "; ".join(errors))
else:
result.ok("structural")
def check_item_completeness(data: dict, result: ValidationResult) -> None:
"""Check 3: All items have required metadata (title, options for dropdowns)."""
all_items = collect_all_items(data)
issues: list[str] = []
for _path, item in all_items:
key = item.get("key", "unknown")
if "title" not in item:
issues.append(f"{key}: missing 'title'")
elif item["title"] == key:
issues.append(f"{key}: title must not equal key (use a human-readable title)")
widget = item.get("widget")
if widget in ("multiple_button", "option") and "options" in item:
opts = item["options"]
if not isinstance(opts, list):
issues.append(f"{key}: options must be a list")
else:
for opt in opts:
if not isinstance(opt, dict) or "value" not in opt or "label" not in opt:
issues.append(f"{key}: each option must have 'value' and 'label'")
break
if issues:
for issue in issues:
result.error("item completeness", issue)
else:
result.ok("item completeness")
def check_no_duplicate_keys(data: dict, result: ValidationResult) -> None:
"""Check 4: No param key appears in more than one panel."""
panel_keys: dict[str, list[str]] = {} # key -> list of panel ids
for panel in data.get("panels", []):
pid = panel.get("id", "?")
keys = collect_panel_keys(panel)
for key in keys:
panel_keys.setdefault(key, []).append(pid)
# Also check vehicle_settings keys don't collide with panel keys
for brand, brand_data in data.get("vehicle_settings", {}).items():
brand_items = brand_data.get("items", []) if isinstance(brand_data, dict) else brand_data
for item in brand_items:
key = item.get("key")
if key:
panel_keys.setdefault(key, []).append(f"vehicle_settings.{brand}")
duplicates = {k: v for k, v in panel_keys.items() if len(v) > 1}
if duplicates:
details = "; ".join(f"'{k}' in [{', '.join(v)}]" for k, v in duplicates.items())
result.error("no duplicate keys", details)
else:
result.ok("no duplicate keys")
def check_rule_wellformedness(data: dict, result: ValidationResult) -> None:
"""Check 5: All rules have valid structure."""
all_items = collect_all_items(data)
# Save current error count to detect new errors
error_count_before = len(result.failed)
for path, item in all_items:
for ctx, rules in collect_rules_from_item(item):
for i, rule in enumerate(rules):
validate_rule(rule, f"{path} > {ctx}[{i}]", result, CAPABILITY_FIELDS)
# Also validate trigger_condition rules on sub_panels
for panel in data.get("panels", []):
pid = panel.get("id", "?")
for section in panel.get("sections", []):
for sp in section.get("sub_panels", []):
if "trigger_condition" in sp:
validate_rule(sp["trigger_condition"], f"panel '{pid}' > sub_panel '{sp.get('id', '?')}' trigger_condition",
result, CAPABILITY_FIELDS)
if len(result.failed) == error_count_before:
result.ok("rule well-formedness")
def check_capability_refs(data: dict, result: ValidationResult) -> None:
"""Check 6: All capability rule field values are in CAPABILITY_FIELDS."""
all_items = collect_all_items(data)
invalid_refs: list[str] = []
cap_set = set(CAPABILITY_FIELDS)
for _path, item in all_items:
for _ctx, rules in collect_rules_from_item(item):
for rule in walk_rules_flat(rules):
if rule.get("type") == "capability":
field = rule.get("field")
if field and field not in cap_set:
invalid_refs.append(f"'{field}' in item '{item.get('key', '?')}'")
# Also check trigger_conditions
for panel in data.get("panels", []):
for section in panel.get("sections", []):
for sp in section.get("sub_panels", []):
if "trigger_condition" in sp:
for rule in walk_rules_flat([sp["trigger_condition"]]):
if rule.get("type") == "capability":
field = rule.get("field")
if field and field not in cap_set:
invalid_refs.append(f"'{field}' in sub_panel '{sp.get('id', '?')}' trigger_condition")
if invalid_refs:
result.error("capability refs", f"unknown capability fields: {', '.join(invalid_refs)}")
else:
result.ok("capability refs")
def check_no_self_reference(data: dict, result: ValidationResult) -> None:
"""Check 7: Item's rules must not reference the item's own key."""
all_items = collect_all_items(data)
self_refs: list[str] = []
for path, item in all_items:
key = item.get("key")
if not key:
continue
for _ctx, rules in collect_rules_from_item(item):
for rule in walk_rules_flat(rules):
if rule.get("type") in ("param", "param_compare") and rule.get("key") == key:
self_refs.append(f"'{key}' at {path}")
if self_refs:
result.error("no self-reference", f"items reference their own key: {', '.join(self_refs)}")
else:
result.ok("no self-reference")
def check_sub_panel_triggers(data: dict, result: ValidationResult) -> None:
"""Check 8: Sub-panel trigger_key must reference a key in the same panel."""
errors: list[str] = []
for panel in data.get("panels", []):
pid = panel.get("id", "?")
panel_keys = collect_panel_keys(panel)
# Check sub_panels at section level
for section in panel.get("sections", []):
for sp in section.get("sub_panels", []):
trigger = sp.get("trigger_key")
if trigger and trigger not in panel_keys:
errors.append(
f"sub_panel '{sp.get('id', '?')}' trigger_key '{trigger}'"
+ f" not found in panel '{pid}'"
)
# Check top-level sub_panels
for sp in panel.get("sub_panels", []):
trigger = sp.get("trigger_key")
if trigger and trigger not in panel_keys:
errors.append(
f"sub_panel '{sp.get('id', '?')}' trigger_key '{trigger}'"
+ f" not found in panel '{pid}'"
)
if errors:
result.error("sub-panel triggers", "; ".join(errors))
else:
result.ok("sub-panel triggers")
def check_ordering(data: dict, result: ValidationResult) -> None:
"""Check 9: Panel order values must be unique."""
orders: dict[int, list[str]] = {}
for panel in data.get("panels", []):
order = panel.get("order")
if order is not None:
orders.setdefault(order, []).append(panel.get("id", "?"))
duplicates = {o: ids for o, ids in orders.items() if len(ids) > 1}
if duplicates:
details = "; ".join(f"order {o}: [{', '.join(ids)}]" for o, ids in duplicates.items())
result.error("ordering", f"duplicate order values: {details}")
else:
result.ok("ordering")
def check_vehicle_brands(data: dict, result: ValidationResult) -> None:
"""Check 10: Vehicle settings keys lowercase + each brand has consistent {title, description, items} shape."""
vehicle = data.get("vehicle_settings", {})
errors: list[str] = []
for brand, brand_data in vehicle.items():
if not isinstance(brand, str) or brand != brand.lower():
errors.append(f"non-lowercase brand key: '{brand}'")
if not isinstance(brand_data, dict):
errors.append(f"brand '{brand}': expected object with {{title, description, items}}, got bare list")
continue
if "items" not in brand_data:
errors.append(f"brand '{brand}': missing 'items'")
if "title" not in brand_data:
errors.append(f"brand '{brand}': missing 'title' (use empty string for none)")
if errors:
result.error("vehicle brands", "; ".join(errors))
else:
result.ok("vehicle brands")
def validate(path: str) -> bool:
"""Run all validation checks on the given settings_ui.json file.
Returns True if all checks pass.
"""
result = ValidationResult()
# Check 1: JSON parseable
data = check_json_parseable(path, result)
if data is None:
result.summary()
return False
# Checks 2-10
check_structural(data, result)
check_item_completeness(data, result)
check_no_duplicate_keys(data, result)
check_rule_wellformedness(data, result)
check_capability_refs(data, result)
check_no_self_reference(data, result)
check_sub_panel_triggers(data, result)
check_ordering(data, result)
check_vehicle_brands(data, result)
result.summary()
return result.success
if __name__ == "__main__":
target = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_PATH
success = validate(target)
sys.exit(0 if success else 1)
-282
View File
@@ -1,282 +0,0 @@
#!/usr/bin/env python3
import json
import os
import random
import requests
import threading
import time
import traceback
import datetime
from collections.abc import Iterator
from openpilot.cereal import log
import openpilot.cereal.messaging as messaging
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
from openpilot.common.utils import get_upload_stream
from openpilot.common.params import Params
from openpilot.common.realtime import set_core_affinity
from openpilot.common.hardware.hw import Paths
from openpilot.system.loggerd.xattr_cache import getxattr, setxattr
from openpilot.common.swaglog import cloudlog
NetworkType = log.DeviceState.NetworkType
UPLOAD_ATTR_NAME = 'user.sunny.upload'
UPLOAD_ATTR_VALUE = b'1'
MAX_UPLOAD_SIZES = {
"qlog": 25*1e6, # can't be too restrictive here since we use qlogs to find
# bugs, including ones that can cause massive log sizes
"qcam": 5*1e6,
}
allow_sleep = bool(os.getenv("UPLOADER_SLEEP", "1"))
force_wifi = os.getenv("FORCEWIFI") is not None
fake_upload = os.getenv("FAKEUPLOAD") is not None
class FakeRequest:
def __init__(self):
self.headers = {"Content-Length": "0"}
class FakeResponse:
def __init__(self):
self.status_code = 200
self.request = FakeRequest()
def get_directory_sort(d: str) -> list[str]:
# ensure old format is sorted sooner
o = ["0", ] if d.startswith("2024-") else ["1", ]
return o + [s.rjust(10, '0') for s in d.rsplit('--', 1)]
def listdir_by_creation(d: str) -> list[str]:
if not os.path.isdir(d):
return []
try:
paths = [f for f in os.listdir(d) if os.path.isdir(os.path.join(d, f))]
paths = sorted(paths, key=get_directory_sort)
return paths
except OSError:
cloudlog.exception("listdir_by_creation failed")
return []
def clear_locks(root: str) -> None:
for logdir in os.listdir(root):
path = os.path.join(root, logdir)
try:
for fname in os.listdir(path):
if fname.endswith(".lock"):
os.unlink(os.path.join(path, fname))
except OSError:
cloudlog.exception("clear_locks failed")
class Uploader:
def __init__(self, dongle_id: str, root: str):
self.dongle_id = dongle_id
self.api = SunnylinkApi(dongle_id)
self.root = root
self.params = Params()
# stats for last successfully uploaded file
self.last_filename = ""
self.immediate_folders = ["crash/", "boot/"]
self.immediate_priority = {"qlog": 0, "qlog.zst": 0, "qcamera.ts": 1}
def list_upload_files(self, metered: bool) -> Iterator[tuple[str, str, str]]:
r = self.params.get("AthenadRecentlyViewedRoutes")
requested_routes = [] if r is None else [route for route in r.split(",") if route]
for logdir in listdir_by_creation(self.root):
path = os.path.join(self.root, logdir)
try:
names = os.listdir(path)
except OSError:
continue
if any(name.endswith(".lock") for name in names):
continue
for name in sorted(names, key=lambda n: self.immediate_priority.get(n, 1000)):
key = os.path.join(logdir, name)
fn = os.path.join(path, name)
# skip files already uploaded
try:
ctime = os.path.getctime(fn)
is_uploaded = getxattr(fn, UPLOAD_ATTR_NAME) == UPLOAD_ATTR_VALUE
except OSError:
cloudlog.event("uploader_getxattr_failed", key=key, fn=fn)
# deleter could have deleted, so skip
continue
if is_uploaded:
continue
# limit uploading on metered connections
if metered:
dt = datetime.timedelta(hours=12)
if logdir in self.immediate_folders and (datetime.datetime.now() - datetime.datetime.fromtimestamp(ctime)) < dt:
continue
if name == "qcamera.ts" and not any(logdir.startswith(r.split('|')[-1]) for r in requested_routes):
continue
yield name, key, fn
def next_file_to_upload(self, metered: bool) -> tuple[str, str, str] | None:
upload_files = list(self.list_upload_files(metered))
for name, key, fn in upload_files:
if any(f in fn for f in self.immediate_folders):
return name, key, fn
for name, key, fn in upload_files:
if name in self.immediate_priority:
return name, key, fn
return None
def do_upload(self, key: str, fn: str):
url_resp = self.api.get(
f"device/{self.dongle_id}/upload_url/",
timeout=10,
path=key,
access_token=self.api.get_token(),
)
if url_resp.status_code == 412:
return url_resp
url_resp_json = json.loads(url_resp.text)
url = url_resp_json['url']
headers = url_resp_json['headers']
cloudlog.debug("sunnylink upload_url %s | Headers: %s", url, headers)
if fake_upload:
return FakeResponse()
stream = None
try:
compress = key.endswith('.zst') and not fn.endswith('.zst')
stream, _ = get_upload_stream(fn, compress)
response = requests.put(url, data=stream, headers=headers, timeout=10)
return response
finally:
if stream:
stream.close()
def upload(self, name: str, key: str, fn: str, network_type: int, metered: bool) -> bool:
try:
sz = os.path.getsize(fn)
except OSError:
cloudlog.exception("upload: getsize failed")
return False
cloudlog.event("upload_start", key=key, fn=fn, sz=sz, network_type=network_type, metered=metered)
if sz == 0:
# tag files of 0 size as uploaded
success = True
elif name in MAX_UPLOAD_SIZES and sz > MAX_UPLOAD_SIZES[name]:
cloudlog.event("uploader_too_large", key=key, fn=fn, sz=sz)
success = True
else:
start_time = time.monotonic()
stat = None
last_exc = None
try:
stat = self.do_upload(key, fn)
except Exception as e:
last_exc = (e, traceback.format_exc())
if stat is not None and stat.status_code in (200, 201, 412):
self.last_filename = fn
dt = time.monotonic() - start_time
if stat.status_code == 412:
cloudlog.event("upload_ignored", key=key, fn=fn, sz=sz, network_type=network_type, metered=metered)
else:
content_length = int(stat.request.headers.get("Content-Length", 0))
speed = (content_length / 1e6) / dt
cloudlog.event("upload_success", key=key, fn=fn, sz=sz, content_length=content_length,
network_type=network_type, metered=metered, speed=speed)
success = True
elif stat is not None: # 401, 403... Not sure why they were up to begin with
success = False
cloudlog.event("upload_failed with content", stat=stat, exc=last_exc, key=key, fn=fn, sz=sz, network_type=network_type, metered=metered,
error=stat.content.decode("utf-8"))
else:
success = False
cloudlog.event("upload_failed", stat=stat, exc=last_exc, key=key, fn=fn, sz=sz, network_type=network_type, metered=metered)
if success:
# tag file as uploaded
try:
setxattr(fn, UPLOAD_ATTR_NAME, UPLOAD_ATTR_VALUE)
except OSError:
cloudlog.event("uploader_setxattr_failed", exc=last_exc, key=key, fn=fn, sz=sz)
return success
def step(self, network_type: int, metered: bool) -> bool | None:
d = self.next_file_to_upload(metered)
if d is None:
return None
name, key, fn = d
# qlogs and bootlogs need to be compressed before uploading
if key.endswith(('qlog', 'rlog')) or (key.startswith('boot/') and not key.endswith('.zst')):
key += ".zst"
return self.upload(name, key, fn, network_type, metered)
def main(exit_event: threading.Event | None = None) -> None:
if exit_event is None:
exit_event = threading.Event()
try:
set_core_affinity([0, 1, 2, 3])
except Exception:
cloudlog.exception("failed to set core affinity")
clear_locks(Paths.log_root())
params = Params()
dongle_id = params.get("SunnylinkDongleId")
if dongle_id is None:
cloudlog.info("uploader missing dongle_id")
raise Exception("uploader can't start without dongle id")
sm = messaging.SubMaster(['deviceState'])
uploader = Uploader(dongle_id, Paths.log_root())
backoff = 0.1
while not exit_event.is_set():
sm.update(0)
offroad = params.get_bool("IsOffroad")
network_type = sm['deviceState'].networkType if not force_wifi else NetworkType.wifi
if network_type == NetworkType.none:
if allow_sleep:
time.sleep(60 if offroad else 5)
continue
success = uploader.step(sm['deviceState'].networkType.raw, sm['deviceState'].networkMetered)
if success is None:
backoff = 60 if offroad else 5
elif success:
backoff = 0.1
else:
cloudlog.info("upload backoff %r", backoff)
backoff = min(backoff*2, 120)
if allow_sleep:
time.sleep(backoff + random.uniform(0, backoff))
if __name__ == "__main__":
main()
-129
View File
@@ -1,129 +0,0 @@
import base64
import gzip
import json
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID
from openpilot.common.params import Params, ParamKeyType
from openpilot.common.version import is_prebuilt
def get_sunnylink_status(params=None) -> tuple[bool, bool, bool]:
"""Get the status of Sunnylink on the device. Returns a tuple of (is_sunnylink_enabled, is_registered)."""
params = params or Params()
is_sunnylink_enabled = params.get_bool("SunnylinkEnabled")
is_registered = params.get("SunnylinkDongleId") not in (None, UNREGISTERED_SUNNYLINK_DONGLE_ID)
is_on_temporary_fault = params.get_bool("SunnylinkTempFault")
return is_sunnylink_enabled, is_registered, is_on_temporary_fault
def sunnylink_ready(params=None) -> bool:
"""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 and not is_on_temporary_fault
def use_sunnylink_uploader(params) -> bool:
"""Check if the device is ready to use Sunnylink and the uploader is enabled."""
return not params.get_bool("NetworkMetered") and sunnylink_ready(params) and params.get_bool("EnableSunnylinkUploader")
def sunnylink_need_register(params=None) -> bool:
"""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_on_temporary_fault
def register_sunnylink():
"""Register the device with Sunnylink if it is enabled."""
extra_args = {}
if not Params().get_bool("SunnylinkEnabled"):
print("Sunnylink is not enabled. Exiting.")
exit(0)
if not is_prebuilt():
extra_args = {
"verbose": True,
"timeout": 60
}
try:
sunnylink_id = SunnylinkApi(None).register_device(None, **extra_args)
print(f"SunnyLinkId: {sunnylink_id}")
except Exception:
Params().put_bool("SunnylinkTempFault", True, block=True)
raise
def get_api_token():
"""Get the API token for the device."""
params = Params()
sunnylink_dongle_id = params.get("SunnylinkDongleId")
sunnylink_api = SunnylinkApi(sunnylink_dongle_id)
token = sunnylink_api.get_token()
print(f"API Token: {token}")
def get_param_as_byte(param_name: str, params=None, get_default=False) -> bytes | None:
"""Get a parameter as bytes. Returns None if the parameter does not exist."""
params = params or Params()
param = params.get(param_name) if not get_default else params.get_default_value(param_name)
if param is None:
return None
param_type = params.get_type(param_name)
return _to_bytes(param, param_type)
def _to_bytes(param: bytes, param_type: ParamKeyType) -> bytes | None:
"""Convert a parameter value to bytes based on its type."""
if param_type == ParamKeyType.BYTES:
return bytes(param)
elif param_type == ParamKeyType.JSON:
return json.dumps(param).encode('utf-8')
return str(param).encode('utf-8')
def save_param_from_base64_encoded_string(param_name: str, base64_encoded_data: str, is_compressed=False) -> None:
"""Save a parameter from bytes. Overwrites the parameter if it already exists."""
params = Params()
# Find real param name (with correct casing)
param_type = params.get_type(param_name)
value = base64.b64decode(base64_encoded_data)
if is_compressed:
value = gzip.decompress(value)
# We convert to string anything that isn't bytes first. We later transform further.
param_value = _convert_param_to_type(value, param_type)
params.put(param_name, param_value, block=True)
def _convert_param_to_type(value: bytes, param_type: ParamKeyType) -> bytes | str | int | float | bool | dict | None:
"""
Convert a byte value to the specified param type. Used internally when getting a Param to convert it to the right type.
If this method looks familiar, it's because on SP we have a similar one in openpilot/sunnypilot/car/__init__.py.
"""
# We convert to string anything that isn't bytes first. We later transform further.
if param_type == ParamKeyType.BYTES:
return value
decoded = value.decode('utf-8')
if param_type == ParamKeyType.STRING:
return decoded
elif param_type == ParamKeyType.BOOL:
return decoded.lower() in ('true', '1', 'yes')
elif param_type == ParamKeyType.INT:
return int(decoded)
elif param_type == ParamKeyType.FLOAT:
return float(decoded)
elif param_type == ParamKeyType.TIME:
return str(decoded)
elif param_type == ParamKeyType.JSON:
return json.loads(decoded)
return decoded
@@ -7,7 +7,7 @@ See the LICENSE.md file in the root directory for more details.
import json
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.selfdrive.car.sync_sunnylink_params import CAR_LIST_JSON_OUT
from openpilot.sunnypilot.selfdrive.car.sync_car_list_params import CAR_LIST_JSON_OUT
ONROAD_BRIGHTNESS_MIGRATION_VERSION: str = "1.0"
ONROAD_BRIGHTNESS_TIMER_MIGRATION_VERSION: str = "1.0"
+24 -81
View File
@@ -17,7 +17,6 @@ import sys
import tempfile
import threading
import time
import gzip
from contextlib import suppress
from dataclasses import asdict, dataclass, replace
from datetime import datetime
@@ -151,7 +150,6 @@ def send_queue_push(data: str, priority: int) -> None:
send_queue.put_nowait((priority, next(send_seq), data)) # tie-break with a monotonic counter
# TODO-SP: adapt zst for sunnylink
def strip_zst_extension(fn: str) -> str:
if fn.endswith('.zst'):
return fn[:-4]
@@ -869,65 +867,23 @@ def get_logs_to_send_sorted(log_attr_name=LOG_ATTR_NAME) -> list[str]:
return sorted(logs)[:-1]
def add_log_to_queue(log_path, log_id, is_sunnylink=False):
MAX_SIZE_KB = 32
MAX_SIZE_BYTES = MAX_SIZE_KB * 1024
def add_log_to_queue(log_path, log_id):
with open(log_path) as f:
data = f.read()
# Check if the file is empty
if not data:
cloudlog.warning(f"Log file {log_path} is empty.")
return
if not data:
cloudlog.warning(f"Log file {log_path} is empty.")
return
# Initialize variables for encoding
payload = data
is_compressed = False
jsonrpc = {
"method": "forwardLogs",
"params": {"logs": data},
"jsonrpc": "2.0",
"id": log_id,
}
send_queue_push(json.dumps(jsonrpc), SEND_PRIORITY_LOW)
# Log the current size of the file
current_size = len(json.dumps(payload).encode("utf-8")) + len(log_id.encode("utf-8")) + 100 # Add 100 bytes to account for encoding overhead
cloudlog.debug(f"Current size of log file {log_path}: {current_size} bytes")
if is_sunnylink and current_size > MAX_SIZE_BYTES:
# Compress and encode the data if it exceeds the maximum size
compressed_data = gzip.compress(data.encode())
payload = base64.b64encode(compressed_data).decode()
is_compressed = True
# Log the size after compression and encoding
compressed_size = len(compressed_data)
encoded_size = len(payload)
cloudlog.debug(f"Size of log file {log_path} " +
f"after compression: {compressed_size} bytes, " +
f"after encoding: {encoded_size} bytes")
params: dict[str, str | bool] = {"logs": payload}
if is_sunnylink and is_compressed:
params["compressed"] = is_compressed
jsonrpc: dict = {
"method": "forwardLogs",
"params": params,
"jsonrpc": "2.0",
"id": log_id
}
jsonrpc_str = json.dumps(jsonrpc)
size_in_bytes = len(jsonrpc_str.encode('utf-8'))
if is_sunnylink and size_in_bytes <= MAX_SIZE_BYTES:
cloudlog.debug(f"Target is sunnylink and log file {log_path} is small enough to send in one request ({size_in_bytes} bytes).")
send_queue_push(jsonrpc_str, SEND_PRIORITY_LOW)
elif is_sunnylink:
cloudlog.warning(f"Target is sunnylink and log file {log_path} is too large to send in one request.")
else:
cloudlog.debug(f"Target is not sunnylink, proceeding to send log file {log_path} in one request ({size_in_bytes} bytes).")
send_queue_push(jsonrpc_str, SEND_PRIORITY_LOW)
def log_handler(end_event: threading.Event, log_attr_name=LOG_ATTR_NAME) -> None:
is_sunnylink = log_attr_name != LOG_ATTR_NAME
def log_handler(end_event: threading.Event) -> None:
if PC:
cloudlog.debug("athena.log_handler: Not supported on PC")
time.sleep(1)
@@ -939,7 +895,7 @@ def log_handler(end_event: threading.Event, log_attr_name=LOG_ATTR_NAME) -> None
try:
curr_scan = time.monotonic()
if curr_scan - last_scan > 10:
log_files = get_logs_to_send_sorted(log_attr_name)
log_files = get_logs_to_send_sorted()
last_scan = curr_scan
# send one log
@@ -950,9 +906,9 @@ def log_handler(end_event: threading.Event, log_attr_name=LOG_ATTR_NAME) -> None
try:
curr_time = int(time.time()) # noqa: TID251
log_path = os.path.join(Paths.swaglog_root(), log_entry)
setxattr(log_path, log_attr_name, int.to_bytes(curr_time, 4, sys.byteorder))
setxattr(log_path, LOG_ATTR_NAME, int.to_bytes(curr_time, 4, sys.byteorder))
add_log_to_queue(log_path, log_entry, is_sunnylink)
add_log_to_queue(log_path, log_entry)
curr_log = log_entry
except OSError:
pass # file could be deleted by log rotation
@@ -970,7 +926,7 @@ def log_handler(end_event: threading.Event, log_attr_name=LOG_ATTR_NAME) -> None
if log_entry and log_success:
log_path = os.path.join(Paths.swaglog_root(), log_entry)
try:
setxattr(log_path, log_attr_name, LOG_ATTR_VALUE_MAX_UNIX_TIME)
setxattr(log_path, LOG_ATTR_NAME, LOG_ATTR_VALUE_MAX_UNIX_TIME)
except OSError:
pass # file could be deleted by log rotation
if curr_log == log_entry:
@@ -983,7 +939,7 @@ def log_handler(end_event: threading.Event, log_attr_name=LOG_ATTR_NAME) -> None
cloudlog.exception("athena.log_handler.exception")
def stat_handler(end_event: threading.Event, stats_dir=None, is_sunnylink=False) -> None:
def stat_handler(end_event: threading.Event, stats_dir=None) -> None:
stats_dir = stats_dir or Paths.stats_root()
last_scan = 0.0
@@ -996,27 +952,14 @@ def stat_handler(end_event: threading.Event, stats_dir=None, is_sunnylink=False)
stat_path = os.path.join(stats_dir, stat_filenames[0])
with open(stat_path) as f:
payload = f.read()
is_compressed = False
# Log the current size of the file
if is_sunnylink:
# Compress and encode the data if it exceeds the maximum size
compressed_data = gzip.compress(payload.encode())
payload = base64.b64encode(compressed_data).decode()
is_compressed = True
params: dict[str, str | bool] = {"stats": payload}
if is_sunnylink and is_compressed:
params["compressed"] = is_compressed
jsonrpc: dict = {
"method": "storeStats",
"params": params,
"jsonrpc": "2.0",
"id": stat_filenames[0]
}
send_queue_push(json.dumps(jsonrpc), SEND_PRIORITY_LOW)
jsonrpc = {
"method": "storeStats",
"params": {"stats": payload},
"jsonrpc": "2.0",
"id": stat_filenames[0],
}
send_queue_push(json.dumps(jsonrpc), SEND_PRIORITY_LOW)
os.remove(stat_path)
last_scan = curr_scan
except Exception:
@@ -12,7 +12,6 @@ from openpilot.common.hardware.hw import Paths
from openpilot.sunnypilot.mapd.mapd_manager import MAPD_PATH
from openpilot.sunnypilot.models.helpers import get_active_model_runner
from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, use_sunnylink_uploader
WEBCAM = os.getenv("USE_WEBCAM") is not None
@@ -74,18 +73,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 sunnylink_ready_shim(started, params, CP: car.CarParams) -> bool:
"""Shim for sunnylink_ready to match the process manager signature."""
return sunnylink_ready(params)
def sunnylink_need_register_shim(started, params, CP: car.CarParams) -> bool:
"""Shim for sunnylink_need_register to match the process manager signature."""
return sunnylink_need_register(params)
def use_sunnylink_uploader_shim(started, params, CP: car.CarParams) -> bool:
"""Shim for use_sunnylink_uploader to match the process manager signature."""
return use_sunnylink_uploader(params)
def is_tinygrad_model(started, params, CP: car.CarParams) -> bool:
"""Check if the active model runner is tinygrad."""
return bool(get_active_model_runner(params, not started) == custom.ModelManagerSP.Runner.tinygrad)
@@ -167,10 +154,6 @@ procs = [
PythonProcess("webrtcd", "openpilot.system.webrtc.webrtcd", or_(livestream, notcar)),
PythonProcess("joystick", "openpilot.tools.joystick.joystick_control", and_(joystick, iscar)),
# sunnylink <3
DaemonProcess("manage_sunnylinkd", "openpilot.sunnypilot.sunnylink.athena.manage_sunnylinkd", "SunnylinkdPid"),
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)),
]
# sunnypilot
@@ -180,9 +163,6 @@ procs += [
NativeProcess("modeld_tinygrad", "openpilot/sunnypilot/modeld_v2", ["./modeld"], and_(only_onroad, is_tinygrad_model)),
PythonProcess("modeld_warmup", "openpilot.sunnypilot.modeld_v2.warmup", only_offroad),
# Backup
PythonProcess("backup_manager", "openpilot.sunnypilot.sunnylink.backups.manager", and_(only_offroad, sunnylink_ready_shim)),
# mapd
NativeProcess("mapd", Paths.mapd_root(), ["bash", "-c", f"{MAPD_PATH} > /dev/null 2>&1"], mapd_ready),
PythonProcess("mapd_manager", "openpilot.sunnypilot.mapd.mapd_manager", always_run),
@@ -191,9 +171,6 @@ procs += [
NativeProcess("locationd_llk", "openpilot/sunnypilot/selfdrive/locationd", ["./locationd"], only_onroad),
]
if os.path.exists("../../sunnypilot/sunnylink/uploader.py"):
procs += [PythonProcess("sunnylink_uploader", "openpilot.sunnypilot.sunnylink.uploader", use_sunnylink_uploader_shim)]
if os.path.exists("../../third_party/copyparty/copyparty-sfx.py"):
sunnypilot_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
copyparty_args = [f"-v{Paths.crash_log_root()}:/swaglogs:r"]
@@ -1,139 +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 pyray as rl
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID, API_HOST
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.text_measure import measure_text_cached
class SunnylinkPairingDialog(PairingDialog):
"""Dialog for device pairing with QR code."""
QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
def __init__(self, sponsor_pairing: bool = False):
PairingDialog.__init__(self)
self._sponsor_pairing = sponsor_pairing
self._is_paired_prev = ui_state.sunnylink_state.is_paired()
def _get_pairing_url(self) -> str:
qr_string = "https://github.com/sponsors/sunnyhaibin"
if self._sponsor_pairing:
try:
sl_dongle_id = self.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID
token = SunnylinkApi(sl_dongle_id).get_token()
inner_string = f"1|{sl_dongle_id}|{token}"
payload_bytes = base64.b64encode(inner_string.encode('utf-8')).decode('utf-8')
qr_string = f"{API_HOST}/sso?state={payload_bytes}"
except Exception:
cloudlog.exception("Failed to get pairing token")
return qr_string
def _update_state(self):
is_paired = ui_state.sunnylink_state.is_paired()
if not self._is_paired_prev and is_paired:
gui_app.pop_widget()
def _render(self, rect: rl.Rectangle) -> int:
rl.clear_background(rl.Color(224, 224, 224, 255))
self._check_qr_refresh()
margin = 70
content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - 2 * margin, rect.height - 2 * margin)
y = content_rect.y
# Close button
close_size = 80
pad = 20
close_rect = rl.Rectangle(content_rect.x - pad, y - pad, close_size + pad * 2, close_size + pad * 2)
self._close_btn.render(close_rect)
y += close_size + 40
# Title
title = tr("Pair your GitHub account") if self._sponsor_pairing else tr("Early Access: Become a sunnypilot Sponsor")
title_font = gui_app.font(FontWeight.NORMAL)
left_width = int(content_rect.width * 0.5 - 15)
title_wrapped = wrap_text(title_font, title, 75, left_width)
rl.draw_text_ex(title_font, "\n".join(title_wrapped), rl.Vector2(content_rect.x, y), 75, 0.0, rl.BLACK)
y += len(title_wrapped) * 75 + 60
# Two columns: instructions and QR code
remaining_height = content_rect.height - (y - content_rect.y)
right_width = content_rect.width // 2 - 20
# Instructions
self._render_instructions(rl.Rectangle(content_rect.x, y, left_width, remaining_height))
# QR code
qr_size = min(right_width, content_rect.height) - 40
qr_x = content_rect.x + left_width + 40 + (right_width - qr_size) // 2
qr_y = content_rect.y
self._render_qr_code(rl.Rectangle(qr_x, qr_y, qr_size, qr_size))
return -1
def _render_instructions(self, rect: rl.Rectangle) -> None:
if self._sponsor_pairing:
instructions = [
tr("Scan the QR code to login to your GitHub account"),
tr("Follow the prompts to complete the pairing process"),
tr("Re-enter the \"sunnylink\" panel to verify sponsorship status"),
tr("If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai")
]
else:
instructions = [
tr("Scan the QR code to visit sunnyhaibin's GitHub Sponsors page"),
tr("Choose your sponsorship tier and confirm your support"),
tr("Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues")
]
font = gui_app.font(FontWeight.BOLD)
y = rect.y
for i, text in enumerate(instructions):
circle_radius = 25
circle_x = rect.x + circle_radius + 15
text_x = rect.x + circle_radius * 2 + 40
text_width = rect.width - (circle_radius * 2 + 40)
wrapped = wrap_text(font, text, 47, int(text_width))
text_height = len(wrapped) * 47
circle_y = y + text_height // 2
# Circle and number
rl.draw_circle(int(circle_x), int(circle_y), circle_radius, rl.Color(70, 70, 70, 255))
number = str(i + 1)
number_size = measure_text_cached(font, number, 30)
rl.draw_text_ex(font, number, (int(circle_x - number_size.x // 2), int(circle_y - number_size.y // 2)), 30, 0, rl.WHITE)
# Text
rl.draw_text_ex(font, "\n".join(wrapped), rl.Vector2(text_x, y), 47, 0.0, rl.BLACK)
y += text_height + 50
if __name__ == "__main__":
gui_app.init_window("pairing device")
pairing = SunnylinkPairingDialog(sponsor_pairing=True)
try:
for _ in gui_app.render():
result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if result != -1:
break
finally:
del pairing
+1 -1
View File
@@ -6,7 +6,7 @@ export SIMULATION="1"
export SKIP_FW_QUERY="1"
export FINGERPRINT="HONDA_CIVIC_2022"
export BLOCK="${BLOCK},camerad,loggerd,encoderd,micd,logmessaged,manage_athenad,manage_sunnylinkd"
export BLOCK="${BLOCK},camerad,loggerd,encoderd,micd,logmessaged,manage_athenad"
if [[ "$CI" ]]; then
# TODO: offscreen UI should work
export BLOCK="${BLOCK},ui"