mirror of
https://jihulab.com/mr-one/openpilot.git
synced 2026-10-27 04:53:44 +08:00
sunnypilot: remove Sunnylink integration
This commit is contained in:
Binary file not shown.
@@ -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}},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user