diff --git a/selfdrive/ui/sunnypilot/layouts/settings/settings.py b/selfdrive/ui/sunnypilot/layouts/settings/settings.py
index 049ad1bd1..18d33029b 100644
--- a/selfdrive/ui/sunnypilot/layouts/settings/settings.py
+++ b/selfdrive/ui/sunnypilot/layouts/settings/settings.py
@@ -12,7 +12,7 @@ from openpilot.selfdrive.ui.layouts.settings import settings as OP
from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.device import DeviceLayoutSP
from openpilot.selfdrive.ui.layouts.settings.firehose import FirehoseLayout
-from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.software import SoftwareLayoutSP
from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout
from openpilot.selfdrive.ui.layouts.settings.ictoggles import ICTogglesLayout
from openpilot.system.ui.lib.application import gui_app, MousePos
@@ -116,7 +116,7 @@ class SettingsLayoutSP(OP.SettingsLayout):
OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/wifi_strength_full.png"),
OP.PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_toggle.png"),
OP.PanelType.ICTOGGLES: PanelInfo(tr_noop("infiniteCable"), ICTogglesLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_toggle.png"),
- OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_software.png"),
+ 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"),
OP.PanelType.STEERING: PanelInfo(tr_noop("Steering"), SteeringLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_lateral.png"),
OP.PanelType.CRUISE: PanelInfo(tr_noop("Cruise"), CruiseLayout(), icon="icons/speed_limit.png"),
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/software.py b/selfdrive/ui/sunnypilot/layouts/settings/software.py
new file mode 100644
index 000000000..b890dd5b5
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/software.py
@@ -0,0 +1,96 @@
+"""
+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 os
+
+from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.hardware import HARDWARE
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.lib.multilang import tr, tr_noop
+from openpilot.system.ui.widgets import DialogResult
+from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
+
+from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
+from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder
+
+
+DESCRIPTIONS = {
+ 'disable_updates_offroad': tr_noop(
+ "When enabled, automatic software updates will be off.
This requires a reboot to take effect."
+ ),
+ 'disable_updates_onroad': tr_noop(
+ "Please enable \"Always Offroad\" mode or turn off the vehicle to adjust these toggles."
+ )
+}
+
+
+class SoftwareLayoutSP(SoftwareLayout):
+ def __init__(self):
+ super().__init__()
+ self.disable_updates_toggle = toggle_item_sp(
+ lambda: tr("Disable Updates"),
+ description="",
+ initial_state=ui_state.params.get_bool("DisableUpdates"),
+ callback=self._on_disable_updates_toggled,
+ )
+ self._scroller.add_widget(self.disable_updates_toggle)
+
+ def _handle_reboot(self, result):
+ if result == DialogResult.CONFIRM:
+ ui_state.params.put_bool("DisableUpdates", self.disable_updates_toggle.action_item.get_state())
+ ui_state.params.put_bool("DoReboot", True)
+ else:
+ self.disable_updates_toggle.action_item.set_state(ui_state.params.get_bool("DisableUpdates"))
+
+ def _on_disable_updates_toggled(self, enabled):
+ dialog = ConfirmDialog(tr("System reboot required for changes to take effect. Reboot now?"), tr("Reboot"))
+ gui_app.set_modal_overlay(dialog, callback=self._handle_reboot)
+
+ def _on_select_branch(self):
+ current_git_branch = ui_state.params.get("GitBranch") or ""
+ branches_str = ui_state.params.get("UpdaterAvailableBranches") or ""
+ branches = [b for b in branches_str.split(",") if b]
+ current_target = ui_state.params.get("UpdaterTargetBranch") or ""
+ top_level_branches = [current_git_branch, "release-mici", "release-tizi", "staging", "dev", "master"]
+
+ if HARDWARE.get_device_type() == "tici":
+ top_level_branches = ["release-tici", "staging-tici"]
+ branches = [b for b in branches if b.endswith("-tici")]
+
+ top_level_nodes = [TreeNode(b, {'display_name': b}) for b in top_level_branches if b in branches]
+ remaining_branches = [b for b in branches if b not in top_level_branches]
+ prebuilt_nodes = [TreeNode(b, {'display_name': b}) for b in remaining_branches if b.endswith("-prebuilt")]
+ non_prebuilt_nodes = [TreeNode(b, {'display_name': b}) for b in remaining_branches if not b.endswith("-prebuilt")]
+
+ folders = [
+ TreeFolder("", top_level_nodes),
+ TreeFolder("Prebuilt Branches", prebuilt_nodes),
+ TreeFolder("Non-Prebuilt Branches", non_prebuilt_nodes),
+ ]
+
+ def _on_branch_selected(result):
+ if result == DialogResult.CONFIRM and self._branch_dialog is not None:
+ selection = self._branch_dialog.selection_ref
+ if selection:
+ ui_state.params.put("UpdaterTargetBranch", selection)
+ self._branch_btn.action_item.set_value(selection)
+ os.system("pkill -SIGUSR1 -f system.updated.updated")
+ self._branch_dialog = None
+
+ self._branch_dialog = TreeOptionDialog(tr("Select a branch"), folders, current_target, "",
+ on_exit=_on_branch_selected)
+
+ gui_app.set_modal_overlay(self._branch_dialog, callback=_on_branch_selected)
+
+ def _update_state(self):
+ super()._update_state()
+ show_advanced = ui_state.params.get_bool("ShowAdvancedControls")
+ self.disable_updates_toggle.action_item.set_enabled(ui_state.is_offroad())
+ self.disable_updates_toggle.set_visible(show_advanced)
+
+ disable_updates_desc = tr(DESCRIPTIONS["disable_updates_offroad"] if ui_state.is_offroad() else DESCRIPTIONS["disable_updates_onroad"])
+ self.disable_updates_toggle.set_description(disable_updates_desc)
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
index 7125fdf3f..316d4d224 100644
--- a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
+++ b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
@@ -328,12 +328,11 @@ class SunnylinkLayout(Widget):
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 and not ui_state.sunnylink_state.is_sponsor())
+ 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.set_visible(lambda: self._sunnylink_enabled and not ui_state.sunnylink_state.is_paired())
- self._pair_btn.action_item.set_enabled(self._sunnylink_enabled and not ui_state.sunnylink_state.is_paired())
+ self._pair_btn.action_item.set_enabled(self._sunnylink_enabled)
def _render(self, rect):
self._scroller.render(rect)
diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py
index bd68d35a4..ac646df5b 100644
--- a/system/ui/sunnypilot/widgets/list_view.py
+++ b/system/ui/sunnypilot/widgets/list_view.py
@@ -165,8 +165,10 @@ class ListItemSP(ListItem):
content_width = item_rect.width - (style.ITEM_PADDING * 2)
title_width = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE).x
right_width = min(content_width - title_width, right_width)
-
- action_x = item_rect.x + item_rect.width - right_width
+ if isinstance(self.action_item, ToggleAction):
+ action_x = item_rect.x
+ else:
+ action_x = item_rect.x + item_rect.width - right_width
action_y = item_rect.y
return rl.Rectangle(action_x, action_y, right_width, style.ITEM_BASE_HEIGHT)
diff --git a/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py b/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
index 4fab79caf..af6b9cf45 100644
--- a/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
+++ b/system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py
@@ -25,6 +25,7 @@ class SunnylinkPairingDialog(PairingDialog):
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"
@@ -42,7 +43,8 @@ class SunnylinkPairingDialog(PairingDialog):
return qr_string
def _update_state(self):
- if ui_state.sunnylink_state.is_paired():
+ is_paired = ui_state.sunnylink_state.is_paired()
+ if not self._is_paired_prev and is_paired:
gui_app.set_modal_overlay(None)
def _render(self, rect: rl.Rectangle) -> int:
diff --git a/system/ui/sunnypilot/widgets/toggle.py b/system/ui/sunnypilot/widgets/toggle.py
index 46b390f77..2924aec2b 100644
--- a/system/ui/sunnypilot/widgets/toggle.py
+++ b/system/ui/sunnypilot/widgets/toggle.py
@@ -24,6 +24,9 @@ class ToggleSP(Toggle):
initial_state = self.params.get_bool(self.param_key)
Toggle.__init__(self, initial_state, callback)
+ def set_rect(self, rect: rl.Rectangle):
+ self._rect = rl.Rectangle(rect.x, rect.y, style.TOGGLE_WIDTH, style.TOGGLE_HEIGHT)
+
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
if self._enabled and self.param_key:
diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py
index cfc0a5caf..2dd5e3a2d 100644
--- a/system/ui/sunnypilot/widgets/tree_dialog.py
+++ b/system/ui/sunnypilot/widgets/tree_dialog.py
@@ -78,7 +78,7 @@ class TreeItemWidget(Button):
class TreeOptionDialog(MultiOptionDialog):
def __init__(self, title, folders, current_ref="", fav_param="", option_font_weight=FontWeight.MEDIUM, search_prompt=None,
get_folders_fn=None, on_exit=None, display_func=None, search_funcs=None, search_title=None, search_subtitle=None):
- super().__init__(title, [], "", option_font_weight)
+ super().__init__(title, [], current_ref, option_font_weight)
self.folders = folders
self.selection_ref = current_ref
self.fav_param = fav_param
@@ -101,6 +101,23 @@ class TreeOptionDialog(MultiOptionDialog):
self.search_dialog = None
self._search_pressed = False
+ self.selection_node = None
+ # Try to match by ref, by display text, or fall back to "Default" when no ref is set
+ for folder in self.folders:
+ for node in folder.nodes:
+ display = self.display_func(node)
+ if (
+ node.ref == current_ref or
+ display == current_ref or
+ (not current_ref and node.ref == "Default")
+ ):
+ self.selection = display
+ self.current = display
+ self.selection_node = node
+ break
+ if self.selection_node is not None:
+ break
+
self._build_visible_items()
def _on_search_confirm(self, result, text):
@@ -142,6 +159,17 @@ class TreeOptionDialog(MultiOptionDialog):
def _build_visible_items(self, reset_scroll=True):
self.visible_items = []
+
+ # Pinned selected item at the very top (if any)
+ if getattr(self, "selection_node", None) is not None:
+ node = self.selection_node
+ display = self.display_func(node)
+ self.selection = self.current = display
+ favorite_cb = (lambda node_ref=node: self._toggle_favorite(node_ref)) if self.fav_param and node.ref != "Default" else None
+ self.visible_items.append(TreeItemWidget(self.display_func(node), node.ref, False, 0,
+ lambda node_ref=node: self._select_node(node_ref),
+ favorite_cb, node.ref in self.favorites, is_expanded=True))
+
for folder in self.folders:
nodes = [node for node in folder.nodes if not self.query or search_from_list(self.query, [search_func(node) for search_func in self.search_funcs])]
if not nodes and self.query:
@@ -152,10 +180,15 @@ class TreeOptionDialog(MultiOptionDialog):
lambda folder_ref=folder: self._toggle_folder(folder_ref)))
if expanded:
for node in nodes:
+ # Skip duplicate root-level item for the selected node
+ if self.selection_node is not None and node.ref == self.selection_node.ref and not folder.folder:
+ continue
+
favorite_cb = (lambda node_ref=node: self._toggle_favorite(node_ref)) if self.fav_param and node.ref != "Default" else None
self.visible_items.append(TreeItemWidget(self.display_func(node), node.ref, False, 1 if folder.folder else 0,
lambda node_ref=node: self._select_node(node_ref),
favorite_cb, node.ref in self.favorites, is_expanded=expanded))
+
self.option_buttons = self.visible_items
self.options = [item.text for item in self.visible_items]
self.scroller._items = self.visible_items