Compare commits

..

74 Commits

Author SHA1 Message Date
nayan d57983c680 sunnylink panel 2025-11-22 21:55:44 -05:00
nayan b3a9169a96 Merge branch 'rl-sunnylink-panel' into mici-playground 2025-11-22 14:45:57 -05:00
nayan 9e794892e3 Merge branch 'py-sunnylink' into mici-playground 2025-11-22 13:41:14 -05:00
nayan abd19ca8da Merge branch 'py-ui-state-sp' into mici-playground 2025-11-22 13:41:11 -05:00
nayan fb1b0655c4 Merge remote-tracking branch 'origin/master' into py-sunnylink
# Conflicts:
#	system/ui/sunnypilot/lib/styles.py
#	system/ui/sunnypilot/widgets/list_view.py
#	system/ui/sunnypilot/widgets/toggle.py
2025-11-22 13:33:49 -05:00
nayan 01842dbdca fetch only when connected to network 2025-11-22 13:31:15 -05:00
nayan 8fa22cd14c init sunnylink panel 2025-11-22 12:35:33 -05:00
nayan 471621ff84 Merge branch 'py-ui-state-sp' into rl-sunnylink-panel 2025-11-21 18:15:37 -05:00
nayan 3f664201a6 Merge branch 'rl-sp-panels' into rl-sunnylink-panel 2025-11-21 18:15:13 -05:00
nayan 75a3591cd6 Merge remote-tracking branch 'origin/ui-gui-app-ext' into rl-sunnylink-panel 2025-11-21 18:14:44 -05:00
nayan 5d3f95d420 use gui_app.sunnypilot_ui() 2025-11-21 18:08:19 -05:00
nayan f8d19fe9dd Merge branch 'rl-sp-toggles' into rl-sp-panels 2025-11-21 18:07:19 -05:00
nayan 9d711350c2 Merge remote-tracking branch 'origin/ui-gui-app-ext' into rl-sp-panels 2025-11-21 18:07:03 -05:00
nayan 45c853c87a Merge remote-tracking branch 'origin/ui-gui-app-ext' into py-ui-state-sp 2025-11-21 17:57:35 -05:00
nayan e8ab9d812d use gui_app.sunnypilot_ui() 2025-11-21 17:57:06 -05:00
nayan 5957db94f6 use gui_app.sunnypilot_ui() 2025-11-21 17:55:00 -05:00
nayan ef1810913e Merge branch 'rl-sp-toggles' into py-sunnylink 2025-11-21 17:51:03 -05:00
nayan ed775185f2 use gui_app.sunnypilot_ui() 2025-11-21 17:49:27 -05:00
nayan 7bbbc6588e Merge remote-tracking branch 'origin/ui-gui-app-ext' into rl-sp-toggles 2025-11-21 17:42:23 -05:00
nayan e68c65d15d Merge remote-tracking branch 'origin/master' into rl-sp-toggles 2025-11-21 17:40:10 -05:00
Jason Wen 0db8722221 Merge branch 'master' into ui-gui-app-ext 2025-11-21 17:24:14 -05:00
Jason Wen a33497ed19 add to readme 2025-11-21 16:42:59 -05:00
Jason Wen 91f2bf3459 ui: GuiApplicationExt 2025-11-21 16:23:01 -05:00
Jason Wen 7fad2fc189 Merge branch 'master' into rl-sp-toggles 2025-11-21 15:55:34 -05:00
nayan 9f303e9ea9 Merge remote-tracking branch 'origin/master' into py-sunnylink 2025-11-21 15:38:52 -05:00
nayan e74252bdf5 Merge remote-tracking branch 'origin/master' into py-ui-state-sp 2025-11-21 15:37:33 -05:00
nayan b5a525b280 Merge branch 'rl-sp-panels' into rl-sunnylink-panel
# Conflicts:
#	selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py
2025-11-21 15:28:50 -05:00
nayan 48d33e98e7 scroller -> scroller_tici 2025-11-21 15:27:51 -05:00
nayan 009d350a58 Merge branch 'rl-sp-panels' into rl-sunnylink-panel 2025-11-21 15:26:06 -05:00
nayan 49e441dd1a Merge remote-tracking branch 'origin/master' into rl-sunnylink-panel 2025-11-21 15:25:58 -05:00
nayan 2717d97350 scroller -> scroller_tici 2025-11-21 15:20:31 -05:00
nayan 64232397ed Merge remote-tracking branch 'origin/master' into rl-sp-panels 2025-11-21 15:16:50 -05:00
Jason Wen 0613442ac9 Merge branch 'master' into rl-sp-toggles 2025-11-21 15:00:14 -05:00
nayan e6f5aae246 remove padding from line separator.
like, WHY? 😩😩
2025-11-20 18:05:12 -05:00
nayan 7032e4a972 add show_description method 2025-11-20 18:00:44 -05:00
nayan ffa78eabaa Revert "add ui_update callback"
This reverts commit 4da32cc009.
2025-11-20 17:58:19 -05:00
nayan 5b03369a8f listitem -> listitemsp 2025-11-20 17:56:26 -05:00
nayan 8c3cf8e542 better 2025-11-20 10:54:46 -05:00
nayan 1e0564b484 this 2025-11-20 08:05:20 -05:00
nayan eb94abaa14 better padding 2025-11-19 23:44:05 -05:00
nayan bf673a38c8 change it up 2025-11-19 23:29:06 -05:00
nayan 4da32cc009 add ui_update callback 2025-11-19 23:03:56 -05:00
nayan 47687d4664 not needed 2025-11-19 22:58:05 -05:00
nayan b7d1d82e1e handle layout updates 2025-11-19 22:50:56 -05:00
nayan 63fe9d8f9c enable, disable, enable, disable 2025-11-19 22:03:30 -05:00
nayan ddb71f81d7 update 2025-11-19 21:57:36 -05:00
nayan b65c550cc8 fruit loops 2025-11-19 21:12:06 -05:00
nayan 6b45d2648b backup & restore 2025-11-19 14:02:58 -05:00
nayan a8b319cfff init panel elements 2025-11-19 13:01:20 -05:00
nayan 6b1da28c01 sponsor & pairing qr 2025-11-19 13:01:09 -05:00
nayan d3d812dd92 Merge branch 'refs/heads/py-sunnylink' into rl-sunnylink-panel 2025-11-19 12:28:09 -05:00
nayan b670c6a056 Merge branch 'refs/heads/py-ui-state-sp' into rl-sunnylink-panel 2025-11-19 12:26:00 -05:00
nayan c270268d3a Merge branch 'py-ui-state-sp' into py-sunnylink
# Conflicts:
#	selfdrive/ui/sunnypilot/ui_state.py
2025-11-18 19:11:26 -05:00
nayan 4820265268 better 2025-11-18 19:09:46 -05:00
nayan 01aa6c4204 param to control stock vs sp ui 2025-11-18 18:51:52 -05:00
nayan 6d6c975bfb cloudlog & ruff 2025-11-18 16:34:32 -05:00
nayan accf09c34e poll from ui_state_sp 2025-11-18 16:28:55 -05:00
nayan 3a3f7a3843 Merge branch 'refs/heads/py-ui-state-sp' into py-sunnylink 2025-11-18 16:27:54 -05:00
nayan 21beea51ec introducing ui_state_sp for py 2025-11-18 16:26:48 -05:00
nayan 06c1557785 sunnylink state 2025-11-18 16:20:23 -05:00
nayan a9e57f0a76 add ui previews 2025-11-16 12:47:37 -05:00
nayan 712a358c94 Merge branch 'rl-sp-toggles' into rl-sp-panels 2025-11-16 11:23:31 -05:00
nayan 423a7d2ed0 fix ui preview 2025-11-16 11:15:28 -05:00
nayan e4e10d4b87 fix callback 2025-11-16 11:15:22 -05:00
nayan 632e9d13b2 Merge branch 'rl-sp-toggles' into rl-sp-panels 2025-11-16 09:55:25 -05:00
nayan 362e9ce04b sp raylib preview 2025-11-16 09:53:28 -05:00
nayan 51d0666c85 more patience, grasshopper 2025-11-16 09:33:35 -05:00
nayan deda1329a2 patience, grasshopper 2025-11-16 09:33:35 -05:00
nayan 4110749cb0 Panels. With Icons. And Scroller. 2025-11-16 09:33:35 -05:00
nayan 3946e643f6 optimizations 2025-11-16 09:29:58 -05:00
nayan 0c37a38596 Lint 2025-11-16 09:29:58 -05:00
nayan 9c5acf61c0 SP Toggles 2025-11-16 09:29:58 -05:00
nayan 121b304fe0 init styles 2025-11-16 09:29:58 -05:00
nayan 47d848293b param to control stock vs sp ui 2025-11-16 09:29:58 -05:00
172 changed files with 7839 additions and 4663 deletions
@@ -6,10 +6,10 @@ env:
CI_DIR: ${{ github.workspace }}/release/ci
SCONS_CACHE_DIR: ${{ github.workspace }}/release/ci/scons_cache
PUBLIC_REPO_URL: "https://github.com/sunnypilot/sunnypilot"
# Branch configurations
STAGING_SOURCE_BRANCH: 'master'
# Runtime configuration
SOURCE_BRANCH: "${{ github.head_ref || github.ref_name }}"
@@ -75,7 +75,7 @@ jobs:
cancel="$(echo "$CONFIG" | jq -r '.cancel_publish_in_progress')";
echo "cancel_publish_in_progress=$( [ "$cancel" = "null" ] && echo "true" || echo $cancel)" >> $GITHUB_OUTPUT
echo "publish_concurrency_group=publish-${BRANCH}$( [ "$cancel" = "null" ] || [ "$cancel" = "true" ] || echo "${{ github.sha }}" )" >> $GITHUB_OUTPUT
is_stable_branch="$(echo "$CONFIG" | jq -r '.stable_branch // false')";
echo "is_stable_branch=$is_stable_branch" >> $GITHUB_OUTPUT
@@ -85,7 +85,7 @@ jobs:
fi
echo "build=$BUILD" >> $GITHUB_OUTPUT
cat $GITHUB_OUTPUT
validate_tests:
runs-on: ubuntu-24.04
needs: [ prepare_strategy ]
@@ -119,7 +119,7 @@ jobs:
needs.prepare_strategy.result == 'success' &&
(needs.validate_tests.result == 'success' || needs.validate_tests.result == 'skipped') &&
(!contains(github.event_name, 'pull_request') ||
(github.event.action == 'labeled' && github.event.label.name == 'prebuilt'))
(github.event.action == 'labeled' && github.event.label.name == 'prebuilt'))
}}
steps:
- uses: actions/checkout@v4
@@ -134,7 +134,7 @@ jobs:
with:
path: ${{env.SCONS_CACHE_DIR}}
key: scons-${{ runner.os }}-${{ runner.arch }}-${{ env.SOURCE_BRANCH }}-${{ github.sha }}
# Note: GitHub Actions enforces cache isolation between different build sources (PR builds, workflow dispatches, etc.)
# Note: GitHub Actions enforces cache isolation between different build sources (PR builds, workflow dispatches, etc.)
# for security. Only caches from the default branch are shared across all builds. This is by design and cannot be overridden.
restore-keys: |
scons-${{ runner.os }}-${{ runner.arch }}-${{ env.SOURCE_BRANCH }}
@@ -148,7 +148,7 @@ jobs:
echo "version=${{ needs.prepare_strategy.outputs.version }}" >> $GITHUB_OUTPUT
echo "extra_version_identifier=${{ needs.prepare_strategy.outputs.extra_version_identifier }}" >> $GITHUB_OUTPUT
echo "commit_sha=${{ github.sha }}" >> $GITHUB_OUTPUT
# Set up common environment
source /etc/profile;
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
@@ -180,15 +180,6 @@ jobs:
./release/release_files.py | sort | uniq | rsync -rRl${RUNNER_DEBUG:+v} --files-from=- . $BUILD_DIR/
cd $BUILD_DIR
sed -i '/from .board.jungle import PandaJungle, PandaJungleDFU/s/^/#/' panda/__init__.py
echo "Building sunnypilot's modeld..."
scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/modeld
echo "Building sunnypilot's modeld_v2..."
scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/modeld_v2
echo "Building sunnypilot's locationd..."
scons -j2 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/selfdrive/locationd
echo "Building openpilot's locationd..."
scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal selfdrive/locationd
echo "Building rest of sunnypilot"
scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal
touch ${BUILD_DIR}/prebuilt
if [[ "${{ runner.debug }}" == "1" ]]; then
@@ -250,8 +241,8 @@ jobs:
if: always()
run: |
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
publish:
concurrency:
# We do a bit of a hack here to avoid canceling the publishing job if a new commit comes in while we're publishing by adding the sha to the group name.
@@ -302,7 +293,7 @@ jobs:
echo "1. Go to: ${{ github.server_url }}/${{ github.repository }}/settings/variables/actions/AUTO_DEPLOY_PREBUILT_BRANCHES"
echo "2. Current value: ${{ vars.AUTO_DEPLOY_PREBUILT_BRANCHES }}"
echo "3. Update as needed (JSON array with no spaces)"
- name: Tag ${{ needs.prepare_strategy.outputs.environment }}
if: ${{ needs.prepare_strategy.outputs.is_stable_branch == 'true' && (github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')) }}
run: |
@@ -311,7 +302,7 @@ jobs:
git push -f origin ${TAG}
notify:
needs:
needs:
- prepare_strategy
- build
- publish
@@ -340,7 +331,7 @@ jobs:
${{ vars.DISCOURSE_GENERAL_UPDATE_NOTICE }}
EOF
)
{
echo 'content<<EOFMARKER'
echo "$MESSAGE"
+10
View File
@@ -75,6 +75,7 @@ env = Environment(
"#third_party/acados/include/hpipm/include",
"#third_party/catch2/include",
"#third_party/libyuv/include",
"#third_party/snpe/include",
],
LIBPATH=[
"#common",
@@ -100,6 +101,7 @@ if arch == "larch64":
"/usr/local/lib",
"/system/vendor/lib64",
"/usr/lib/aarch64-linux-gnu",
"#third_party/snpe/larch64",
])
arch_flags = ["-D__TICI__", "-mcpu=cortex-a57", "-DQCOM2"]
env.Append(CCFLAGS=arch_flags)
@@ -123,6 +125,14 @@ else:
"/usr/local/lib",
])
if arch == "x86_64":
env.Append(LIBPATH=[
f"#third_party/snpe/{arch}"
])
env.Append(RPATH=[
Dir(f"#third_party/snpe/{arch}").abspath,
])
# Sanitizers and extra CCFLAGS from CLI
if GetOption('asan'):
env.Append(CCFLAGS=["-fsanitize=address", "-fno-omit-frame-pointer"])
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:434a720871336d359378beff5ebff3f9fd654d958693d272c7c6f2e271c7e41c
size 47676
+28 -8
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env python3
import math
import threading
import time
from numbers import Number
from cereal import car, log
@@ -20,6 +22,8 @@ from openpilot.selfdrive.controls.lib.longcontrol import LongControl
from openpilot.selfdrive.modeld.modeld import LAT_SMOOTH_SECONDS
from openpilot.selfdrive.locationd.helpers import PoseCalibrator, Pose
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
from openpilot.sunnypilot.modeld.modeld_base import ModelStateBase
from openpilot.sunnypilot.selfdrive.controls.controlsd_ext import ControlsExt
State = log.SelfdriveState.OpenpilotState
@@ -29,7 +33,7 @@ LaneChangeDirection = log.LaneChangeDirection
ACTUATOR_FIELDS = tuple(car.CarControl.Actuators.schema.fields.keys())
class Controls(ControlsExt):
class Controls(ControlsExt, ModelStateBase):
def __init__(self) -> None:
self.params = Params()
cloudlog.info("controlsd is waiting for CarParams")
@@ -38,6 +42,7 @@ class Controls(ControlsExt):
# Initialize sunnypilot controlsd extension and base model state
ControlsExt.__init__(self, self.CP, self.params)
ModelStateBase.__init__(self)
self.CI = interfaces[self.CP.carFingerprint](self.CP, self.CP_SP)
@@ -226,15 +231,30 @@ class Controls(ControlsExt):
cc_send.carControl = CC
self.pm.send('carControl', cc_send)
def params_thread(self, evt):
while not evt.is_set():
self.get_params_sp()
if self.CP.lateralTuning.which() == 'torque':
self.lat_delay = get_lat_delay(self.params, self.sm["liveDelay"].lateralDelay)
time.sleep(0.1)
def run(self):
rk = Ratekeeper(100, print_delay_threshold=None)
while True:
self.update()
CC, lac_log = self.state_control()
self.publish(CC, lac_log)
self.get_params_sp(self.sm)
self.run_ext(self.sm, self.pm)
rk.monitor_time()
e = threading.Event()
t = threading.Thread(target=self.params_thread, args=(e,))
try:
t.start()
while True:
self.update()
CC, lac_log = self.state_control()
self.publish(CC, lac_log)
self.run_ext(self.sm, self.pm)
rk.monitor_time()
finally:
e.set()
t.join()
def main():
+1 -1
View File
@@ -227,6 +227,6 @@ class HomeLayout(Widget):
self._prev_alerts_present = alerts_present
def _get_version_text(self) -> str:
brand = "sunnypilot"
brand = "openpilot"
description = self.params.get("UpdaterCurrentDescription")
return f"{brand} {description}" if description else brand
-1
View File
@@ -13,7 +13,6 @@ from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow
if gui_app.sunnypilot_ui():
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.settings import SettingsLayoutSP as SettingsLayout
class MainState(IntEnum):
HOME = 0
SETTINGS = 1
+4 -4
View File
@@ -109,8 +109,8 @@ class TermsPage(Widget):
self._on_accept = on_accept
self._on_decline = on_decline
self._title = Label(tr("Welcome to sunnypilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._desc = Label(tr("You must accept the Terms and Conditions to use sunnypilot. Read the latest terms at https://comma.ai/terms before continuing."),
self._title = Label(tr("Welcome to openpilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._desc = Label(tr("You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing."),
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._decline_btn = Button(tr("Decline"), click_callback=on_decline)
@@ -143,10 +143,10 @@ class TermsPage(Widget):
class DeclinePage(Widget):
def __init__(self, back_callback=None):
super().__init__()
self._text = Label(tr("You must accept the Terms and Conditions in order to use sunnypilot."),
self._text = Label(tr("You must accept the Terms and Conditions in order to use openpilot."),
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._back_btn = Button(tr("Back"), click_callback=back_callback)
self._uninstall_btn = Button(tr("Decline, uninstall sunnypilot"), button_style=ButtonStyle.DANGER,
self._uninstall_btn = Button(tr("Decline, uninstall openpilot"), button_style=ButtonStyle.DANGER,
click_callback=self._on_uninstall_clicked)
def _on_uninstall_clicked(self):
+7 -8
View File
@@ -1,3 +1,4 @@
from openpilot.common.params import Params
from openpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.widgets import Widget
@@ -22,11 +23,10 @@ DESCRIPTIONS = {
"other than your own. A comma employee will NEVER ask you to add their GitHub username."
),
'alpha_longitudinal': tr_noop(
"<b>WARNING: sunnypilot longitudinal control is in alpha for this car and will disable Automatic Emergency Braking (AEB).</b><br><br>" +
"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."
"<b>WARNING: openpilot longitudinal control is in alpha for this car and will disable Automatic Emergency Braking (AEB).</b><br><br>" +
"On this car, openpilot defaults to the car's built-in ACC instead of openpilot's longitudinal control. " +
"Enable this to switch to openpilot longitudinal control. Enabling Experimental mode is recommended when enabling openpilot longitudinal control alpha. " +
"Changing this setting will restart openpilot if the car is powered on."
),
}
@@ -34,7 +34,7 @@ DESCRIPTIONS = {
class DeveloperLayout(Widget):
def __init__(self):
super().__init__()
self._params = ui_state.params
self._params = Params()
self._is_release = self._params.get_bool("IsReleaseBranch")
# Build items and keep references for callbacks/state updates
@@ -71,7 +71,7 @@ class DeveloperLayout(Widget):
)
self._alpha_long_toggle = toggle_item(
lambda: tr("sunnypilot Longitudinal Control (Alpha)"),
lambda: tr("openpilot Longitudinal Control (Alpha)"),
description=lambda: tr(DESCRIPTIONS["alpha_longitudinal"]),
initial_state=self._params.get_bool("AlphaLongitudinalEnabled"),
callback=self._on_alpha_long_enabled,
@@ -148,7 +148,6 @@ class DeveloperLayout(Widget):
self._params.put_bool("ShowDebugInfo", state)
gui_app.set_show_touches(state)
gui_app.set_show_fps(state)
gui_app.set_show_mouse_coords(state)
def _on_enable_adb(self, state: bool):
self._params.put_bool("AdbEnabled", state)
+6 -8
View File
@@ -3,6 +3,7 @@ import math
from cereal import messaging, log
from openpilot.common.basedir import BASEDIR
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog
from openpilot.selfdrive.ui.ui_state import ui_state
@@ -18,15 +19,12 @@ from openpilot.system.ui.widgets.list_view import text_item, button_item, dual_b
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.scroller_tici import Scroller
if gui_app.sunnypilot_ui():
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item
# Description constants
DESCRIPTIONS = {
'pair_device': tr_noop("Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer."),
'driver_camera': tr_noop("Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)"),
'reset_calibration': tr_noop("sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down."),
'review_guide': tr_noop("Review the rules, features, and limitations of sunnypilot"),
'reset_calibration': tr_noop("openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down."),
'review_guide': tr_noop("Review the rules, features, and limitations of openpilot"),
}
@@ -34,7 +32,7 @@ class DeviceLayout(Widget):
def __init__(self):
super().__init__()
self._params = ui_state.params
self._params = Params()
self._select_language_dialog: MultiOptionDialog | None = None
self._driver_camera: DriverCameraDialog | None = None
self._pair_device_dialog: PairingDialog | None = None
@@ -164,8 +162,8 @@ class DeviceLayout(Widget):
cloudlog.exception("invalid LiveTorqueParameters")
desc += "<br><br>"
desc += tr("sunnypilot is continuously calibrating, resetting is rarely required. " +
"Resetting calibration will restart sunnypilot if the car is powered on.")
desc += tr("openpilot is continuously calibrating, resetting is rarely required. " +
"Resetting calibration will restart openpilot if the car is powered on.")
self._reset_calib_btn.set_description(desc)
+1 -1
View File
@@ -17,7 +17,7 @@ from openpilot.selfdrive.ui.lib.api_helpers import get_token
TITLE = tr_noop("Firehose Mode")
DESCRIPTION = tr_noop(
"sunnypilot learns to drive by watching humans, like you, drive.\n\n"
"openpilot learns to drive by watching humans, like you, drive.\n\n"
+ "Firehose Mode allows you to maximize your training data uploads to improve "
+ "openpilot's driving models. More data means bigger models, which means better Experimental Mode."
)
@@ -11,9 +11,6 @@ from openpilot.system.ui.widgets.list_view import button_item, text_item, ListIt
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.scroller_tici import Scroller
if gui_app.sunnypilot_ui():
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item
# TODO: remove this. updater fails to respond on startup if time is not correct
UPDATED_TIMEOUT = 10 # seconds to wait for updated to respond
+20 -16
View File
@@ -1,5 +1,5 @@
from cereal import log
from openpilot.common.params import UnknownKeyName
from openpilot.common.params import Params, UnknownKeyName
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item
from openpilot.system.ui.widgets.scroller_tici import Scroller
@@ -11,27 +11,26 @@ from openpilot.selfdrive.ui.ui_state import ui_state
if gui_app.sunnypilot_ui():
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp as toggle_item
from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp as multiple_button_item
PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
# Description constants
DESCRIPTIONS = {
"OpenpilotEnabledToggle": tr_noop(
"Use the sunnypilot system for adaptive cruise control and lane keep driver assistance. " +
"Use the openpilot system for adaptive cruise control and lane keep driver assistance. " +
"Your attention is required at all times to use this feature."
),
"DisengageOnAccelerator": tr_noop("When enabled, pressing the accelerator pedal will disengage sunnypilot."),
"DisengageOnAccelerator": tr_noop("When enabled, pressing the accelerator pedal will disengage openpilot."),
"LongitudinalPersonality": tr_noop(
"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 " +
"Standard is recommended. In aggressive mode, openpilot will follow lead cars closer and be more aggressive with the gas and brake. " +
"In relaxed mode openpilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " +
"your steering wheel distance button."
),
"IsLdwEnabled": tr_noop(
"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)."
),
"AlwaysOnDM": tr_noop("Enable driver monitoring even when sunnypilot is not engaged."),
"AlwaysOnDM": tr_noop("Enable driver monitoring even when openpilot is not engaged."),
'RecordFront': tr_noop("Upload data from the driver facing camera and help improve the driver monitoring algorithm."),
"IsMetric": tr_noop("Display speed in km/h instead of mph."),
"RecordAudio": tr_noop("Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect."),
@@ -41,13 +40,13 @@ DESCRIPTIONS = {
class TogglesLayout(Widget):
def __init__(self):
super().__init__()
self._params = ui_state.params
self._params = Params()
self._is_release = self._params.get_bool("IsReleaseBranch")
# param, title, desc, icon, needs_restart
self._toggle_defs = {
"OpenpilotEnabledToggle": (
lambda: tr("Enable sunnypilot"),
lambda: tr("Enable openpilot"),
DESCRIPTIONS["OpenpilotEnabledToggle"],
"chffr_wheel.png",
True,
@@ -100,7 +99,7 @@ class TogglesLayout(Widget):
lambda: tr("Driving Personality"),
lambda: tr(DESCRIPTIONS["LongitudinalPersonality"]),
buttons=[lambda: tr("Aggressive"), lambda: tr("Standard"), lambda: tr("Relaxed")],
button_width=300,
button_width=255,
callback=self._set_longitudinal_personality,
selected_index=self._params.get("LongitudinalPersonality", return_default=True),
icon="speed_limit.png"
@@ -126,7 +125,7 @@ class TogglesLayout(Widget):
# Make description callable for live translation
additional_desc = ""
if needs_restart and not locked:
additional_desc = tr("Changing this setting will restart sunnypilot if the car is powered on.")
additional_desc = tr("Changing this setting will restart openpilot if the car is powered on.")
toggle.set_description(lambda og_desc=toggle.description, add_desc=additional_desc: tr(og_desc) + (" " + tr(add_desc) if add_desc else ""))
# track for engaged state updates
@@ -159,10 +158,10 @@ class TogglesLayout(Widget):
ui_state.update_params()
e2e_description = tr(
"sunnypilot defaults to driving in chill mode. Experimental mode enables alpha-level features that aren't ready for chill mode. " +
"openpilot defaults to driving in chill mode. Experimental mode enables alpha-level features that aren't ready for chill mode. " +
"Experimental features are listed below:<br>" +
"<h4>End-to-End Longitudinal Control</h4><br>" +
"Let the driving model control the gas and brakes. sunnypilot will drive as it thinks a human would, including stopping for red lights and stop signs. " +
"Let the driving model control the gas and brakes. openpilot will drive as it thinks a human would, including stopping for red lights and stop signs. " +
"Since the driving model decides the speed to drive, the set speed will only act as an upper bound. This is an alpha quality feature; " +
"mistakes should be expected.<br>" +
"<h4>New Driving Visualization</h4><br>" +
@@ -184,13 +183,13 @@ class TogglesLayout(Widget):
unavailable = tr("Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control.")
long_desc = unavailable + " " + tr("sunnypilot longitudinal control may come in a future update.")
long_desc = unavailable + " " + tr("openpilot longitudinal control may come in a future update.")
if ui_state.CP.alphaLongitudinalAvailable:
if self._is_release:
long_desc = unavailable + " " + tr("An alpha version of sunnypilot longitudinal control can be tested, along with " +
long_desc = unavailable + " " + tr("An alpha version of openpilot longitudinal control can be tested, along with " +
"Experimental mode, on non-release branches.")
else:
long_desc = tr("Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode.")
long_desc = tr("Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode.")
self._toggles["ExperimentalMode"].set_description("<b>" + long_desc + "</b><br><br>" + e2e_description)
else:
@@ -198,6 +197,11 @@ class TogglesLayout(Widget):
self._update_experimental_mode_icon()
# TODO: make a param control list item so we don't need to manage internal state as much here
# refresh toggles from params to mirror external changes
for param in self._toggle_defs:
self._toggles[param].action_item.set_state(self._params.get_bool(param))
# these toggles need restart, block while engaged
for toggle_def in self._toggle_defs:
if self._toggle_defs[toggle_def][3] and toggle_def not in self._locked_toggles:
+5 -2
View File
@@ -45,7 +45,7 @@ class DeviceStatus(Widget):
self._version_text = self._get_version_text()
def _get_version_text(self) -> str:
brand = "sunnypilot"
brand = "openpilot"
description = ui_state.params.get("UpdaterCurrentDescription")
return f"{brand} {description}" if description else brand
@@ -111,13 +111,16 @@ class MiciHomeLayout(Widget):
self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 55, 35)
self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 55, 35)
self._openpilot_label = MiciLabel("sunnypilot", font_size=96, color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.DISPLAY)
self._openpilot_label = MiciLabel("openpilot", font_size=96, color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.DISPLAY)
self._version_label = MiciLabel("", font_size=36, font_weight=FontWeight.ROMAN)
self._large_version_label = MiciLabel("", font_size=64, color=rl.GRAY, font_weight=FontWeight.ROMAN)
self._date_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN)
self._branch_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN, elide_right=False, scroll=True)
self._version_commit_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN)
if gui_app.sunnypilot_ui():
self._openpilot_label.set_text("sunnypilot")
def show_event(self):
self._version_text = self._get_version_text()
self._update_network_status(ui_state.sm['deviceState'])
+1 -1
View File
@@ -2,7 +2,7 @@ import pyray as rl
from enum import IntEnum
import cereal.messaging as messaging
from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout
from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.settings import SettingsLayoutSP as SettingsLayout
from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts
from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
from openpilot.selfdrive.ui.ui_state import device, ui_state
+1 -1
View File
@@ -254,7 +254,7 @@ class MiciOffroadAlerts(Widget):
parts = new_desc.split(" / ")
if len(parts) > 3:
version, date = parts[0], parts[3]
update_alert_data.text = f"update available\n sunnypilot {version}, {date}. go to comma.ai/blog to read the release notes."
update_alert_data.text = f"update available\n openpilot {version}, {date}. go to comma.ai/blog to read the release notes."
update_alert_data.visible = True
active_count += 1
+4 -4
View File
@@ -182,7 +182,7 @@ class TrainingGuideAttentionNotice(SetupTermsPage):
def __init__(self, continue_callback):
super().__init__(continue_callback, continue_text="continue")
self._title_header = TermsHeader("driver assistance", gui_app.texture("icons_mici/setup/warning.png", 60, 60))
self._warning_label = UnifiedLabel("1. sunnypilot is a driver assistance system.\n\n" +
self._warning_label = UnifiedLabel("1. openpilot is a driver assistance system.\n\n" +
"2. You must pay attention at all times.\n\n" +
"3. You must be ready to take over at any time.\n\n" +
"4. You are fully responsible for driving the car.", 42,
@@ -239,12 +239,12 @@ class TrainingGuide(Widget):
class DeclinePage(Widget):
def __init__(self, back_callback=None):
super().__init__()
self._uninstall_slider = SmallSlider("uninstall sunnypilot", self._on_uninstall)
self._uninstall_slider = SmallSlider("uninstall openpilot", self._on_uninstall)
self._back_button = SmallButton("back")
self._back_button.set_click_callback(back_callback)
self._warning_header = TermsHeader("you must accept the\nterms to use sunnypilot",
self._warning_header = TermsHeader("you must accept the\nterms to use openpilot",
gui_app.texture("icons_mici/setup/red_warning.png", 66, 60))
def _on_uninstall(self):
@@ -282,7 +282,7 @@ class TermsPage(SetupTermsPage):
info_txt = gui_app.texture("icons_mici/setup/green_info.png", 60, 60)
self._title_header = TermsHeader("terms & conditions", info_txt)
self._terms_label = UnifiedLabel("You must accept the Terms and Conditions to use sunnypilot. " +
self._terms_label = UnifiedLabel("You must accept the Terms and Conditions to use openpilot. " +
"Read the latest terms at https://comma.ai/terms before continuing.", 36,
FontWeight.ROMAN)
+3 -3
View File
@@ -156,7 +156,7 @@ class UpdateOpenpilotBigButton(BigButton):
self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64)
self._txt_reboot_icon = gui_app.texture("icons_mici/settings/device/reboot.png", 64, 64)
self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
super().__init__("update sunnypilot", "", self._txt_update_icon)
super().__init__("update openpilot", "", self._txt_update_icon)
self._waiting_for_updater_t: float | None = None
self._hide_value_t: float | None = None
@@ -193,7 +193,7 @@ class UpdateOpenpilotBigButton(BigButton):
if value:
self.set_text("")
else:
self.set_text("update sunnypilot")
self.set_text("update openpilot")
def _update_state(self):
if ui_state.started:
@@ -294,7 +294,7 @@ class DeviceLayoutMici(NavWidget):
reset_calibration_btn = BigButton("reset calibration", "", "icons_mici/settings/device/lkas.png")
reset_calibration_btn.set_click_callback(lambda: _engaged_confirmation_callback(reset_calibration_callback, "reset"))
uninstall_openpilot_btn = BigButton("uninstall sunnypilot", "", "icons_mici/settings/device/uninstall.png")
uninstall_openpilot_btn = BigButton("uninstall openpilot", "", "icons_mici/settings/device/uninstall.png")
uninstall_openpilot_btn.set_click_callback(lambda: _engaged_confirmation_callback(uninstall_openpilot_callback, "uninstall"))
reboot_btn = BigCircleButton("icons_mici/settings/device/reboot.png", red=False)
@@ -17,7 +17,7 @@ from openpilot.system.ui.widgets import NavWidget
TITLE = tr_noop("Firehose Mode")
DESCRIPTION = tr_noop(
"sunnypilot learns to drive by watching humans, like you, drive.\n\n"
"openpilot learns to drive by watching humans, like you, drive.\n\n"
+ "Firehose Mode allows you to maximize your training data uploads to improve "
+ "openpilot's driving models. More data means bigger models, which means better Experimental Mode."
)
@@ -24,7 +24,7 @@ class TogglesLayoutMici(NavWidget):
always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM")
record_front = BigParamControl("record & upload driver camera", "RecordFront", toggle_callback=restart_needed_callback)
record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback)
enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback)
enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback)
self._scroller = Scroller([
self._personality_toggle,
+1 -1
View File
@@ -65,7 +65,7 @@ class Alert:
# Pre-defined alert instances
ALERT_STARTUP_PENDING = Alert(
text1="sunnypilot Unavailable",
text1="openpilot Unavailable",
text2="Waiting to start",
size=AlertSize.mid,
status=AlertStatus.normal,
@@ -153,7 +153,7 @@ class AugmentedRoadView(CameraView):
self._alert_renderer = AlertRenderer()
self._driver_state_renderer = DriverStateRenderer()
self._confidence_ball = ConfidenceBall()
self._offroad_label = UnifiedLabel("start the car to\nuse sunnypilot", 54, FontWeight.DISPLAY,
self._offroad_label = UnifiedLabel("start the car to\nuse openpilot", 54, FontWeight.DISPLAY,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
@@ -171,7 +171,7 @@ class AugmentedRoadView(CameraView):
if ui_state.panda_type == log.PandaState.PandaType.unknown:
self._offroad_label.set_text("system booting")
else:
self._offroad_label.set_text("start the car to\nuse sunnypilot")
self._offroad_label.set_text("start the car to\nuse openpilot")
def _handle_mouse_release(self, mouse_pos: MousePos):
# Don't trigger click callback if bookmark was triggered
+1 -1
View File
@@ -48,7 +48,7 @@ class Alert:
# Pre-defined alert instances
ALERT_STARTUP_PENDING = Alert(
text1=tr("sunnypilot Unavailable"),
text1=tr("openpilot Unavailable"),
text2=tr("Waiting to start"),
size=AlertSize.mid,
status=AlertStatus.normal,
+158
View File
@@ -0,0 +1,158 @@
/**
* 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.
*/
#include "selfdrive/ui/qt/offroad/offroad_home.h"
#include "selfdrive/ui/qt/offroad/experimental_mode.h"
#include "selfdrive/ui/qt/util.h"
#include "selfdrive/ui/qt/widgets/prime.h"
// OffroadHome: the offroad home page
OffroadHome::OffroadHome(QWidget* parent) : QFrame(parent) {
QVBoxLayout* main_layout = new QVBoxLayout(this);
main_layout->setContentsMargins(40, 40, 40, 40);
// top header
header_layout = new QHBoxLayout();
header_layout->setContentsMargins(0, 0, 0, 0);
header_layout->setSpacing(16);
update_notif = new QPushButton(tr("UPDATE"));
update_notif->setVisible(false);
update_notif->setStyleSheet("background-color: #364DEF;");
QObject::connect(update_notif, &QPushButton::clicked, [=]() { center_layout->setCurrentIndex(1); });
header_layout->addWidget(update_notif, 0, Qt::AlignHCenter | Qt::AlignLeft);
alert_notif = new QPushButton();
alert_notif->setVisible(false);
alert_notif->setStyleSheet("background-color: #E22C2C;");
QObject::connect(alert_notif, &QPushButton::clicked, [=] { center_layout->setCurrentIndex(2); });
header_layout->addWidget(alert_notif, 0, Qt::AlignHCenter | Qt::AlignLeft);
version = new ElidedLabel();
header_layout->addWidget(version, 0, Qt::AlignHCenter | Qt::AlignRight);
main_layout->addLayout(header_layout);
// main content
main_layout->addSpacing(25);
center_layout = new QStackedLayout();
QWidget *home_widget = new QWidget(this);
{
home_layout = new QHBoxLayout(home_widget);
home_layout->setContentsMargins(0, 0, 0, 0);
home_layout->setSpacing(30);
#ifndef SUNNYPILOT
// left: PrimeAdWidget
QStackedWidget *left_widget = new QStackedWidget(this);
QVBoxLayout *left_prime_layout = new QVBoxLayout();
left_prime_layout->setContentsMargins(0, 0, 0, 0);
QWidget *prime_user = new PrimeUserWidget();
prime_user->setStyleSheet(R"(
border-radius: 10px;
background-color: #333333;
)");
left_prime_layout->addWidget(prime_user);
left_prime_layout->addStretch();
left_widget->addWidget(new LayoutWidget(left_prime_layout));
left_widget->addWidget(new PrimeAdWidget);
left_widget->setStyleSheet("border-radius: 10px;");
connect(uiState()->prime_state, &PrimeState::changed, [left_widget]() {
left_widget->setCurrentIndex(uiState()->prime_state->isSubscribed() ? 0 : 1);
});
home_layout->addWidget(left_widget, 1);
#endif
// right: ExperimentalModeButton, SetupWidget
QWidget* right_widget = new QWidget(this);
QVBoxLayout* right_column = new QVBoxLayout(right_widget);
right_column->setContentsMargins(0, 0, 0, 0);
right_widget->setFixedWidth(750);
right_column->setSpacing(30);
ExperimentalModeButton *experimental_mode = new ExperimentalModeButton(this);
QObject::connect(experimental_mode, &ExperimentalModeButton::openSettings, this, &OffroadHome::openSettings);
right_column->addWidget(experimental_mode, 1);
SetupWidget *setup_widget = new SetupWidget;
QObject::connect(setup_widget, &SetupWidget::openSettings, this, &OffroadHome::openSettings);
right_column->addWidget(setup_widget, 1);
home_layout->addWidget(right_widget, 1);
}
center_layout->addWidget(home_widget);
// add update & alerts widgets
update_widget = new UpdateAlert();
QObject::connect(update_widget, &UpdateAlert::dismiss, [=]() { center_layout->setCurrentIndex(0); });
center_layout->addWidget(update_widget);
alerts_widget = new OffroadAlert();
QObject::connect(alerts_widget, &OffroadAlert::dismiss, [=]() { center_layout->setCurrentIndex(0); });
center_layout->addWidget(alerts_widget);
main_layout->addLayout(center_layout, 1);
// set up refresh timer
timer = new QTimer(this);
timer->callOnTimeout(this, &OffroadHome::refresh);
setStyleSheet(R"(
* {
color: white;
}
OffroadHome {
background-color: black;
}
OffroadHome > QPushButton {
padding: 15px 30px;
border-radius: 5px;
font-size: 40px;
font-weight: 500;
}
OffroadHome > QLabel {
font-size: 55px;
}
)");
}
void OffroadHome::showEvent(QShowEvent *event) {
refresh();
timer->start(10 * 1000);
}
void OffroadHome::hideEvent(QHideEvent *event) {
timer->stop();
}
void OffroadHome::refresh() {
version->setText(getBrand() + " " + QString::fromStdString(params.get("UpdaterCurrentDescription")));
bool updateAvailable = update_widget->refresh();
int alerts = alerts_widget->refresh();
// pop-up new notification
int idx = center_layout->currentIndex();
if (!updateAvailable && !alerts) {
idx = 0;
} else if (updateAvailable && (!update_notif->isVisible() || (!alerts && idx == 2))) {
idx = 1;
} else if (alerts && (!alert_notif->isVisible() || (!updateAvailable && idx == 1))) {
idx = 2;
}
center_layout->setCurrentIndex(idx);
update_notif->setVisible(updateAvailable);
alert_notif->setVisible(alerts);
if (alerts) {
alert_notif->setText(QString::number(alerts) + (alerts > 1 ? tr(" ALERTS") : tr(" ALERT")));
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* 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.
*/
#pragma once
#include "common/params.h"
#include "selfdrive/ui/qt/body.h"
#include "selfdrive/ui/qt/widgets/offroad_alerts.h"
#ifdef SUNNYPILOT
#include "selfdrive/ui/sunnypilot/qt/widgets/controls.h"
#include "selfdrive/ui/sunnypilot/qt/onroad/onroad_home.h"
#include "selfdrive/ui/sunnypilot/qt/sidebar.h"
#include "selfdrive/ui/sunnypilot/qt/widgets/prime.h"
#define OnroadWindow OnroadWindowSP
#define LayoutWidget LayoutWidgetSP
#define Sidebar SidebarSP
#define ElidedLabel ElidedLabelSP
#define SetupWidget SetupWidgetSP
#else
#include "selfdrive/ui/qt/widgets/controls.h"
#include "selfdrive/ui/qt/onroad/onroad_home.h"
#include "selfdrive/ui/qt/sidebar.h"
#include "selfdrive/ui/qt/widgets/prime.h"
#endif
class OffroadHome : public QFrame {
Q_OBJECT
public:
explicit OffroadHome(QWidget* parent = 0);
signals:
void openSettings(int index = 0, const QString &param = "");
protected:
QHBoxLayout *home_layout;
QHBoxLayout *header_layout;
void showEvent(QShowEvent *event) override;
void refresh();
private:
void hideEvent(QHideEvent *event) override;
Params params;
QTimer* timer;
ElidedLabel* version;
QStackedLayout* center_layout;
UpdateAlert *update_widget;
OffroadAlert* alerts_widget;
QPushButton* alert_notif;
QPushButton* update_notif;
};
View File
@@ -1,12 +1,6 @@
"""
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.common.params import Params
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class CruiseLayout(Widget):
@@ -1,12 +1,5 @@
"""
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.selfdrive.ui.layouts.settings.device import DeviceLayout
class DeviceLayoutSP(DeviceLayout):
def __init__(self):
DeviceLayout.__init__(self)
@@ -1,12 +1,6 @@
"""
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.common.params import Params
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class DisplayLayout(Widget):
@@ -1,253 +1,21 @@
"""
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
import re
import time
import pyray as rl
from cereal import custom
from openpilot.common.constants import CV
from openpilot.selfdrive.ui.ui_state import device, ui_state
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import DialogResult, Widget
from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets.toggle import ON_COLOR
from openpilot.sunnypilot.models.runners.constants import CUSTOM_MODEL_PATH
from openpilot.system.ui.sunnypilot.lib.styles import style
from openpilot.system.ui.sunnypilot.widgets.list_view import ButtonActionSP, ListItemSP, toggle_item_sp, option_item_sp
from openpilot.system.ui.sunnypilot.widgets.progress_bar import progress_item
from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder
if gui_app.sunnypilot_ui():
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item
class ModelAction(ButtonActionSP):
def get_width_hint(self):
return super().get_width_hint() + 1
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class ModelsLayout(Widget):
def __init__(self):
super().__init__()
self.model_manager = None
self.download_status = None
self.prev_download_status = None
self.model_dialog = None
self.last_cache_calc_time = 0
self._initialize_items()
self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB")
for ctrl, key in [(self.lane_turn_value_control, "LaneTurnValue"), (self.delay_control, "LagdToggleDelay")]:
ctrl.action_item.set_value(int(float(ui_state.params.get(key, return_default=True)) * 100))
self._scroller = Scroller(self.items, line_separator=True, spacing=0)
self._params = Params()
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _initialize_items(self):
self.current_model_item = ListItemSP(
title=tr("Current Model"),
description="",
action_item=ModelAction(tr("SELECT")),
callback=self._handle_current_model_clicked
)
items = [
self.supercombo_label = progress_item(tr("Driving Model"))
self.vision_label = progress_item(tr("Vision Model"))
self.policy_label = progress_item(tr("Policy Model"))
self.refresh_item = button_item(tr("Refresh Model List"), tr("REFRESH"), "",
lambda: (ui_state.params.put("ModelManager_LastSyncTime", 0),
gui_app.set_modal_overlay(alert_dialog(tr("Fetching Latest Models")))))
self.clear_cache_item = ListItemSP(
title=tr("Clear Model Cache"),
description="",
action_item=ModelAction(tr("CLEAR")),
callback=self._clear_cache
)
self.cancel_download_item = button_item(tr("Cancel Download"), tr("Cancel"), "", lambda: ui_state.params.remove("ModelManager_DownloadIndex"))
self.lane_turn_value_control = option_item_sp(tr("Adjust Lane Turn Speed"), "LaneTurnValue", 500, 2000,
tr("Set the maximum speed for lane turn desires. Default is 19 mph."),
int(round(100 / CV.MPH_TO_KPH)), None, True, "", style.BUTTON_WIDTH, None, True,
lambda v: f"{int(round(v / 100 * (CV.MPH_TO_KPH if ui_state.is_metric else 1)))}" +
f" {'km/h' if ui_state.is_metric else 'mph'}")
self.lane_turn_desire_toggle = toggle_item_sp(tr("Use Lane Turn Desires"),
tr("If you're 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."),
param="LaneTurnDesire")
self.delay_control = option_item_sp(tr("Adjust Software Delay"), "LagdToggleDelay", 5, 50,
tr("Adjust the software delay when Live Learning Steer Delay is toggled off. The default software delay value is 0.2"),
1, None, True, "", style.BUTTON_WIDTH, None, True, lambda v: f"{v / 100:.2f}s")
self.lagd_toggle = toggle_item_sp(tr("Live Learning Steer Delay"), "", param="LagdToggle")
self.items = [self.current_model_item, self.cancel_download_item, self.supercombo_label, self.vision_label,
self.policy_label, self.refresh_item, self.clear_cache_item, self.lane_turn_desire_toggle,
self.lane_turn_value_control, self.lagd_toggle, self.delay_control]
def _update_lagd_description(self, lagd_toggle: bool):
desc = tr("Enable this for the car to learn and adapt its steering response time. Disable to use a fixed steering response time. " +
"Keeping this on provides the stock openpilot experience.")
if lagd_toggle:
desc += f"<br>{tr('Live Steer Delay:')} {ui_state.sm['liveDelay'].lateralDelay:.3f} s"
elif ui_state.CP:
sw = float(ui_state.params.get("LagdToggleDelay", "0.2"))
cp = ui_state.CP.steerActuatorDelay
desc += f"<br>{tr('Actuator Delay:')} {cp:.2f} s + {tr('Software Delay:')} {sw:.2f} s = {tr('Total Delay:')} {cp + sw:.2f} s"
self.lagd_toggle.set_description(desc)
def _is_downloading(self):
return (self.model_manager and self.model_manager.selectedBundle and
self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading)
@staticmethod
def _calculate_cache_size():
cache_size = 0.0
if os.path.exists(CUSTOM_MODEL_PATH):
cache_size = sum(os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file)) for file in os.listdir(CUSTOM_MODEL_PATH)) / (1024**2)
return cache_size
def _clear_cache(self):
def _callback(response):
if response == DialogResult.CONFIRM:
ui_state.params.put_bool("ModelManager_ClearCache", True)
self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB")
gui_app.set_modal_overlay(ConfirmDialog(tr("This will delete ALL downloaded models from the cache except the currently active model. Are you sure?"),
tr("Clear Cache")), callback=_callback)
def _handle_bundle_download_progress(self):
labels = {custom.ModelManagerSP.Model.Type.supercombo: self.supercombo_label,
custom.ModelManagerSP.Model.Type.vision: self.vision_label,
custom.ModelManagerSP.Model.Type.policy: self.policy_label}
for label in labels.values():
label.set_visible(False)
self.cancel_download_item.set_visible(False)
if not self.model_manager or (not self.model_manager.selectedBundle and not self.model_manager.activeBundle):
return
bundle = self.model_manager.selectedBundle if self._is_downloading() or (
self.model_manager.selectedBundle and self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed
) else self.model_manager.activeBundle
if not bundle:
return
self.download_status = bundle.status
status_changed = self.prev_download_status != self.download_status
self.prev_download_status = self.download_status
self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and bool(ui_state.params.get("ModelManager_DownloadIndex")))
if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5:
self.last_cache_calc_time = current_time
self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB")
if self.download_status == custom.ModelManagerSP.DownloadStatus.downloading:
device.reset_interactive_timeout()
for model in bundle.models:
if label := labels.get(getattr(model.type, 'raw', model.type)):
label.set_visible(True)
p = model.artifact.downloadProgress
text, show, color = f"pending - {bundle.displayName}", False, rl.GRAY
if p.status == custom.ModelManagerSP.DownloadStatus.downloading:
text, show = f"{int(p.progress)}% - {bundle.displayName}", True
elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, custom.ModelManagerSP.DownloadStatus.cached):
status_text = tr("from cache" if p.status == custom.ModelManagerSP.DownloadStatus.cached else "downloaded")
text, color = f"{bundle.displayName} - {status_text if status_changed else tr('ready')}", ON_COLOR
elif p.status == custom.ModelManagerSP.DownloadStatus.failed:
text, color = f"download failed - {bundle.displayName}", rl.RED
label.action_item.update(p.progress, text, show, color)
@staticmethod
def _show_reset_params_dialog():
def _callback(response):
if response == DialogResult.CONFIRM:
ui_state.params.remove("CalibrationParams")
ui_state.params.remove("LiveTorqueParameters")
msg = tr("Model download has started in the background. We suggest resetting calibration. Would you like to do that now?")
gui_app.set_modal_overlay(ConfirmDialog(msg, tr("Reset Calibration")), callback=_callback)
def _on_model_selected(self, result):
if result != DialogResult.CONFIRM:
return
selected_ref = self.model_dialog.selection_ref
if selected_ref == "Default":
ui_state.params.remove("ModelManager_ActiveBundle")
self._show_reset_params_dialog()
elif selected_bundle := next((bundle for bundle in self.model_manager.availableBundles if bundle.ref == selected_ref), None):
ui_state.params.put("ModelManager_DownloadIndex", selected_bundle.index)
if self.model_manager.activeBundle and selected_bundle.generation != self.model_manager.activeBundle.generation:
self._show_reset_params_dialog()
self.model_dialog = None
@staticmethod
def _bundle_to_node(bundle):
return TreeNode(bundle.ref, {'display_name': bundle.displayName, 'short_name': bundle.internalName})
def _get_folders(self, favorites):
bundles = self.model_manager.availableBundles
folders = {}
for bundle in bundles:
folders.setdefault(next((ov_ride.value for ov_ride in bundle.overrides if ov_ride.key == "folder"), ""), []).append(bundle)
folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': tr("Default Model"), 'short_name': "Default"})])]
for folder, folder_bundles in sorted(folders.items(), key=lambda x: max((bundle.index for bundle in x[1]), default=-1), reverse=True):
folder_bundles.sort(key=lambda bundle: bundle.index, reverse=True)
name = folder + (f" - (Updated: {m.group(1)})" if folder_bundles and (m := re.search(r'\(([^)]*)\)[^(]*$', folder_bundles[0].displayName)) else "")
folders_list.append(TreeFolder(name, [self._bundle_to_node(bundle) for bundle in folder_bundles]))
if favorites and (fav_bundles := [bundle for bundle in bundles if bundle.ref in favorites]):
folders_list.insert(1, TreeFolder("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles]))
return folders_list
def _handle_current_model_clicked(self):
favs = ui_state.params.get("ModelManager_Favs")
favorites = set(favs.split(';')) if favs else set()
folders_list = self._get_folders(favorites)
active_ref = self.model_manager.activeBundle.ref if self.model_manager.activeBundle else "Default"
self.model_dialog = TreeOptionDialog(tr("Select a Model"), folders_list, active_ref, "ModelManager_Favs",
get_folders_fn=self._get_folders, on_exit=self._on_model_selected)
gui_app.set_modal_overlay(self.model_dialog, callback=self._on_model_selected)
def _update_state(self):
advanced_controls: bool = ui_state.params.get_bool("ShowAdvancedControls")
turn_desire: bool = ui_state.params.get_bool("LaneTurnDesire")
live_delay: bool = ui_state.params.get_bool("LagdToggle")
self.lane_turn_value_control.set_visible(turn_desire and advanced_controls)
self.delay_control.set_visible(not live_delay and advanced_controls)
new_step = int(round(100 / CV.MPH_TO_KPH)) if ui_state.is_metric else 100
if self.lane_turn_value_control.action_item.value_change_step != new_step:
self.lane_turn_value_control.action_item.value_change_step = new_step
self._update_lagd_description(live_delay)
self.model_manager = ui_state.sm["modelManagerSP"]
self._handle_bundle_download_progress()
active_name = self.model_manager.activeBundle.internalName if self.model_manager and self.model_manager.activeBundle.ref else tr("Default Model")
self.current_model_item.action_item.set_value(active_name)
if not ui_state.is_offroad():
self.current_model_item.action_item.set_enabled(False)
self.current_model_item.set_description(tr("Only available when vehicle is off, or always offroad mode is on"))
else:
self.current_model_item.action_item.set_enabled(True)
self.current_model_item.set_description("")
]
return items
def _render(self, rect):
self._scroller.render(rect)
@@ -1,12 +1,6 @@
"""
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.common.params import Params
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class NavigationLayout(Widget):
@@ -1,46 +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 threading
import time
import pyray as rl
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.network import NetworkUI, PanelType
class NetworkUISP(NetworkUI):
def __init__(self, wifi_manager):
super().__init__(wifi_manager)
self.scan_button = Button(tr("Scan"), self._scan_clicked, button_style=ButtonStyle.NORMAL, font_size=60, border_radius=30)
self.scan_button.set_rect(rl.Rectangle(0, 0, 400, 100))
self._scanning = False
self._wifi_manager.add_callbacks(networks_updated=self._on_networks_updated)
def _scan_clicked(self):
self._scanning = True
self.scan_button.set_text(tr("Scanning..."))
self.scan_button.set_enabled(False)
threading.Thread(target=self._wifi_manager._update_networks, daemon=True).start()
self._wifi_manager._request_scan()
self._wifi_manager._last_network_update = time.monotonic()
def _on_networks_updated(self, networks):
if self._scanning:
self._scanning = False
self.scan_button.set_text(tr("Scan"))
self.scan_button.set_enabled(True)
def _render(self, rect: rl.Rectangle):
super()._render(rect)
if self._current_panel == PanelType.WIFI:
self.scan_button.set_position(self._rect.x, self._rect.y + 20)
self.scan_button.render()
@@ -1,12 +1,6 @@
"""
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.common.params import Params
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class OSMLayout(Widget):
@@ -1,28 +1,20 @@
"""
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 dataclasses import dataclass
from enum import IntEnum
import pyray as rl
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.sunnypilot.layouts.settings.software import SoftwareLayoutSP
from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout
from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout
from openpilot.system.ui.lib.application import gui_app, MousePos
from openpilot.system.ui.lib.application import gui_app,MousePos
from openpilot.system.ui.lib.multilang import tr_noop
from openpilot.system.ui.sunnypilot.lib.styles import style
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets.network import NetworkUI
from openpilot.system.ui.lib.wifi_manager import WifiManager
from openpilot.system.ui.widgets import Widget
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.network import NetworkUISP
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.sunnylink import SunnylinkLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.osm import OSMLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.trips import TripsLayout
@@ -31,14 +23,12 @@ from openpilot.selfdrive.ui.sunnypilot.layouts.settings.steering import Steering
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.cruise import CruiseLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.visuals import VisualsLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.display import DisplayLayout
from openpilot.selfdrive.ui.ui_state import ui_state
# from openpilot.selfdrive.ui.sunnypilot.layouts.settings.navigation import NavigationLayout
#from openpilot.selfdrive.ui.sunnypilot.layouts.settings.navigation import NavigationLayout
OP.PANEL_COLOR = rl.Color(10, 10, 10, 255)
ICON_SIZE = 70
OP.PanelType = IntEnum( # type: ignore
OP.PanelType = IntEnum( # type: ignore
"PanelType",
[es.name for es in OP.PanelType] + [
"SUNNYLINK",
@@ -53,58 +43,20 @@ OP.PanelType = IntEnum( # type: ignore
"VEHICLE",
],
start=0,
)
)
@dataclass
class PanelInfo(OP.PanelInfo):
icon: str = ""
class NavButton(Widget):
def __init__(self, parent, p_type, p_info):
super().__init__()
self.parent = parent
self.panel_type = p_type
self.panel_info = p_info
def _render(self, rect):
is_selected = self.panel_type == self.parent._current_panel
text_color = OP.TEXT_SELECTED if is_selected else OP.TEXT_NORMAL
content_x = rect.x + 90
text_size = measure_text_cached(self.parent._font_medium, self.panel_info.name, 65)
# Draw background if selected
if is_selected:
self.container_rect = rl.Rectangle(
content_x - 50, rect.y, OP.SIDEBAR_WIDTH - 50, OP.NAV_BTN_HEIGHT
)
rl.draw_rectangle_rounded(self.container_rect, 0.2, 5, OP.CLOSE_BTN_COLOR)
if self.panel_info.icon:
icon_texture = gui_app.texture(self.panel_info.icon, ICON_SIZE, ICON_SIZE, keep_aspect_ratio=True)
rl.draw_texture(icon_texture, int(content_x), int(rect.y + (OP.NAV_BTN_HEIGHT - icon_texture.height) / 2),
rl.WHITE)
content_x += ICON_SIZE + 20
# Draw button text (right-aligned)
text_pos = rl.Vector2(
content_x,
rect.y + (OP.NAV_BTN_HEIGHT - text_size.y) / 2
)
rl.draw_text_ex(self.parent._font_medium, self.panel_info.name, text_pos, 55, 0, text_color)
# Store button rect for click detection
self.panel_info.button_rect = rect
class SettingsLayoutSP(OP.SettingsLayout):
def __init__(self):
OP.SettingsLayout.__init__(self)
self._nav_items: list[Widget] = []
# Create sidebar scroller
self._sidebar_scroller = Scroller([], spacing=0, line_separator=False, pad_end=False)
self._sidebar_scroller = Scroller([], spacing=0, line_separator = False, pad_end=False)
# Panel configuration
wifi_manager = WifiManager()
@@ -112,29 +64,67 @@ 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"),
OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUI(wifi_manager), icon="icons/network.png"),
OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/shell.png"),
OP.PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_toggle.png"),
OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayoutSP(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_software.png"),
OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout(), 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"),
OP.PanelType.VISUALS: PanelInfo(tr_noop("Visuals"), VisualsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_visuals.png"),
OP.PanelType.DISPLAY: PanelInfo(tr_noop("Display"), DisplayLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_display.png"),
OP.PanelType.OSM: PanelInfo(tr_noop("OSM"), OSMLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_map.png"),
# OP.PanelType.NAVIGATION: PanelInfo(tr_noop("Navigation"), NavigationLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_map.png"),
#OP.PanelType.NAVIGATION: PanelInfo(tr_noop("Navigation"), NavigationLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_map.png"),
OP.PanelType.TRIPS: PanelInfo(tr_noop("Trips"), TripsLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_trips.png"),
OP.PanelType.VEHICLE: PanelInfo(tr_noop("Vehicle"), VehicleLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_vehicle.png"),
OP.PanelType.FIREHOSE: PanelInfo(tr_noop("Firehose"), FirehoseLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_firehose.png"),
OP.PanelType.DEVELOPER: PanelInfo(tr_noop("Developer"), DeveloperLayout(), icon="icons/shell.png"),
}
def _create_nav_button(self, panel_type: OP.PanelType, panel_info: PanelInfo) -> Widget:
class NavButton(Widget):
def __init__(self, parent, p_type, p_info):
super().__init__()
self.parent = parent
self.panel_type = p_type
self.panel_info = p_info
def _render(self, rect):
is_selected = self.panel_type == self.parent._current_panel
text_color = OP.TEXT_SELECTED if is_selected else OP.TEXT_NORMAL
content_x = rect.x + 90
text_size = measure_text_cached(self.parent._font_medium, self.panel_info.name, 65)
# Draw background if selected
if is_selected:
self.container_rect = rl.Rectangle(
content_x - 20, rect.y, OP.SIDEBAR_WIDTH - 70, OP.NAV_BTN_HEIGHT
)
rl.draw_rectangle_rounded(self.container_rect, 0.2, 5, OP.CLOSE_BTN_COLOR)
if self.panel_info.icon:
icon_texture = gui_app.texture(self.panel_info.icon, ICON_SIZE, ICON_SIZE, keep_aspect_ratio=True)
rl.draw_texture(icon_texture, int(content_x), int(rect.y + (OP.NAV_BTN_HEIGHT - icon_texture.height) / 2), rl.WHITE)
content_x += ICON_SIZE + 20
# Draw button text (right-aligned)
text_pos = rl.Vector2(
content_x,
rect.y + (OP.NAV_BTN_HEIGHT - text_size.y) / 2
)
rl.draw_text_ex(self.parent._font_medium, self.panel_info.name, text_pos, 55, 0, text_color)
# Store button rect for click detection
self.panel_info.button_rect = rect
return NavButton(self, panel_type, panel_info)
def _draw_sidebar(self, rect: rl.Rectangle):
rl.draw_rectangle_rec(rect, OP.SIDEBAR_COLOR)
# Close button
close_btn_rect = rl.Rectangle(
rect.x + style.ITEM_PADDING * 3, rect.y + style.ITEM_PADDING * 2, style.CLOSE_BTN_SIZE, style.CLOSE_BTN_SIZE
rect.x + (rect.width - OP.CLOSE_BTN_SIZE) / 2, rect.y + 60, OP.CLOSE_BTN_SIZE, OP.CLOSE_BTN_SIZE
)
pressed = (rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and
@@ -148,7 +138,7 @@ class SettingsLayoutSP(OP.SettingsLayout):
close_btn_rect.y + (close_btn_rect.height - self._close_icon.height) / 2,
self._close_icon.width,
self._close_icon.height,
)
)
rl.draw_texture_pro(
self._close_icon,
rl.Rectangle(0, 0, self._close_icon.width, self._close_icon.height),
@@ -164,7 +154,7 @@ class SettingsLayoutSP(OP.SettingsLayout):
# Navigation buttons with scroller
if not self._nav_items:
for panel_type, panel_info in self._panels.items():
nav_button = NavButton(self, panel_type, panel_info)
nav_button = self._create_nav_button(panel_type, panel_info)
nav_button.rect.width = rect.width - 100 # Full width minus padding
nav_button.rect.height = OP.NAV_BTN_HEIGHT
self._nav_items.append(nav_button)
@@ -173,7 +163,7 @@ class SettingsLayoutSP(OP.SettingsLayout):
# Draw navigation section with scroller
nav_rect = rl.Rectangle(
rect.x,
self._close_btn_rect.height + style.ITEM_PADDING * 4, # Starting Y position for nav items
rect.y + 300, # Starting Y position for nav items
rect.width,
rect.height - 300 # Remaining height after close button
)
@@ -182,6 +172,7 @@ class SettingsLayoutSP(OP.SettingsLayout):
self._sidebar_scroller.render(nav_rect)
return
def _handle_mouse_release(self, mouse_pos: MousePos) -> bool:
# Check close button
if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect):
@@ -196,12 +187,3 @@ class SettingsLayoutSP(OP.SettingsLayout):
return True
return False
def set_current_panel(self, panel_type: OP.PanelType):
super().set_current_panel(panel_type)
ui_state.set_active_layout(self._panels[self._current_panel].instance)
def show_event(self):
super().show_event()
self._panels[self._current_panel].instance.show_event()
self._sidebar_scroller.show_event()
@@ -1,96 +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 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.<br><b>This requires a reboot to take effect.</b>"
),
'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)
@@ -1,12 +1,6 @@
"""
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.common.params import Params
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class SteeringLayout(Widget):
@@ -1,165 +1,38 @@
"""
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 cereal import custom
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.sunnypilot.ui_state import ui_state_sp
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.sunnypilot.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog
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 button_item, dual_button_item
from openpilot.system.ui.widgets.scroller_tici import Scroller, LineSeparator
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.common.params import Params
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
import pyray as rl
if gui_app.sunnypilot_ui():
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item
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=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
wrap_text=False,
elide=False
)
self._description = UnifiedLabel(
text=tr("For secure backup, restore, and remote configuration"),
font_size=40,
font_weight=FontWeight.LIGHT,
text_color=rl.Color(0, 255, 0, 255), # Green
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_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.LIGHT,
text_color=rl.Color(255, 165, 0, 255), # Orange
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_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.LIGHT,
text_color=rl.WHITE,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_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._params = Params()
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")
self._sunnylink_enabled = self._params.get("SunnylinkEnabled")
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=False, spacing=0)
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _initialize_items(self):
self._sunnylink_toggle = toggle_item_sp(
title=tr("Enable sunnylink"),
description=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
description=tr(
"This is the master switch, it will allow you to cutoff any sunnylink requests should you want to do that."),
param="SunnylinkEnabled"
)
self._sunnylink_description = SunnylinkDescriptionItem()
self._sunnylink_description.set_visible(False)
self._sponsor_btn = button_item(
title=tr("Sponsor Status"),
button_text=tr("SPONSOR"),
@@ -176,8 +49,8 @@ class SunnylinkLayout(Widget):
)
self._sunnylink_uploader_toggle = toggle_item_sp(
title=tr("Enable sunnylink uploader (infrastructure test)"),
description=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.)"),
description=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(
@@ -187,33 +60,22 @@ class SunnylinkLayout(Widget):
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._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 | None:
return str(ui_state.params.get("SunnylinkDongleId") or (lambda: tr("N/A")))
def _handle_pair_btn(self, sponsor_pairing: bool = False):
sunnylink_dongle_id = self._get_sunnylink_dongle_id()
sunnylink_dongle_id = self._params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID
if sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID:
gui_app.set_modal_overlay(alert_dialog(message=tr("sunnylink Dongle ID not found. ") +
tr("This may be due to weak internet connection or sunnylink registration issue. ") +
@@ -223,8 +85,10 @@ class SunnylinkLayout(Widget):
gui_app.set_modal_overlay(self._sunnylink_pairing_dialog, callback=lambda result: setattr(self, '_sunnylink_pairing_dialog', None))
def _handle_backup_btn(self):
backup_dialog = ConfirmDialog(text=tr("Are you sure you want to backup your current sunnypilot settings?"), confirm_text="Backup")
gui_app.set_modal_overlay(backup_dialog, callback=self._backup_handler)
gui_app.set_modal_overlay(alert_dialog(message=tr("Backup functionality is currently not available.") +
tr(" It will be back once all settings are implemented on raylib.")))
# backup_dialog = ConfirmDialog(text=tr("Are you sure you want to backup your current sunnypilot settings?"), confirm_text="Backup")
# gui_app.set_modal_overlay(backup_dialog, callback=self._backup_handler)
def _handle_restore_btn(self):
self._restore_btn.set_enabled(False)
@@ -235,16 +99,16 @@ class SunnylinkLayout(Widget):
if dialog_result == DialogResult.CONFIRM:
self._backup_in_progress = True
self._backup_btn.set_enabled(False)
ui_state.params.put_bool("BackupManager_CreateBackup", True)
self._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")
self._params.put("BackupManager_RestoreVersion", "latest")
def handle_backup_restore_progress(self):
sunnylink_backup_manager = ui_state.sm["backupManagerSP"]
sunnylink_backup_manager = ui_state_sp.sm["backupManagerSP"]
backup_status = sunnylink_backup_manager.backupStatus
restore_status = sunnylink_backup_manager.restoreStatus
@@ -252,84 +116,61 @@ class SunnylinkLayout(Widget):
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_enabled(not ui_state_sp.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)):
elif backup_status == custom.BackupManagerSP.Status.completed:
self._backup_in_progress = False
dialog = alert_dialog(tr("Settings backup completed."))
gui_app.set_modal_overlay(dialog)
self._backup_btn.set_enabled(not ui_state.is_onroad())
self._backup_btn.set_enabled(not ui_state_sp.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
self._restore_btn.set_enabled(False)
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_enabled(not ui_state_sp.is_onroad())
self._restore_btn.set_text(tr("Restore Failed"))
dialog = alert_dialog(tr("Unable to restore the settings, try again later."))
gui_app.set_modal_overlay(dialog)
elif (restore_status == custom.BackupManagerSP.Status.completed or
(restore_status == custom.BackupManagerSP.Status.idle and restore_progress == 100.0)):
elif restore_status == custom.BackupManagerSP.Status.completed:
self._restore_in_progress = False
dialog = alert_dialog(tr("Settings restored. Confirm to restart the interface."))
gui_app.set_modal_overlay(dialog, callback=lambda: gui_app.request_close())
gui_app.set_modal_overlay(dialog, callback=lambda: {
gui_app.request_close()
})
else:
can_enable = self._sunnylink_enabled and not ui_state.is_onroad()
can_enable = self._sunnylink_enabled and not ui_state_sp.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):
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_enabled = self._params.get("SunnylinkEnabled")
self._sunnylink_toggle.action_item.set_enabled(not ui_state_sp.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")
sponsor_btn_text = tr("THANKS ♥") if ui_state_sp.sunnylink_state.is_sponsor() else tr("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")
pair_btn_text = tr("Paired") if ui_state_sp.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)
@@ -337,6 +178,4 @@ class SunnylinkLayout(Widget):
self._scroller.render(rect)
def show_event(self):
super().show_event()
self._scroller.show_event()
self._sunnylink_description.set_visible(False)
@@ -1,12 +1,6 @@
"""
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.common.params import Params
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class TripsLayout(Widget):
@@ -0,0 +1,24 @@
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class VehicleLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _initialize_items(self):
items = [
]
return items
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()
@@ -1,67 +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.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.list_view import ButtonAction
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.factory import BrandSettingsFactory
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.platform_selector import PlatformSelector, LegendWidget
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP
class VehicleLayout(Widget):
def __init__(self):
super().__init__()
self._brand_settings = None
self._brand_items = []
self._current_brand = None
self._platform_selector = PlatformSelector(self._update_brand_settings)
self._vehicle_item = ListItemSP(title=self._platform_selector.text, action_item=ButtonAction(text=tr("Select")),
callback=self._platform_selector._on_clicked)
self._vehicle_item.title_color = self._platform_selector.color
self._legend_widget = LegendWidget(self._platform_selector)
self.items = [self._vehicle_item, self._legend_widget]
self._scroller = Scroller(self.items, line_separator=True, spacing=0)
@staticmethod
def get_brand():
if bundle := ui_state.params.get("CarPlatformBundle"):
return bundle.get("brand", "")
elif ui_state.CP and ui_state.CP.carFingerprint != "MOCK":
return ui_state.CP.brand
return ""
def _update_brand_settings(self):
self._vehicle_item._title = self._platform_selector.text
self._vehicle_item.title_color = self._platform_selector.color
vehicle_text = tr("Remove") if ui_state.params.get("CarPlatformBundle") else tr("Select")
self._vehicle_item.action_item.set_text(vehicle_text)
brand = self.get_brand()
if brand != self._current_brand:
self._current_brand = brand
self._brand_settings = BrandSettingsFactory.create_brand_settings(brand)
self._brand_items = self._brand_settings.items if self._brand_settings else []
self.items = [self._vehicle_item, self._legend_widget] + self._brand_items
self._scroller = Scroller(self.items, line_separator=True, spacing=0)
def _update_state(self):
self._update_brand_settings()
if self._brand_settings:
self._brand_settings.update_settings()
self._platform_selector.refresh()
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()
@@ -1,16 +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 abc
class BrandSettings(abc.ABC):
def __init__(self):
self.items = []
@abc.abstractmethod
def update_settings(self) -> None:
"""Update the settings based on the current vehicle brand."""
@@ -1,15 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
class BodySettings(BrandSettings):
def __init__(self):
super().__init__()
def update_settings(self):
pass
@@ -1,15 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
class ChryslerSettings(BrandSettings):
def __init__(self):
super().__init__()
def update_settings(self):
pass
@@ -1,45 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.body import BodySettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.chrysler import ChryslerSettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.ford import FordSettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.gm import GMSettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.honda import HondaSettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.hyundai import HyundaiSettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.mazda import MazdaSettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.nissan import NissanSettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.psa import PSASettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.rivian import RivianSettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.subaru import SubaruSettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.tesla import TeslaSettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.toyota import ToyotaSettings
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.volkswagen import VolkswagenSettings
class BrandSettingsFactory:
_BRAND_MAP: dict[str, type[BrandSettings]] = {
"body": BodySettings,
"chrysler": ChryslerSettings,
"ford": FordSettings,
"gm": GMSettings,
"honda": HondaSettings,
"hyundai": HyundaiSettings,
"mazda": MazdaSettings,
"nissan": NissanSettings,
"psa": PSASettings,
"rivian": RivianSettings,
"subaru": SubaruSettings,
"tesla": TeslaSettings,
"toyota": ToyotaSettings,
"volkswagen": VolkswagenSettings,
}
@staticmethod
def create_brand_settings(brand: str) -> BrandSettings | None:
cls = BrandSettingsFactory._BRAND_MAP.get(brand)
return cls() if cls is not None else None
@@ -1,15 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
class FordSettings(BrandSettings):
def __init__(self):
super().__init__()
def update_settings(self):
pass
@@ -1,15 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
class GMSettings(BrandSettings):
def __init__(self):
super().__init__()
def update_settings(self):
pass
@@ -1,15 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
class HondaSettings(BrandSettings):
def __init__(self):
super().__init__()
def update_settings(self):
pass
@@ -1,58 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp
from opendbc.car.hyundai.values import CAR, CANFD_UNSUPPORTED_LONGITUDINAL_CAR, UNSUPPORTED_LONGITUDINAL_CAR
class HyundaiSettings(BrandSettings):
def __init__(self):
super().__init__()
self.alpha_long_available = False
tuning_texts = [tr("Off"), tr("Dynamic"), tr("Predictive")]
self.longitudinal_tuning_item = multiple_button_item_sp(tr("Custom Longitudinal Tuning"), "", tuning_texts,
button_width=300, callback=self._on_tuning_selected,
param="HyundaiLongitudinalTuning", inline=False)
self.items = [self.longitudinal_tuning_item]
@staticmethod
def _on_tuning_selected(index):
ui_state.params.put("HyundaiLongitudinalTuning", index)
def update_settings(self):
self.alpha_long_available = False
bundle = ui_state.params.get("CarPlatformBundle")
if bundle:
platform = bundle.get("platform")
self.alpha_long_available = CAR[platform] not in (UNSUPPORTED_LONGITUDINAL_CAR | CANFD_UNSUPPORTED_LONGITUDINAL_CAR)
elif ui_state.CP:
self.alpha_long_available = ui_state.CP.alphaLongitudinalAvailable
tuning_param = int(ui_state.params.get("HyundaiLongitudinalTuning") or "0")
long_enabled = ui_state.has_longitudinal_control
long_tuning_descs = [
tr("Your vehicle will use the Default longitudinal tuning."),
tr("Your vehicle will use the Dynamic longitudinal tuning."),
tr("Your vehicle will use the Predictive longitudinal tuning."),
]
long_tuning_desc = long_tuning_descs[tuning_param] if tuning_param < len(long_tuning_descs) else long_tuning_descs[0]
longitudinal_tuning_disabled = not ui_state.is_offroad() or not long_enabled
if longitudinal_tuning_disabled:
if not ui_state.is_offroad():
long_tuning_desc = tr("This feature is unavailable while the car is onroad.")
elif not long_enabled:
long_tuning_desc = tr("This feature is unavailable because sunnypilot Longitudinal Control (Alpha) is not enabled.")
self.longitudinal_tuning_item.action_item.set_enabled(not longitudinal_tuning_disabled)
self.longitudinal_tuning_item.set_description(long_tuning_desc)
self.longitudinal_tuning_item.show_description(True)
self.longitudinal_tuning_item.set_visible(self.alpha_long_available)
@@ -1,15 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
class MazdaSettings(BrandSettings):
def __init__(self):
super().__init__()
def update_settings(self):
pass
@@ -1,15 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
class NissanSettings(BrandSettings):
def __init__(self):
super().__init__()
def update_settings(self):
pass
@@ -1,15 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
class PSASettings(BrandSettings):
def __init__(self):
super().__init__()
def update_settings(self):
pass
@@ -1,15 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
class RivianSettings(BrandSettings):
def __init__(self):
super().__init__()
def update_settings(self):
pass
@@ -1,54 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
from opendbc.car.subaru.values import CAR, SubaruFlags
class SubaruSettings(BrandSettings):
def __init__(self):
super().__init__()
self.has_stop_and_go = False
self.stop_and_go_toggle = toggle_item_sp(tr("Stop and Go (Beta)"), "", param="SubaruStopAndGo", callback=self._on_toggle_changed)
self.stop_and_go_manual_parking_brake_toggle = toggle_item_sp(tr("Stop and Go for Manual Parking Brake (Beta)"), "",
param="SubaruStopAndGoManualParkingBrake", callback=self._on_toggle_changed)
self.items = [self.stop_and_go_toggle, self.stop_and_go_manual_parking_brake_toggle]
def _on_toggle_changed(self, _):
self.update_settings()
def stop_and_go_disabled_msg(self):
if not self.has_stop_and_go:
return tr("This feature is currently not available on this platform.")
elif not ui_state.is_offroad():
return tr("Enable \"Always Offroad\" in Device panel, or turn vehicle off to toggle.")
return ""
def update_settings(self):
bundle = ui_state.params.get("CarPlatformBundle")
if bundle:
platform = bundle.get("platform")
config = CAR[platform].config
self.has_stop_and_go = not (config.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
elif ui_state.CP:
self.has_stop_and_go = not (ui_state.CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
disabled_msg = self.stop_and_go_disabled_msg()
descriptions = [
tr("Experimental feature to enable auto-resume during stop-and-go for certain supported Subaru platforms."),
tr("Experimental feature to enable stop and go for Subaru Global models with manual handbrake. " +
"Models with electric parking brake should keep this disabled. Thanks to martinl for this implementation!")
]
for toggle, desc in zip([self.stop_and_go_toggle, self.stop_and_go_manual_parking_brake_toggle], descriptions, strict=True):
toggle.action_item.set_enabled(self.has_stop_and_go and ui_state.is_offroad())
toggle.set_description(f"<b>{disabled_msg}</b><br><br>{desc}" if disabled_msg else desc)
@@ -1,43 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
COOP_STEERING_MIN_KMH = 23
OEM_STEERING_MIN_KMH = 48
KM_TO_MILE = 0.621371
class TeslaSettings(BrandSettings):
def __init__(self):
super().__init__()
self.coop_steering_toggle = toggle_item_sp(tr("Cooperative Steering (Beta)"), "", param="TeslaCoopSteering")
self.items = [self.coop_steering_toggle]
def update_settings(self):
is_metric = ui_state.is_metric
unit = "km/h" if is_metric else "mph"
display_value_coop = COOP_STEERING_MIN_KMH if is_metric else round(COOP_STEERING_MIN_KMH * KM_TO_MILE)
display_value_oem = OEM_STEERING_MIN_KMH if is_metric else round(OEM_STEERING_MIN_KMH * KM_TO_MILE)
coop_steering_disabled_msg = tr("Enable \"Always Offroad\" in Device panel, or turn vehicle off to toggle.")
coop_steering_warning = tr(f"Warning: May experience steering oscillations below {display_value_oem} {unit} during turns, " +
"recommend disabling this feature if you experience these.")
coop_steering_desc = (
f"<b>{coop_steering_warning}</b><br><br>" +
f"{tr('Allows the driver to provide limited steering input while openpilot is engaged.')}<br>" +
f"{tr(f'Only works above {display_value_coop} {unit}.')}"
)
if not ui_state.is_offroad():
coop_steering_desc = f"<b>{coop_steering_disabled_msg}</b><br><br>{coop_steering_desc}"
self.coop_steering_toggle.set_description(coop_steering_desc)
self.coop_steering_toggle.action_item.set_enabled(ui_state.is_offroad())
@@ -1,15 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
class ToyotaSettings(BrandSettings):
def __init__(self):
super().__init__()
def update_settings(self):
pass
@@ -1,15 +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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
class VolkswagenSettings(BrandSettings):
def __init__(self):
super().__init__()
def update_settings(self):
pass
@@ -1,138 +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
import os
import pyray as rl
from collections.abc import Callable
from functools import partial
from openpilot.common.basedir import BASEDIR
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import DialogResult, Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.sunnypilot.lib.styles import style
from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder
from openpilot.selfdrive.ui.ui_state import ui_state
CAR_LIST_JSON_OUT = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "car", "car_list.json")
class LegendWidget(Widget):
def __init__(self, platform_selector):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 0, 350))
self._platform_selector = platform_selector
self._font = gui_app.font(FontWeight.NORMAL)
self._bold_font = gui_app.font(FontWeight.BOLD)
def _render(self, rect):
x = rect.x + 20
y = rect.y + 20
rl.draw_text_ex(self._font, tr("Select vehicle to force fingerprint manually."), rl.Vector2(x, y), 40, 0, style.ITEM_DESC_TEXT_COLOR)
y += 80
rl.draw_text_ex(self._font, tr("Colors represent vehicle fingerprint status:"), rl.Vector2(x, y), 40, 0, style.ITEM_DESC_TEXT_COLOR)
y += 80
items = [
(style.GREEN, tr("Fingerprinted automatically")),
(style.BLUE, tr("Manually selected fingerprint")),
(style.YELLOW, tr("Not fingerprinted or manually selected")),
]
for color, text in items:
p_color = self._platform_selector.color
is_active = p_color.r == color.r and p_color.g == color.g and p_color.b == color.b and p_color.a == color.a
rl.draw_rectangle(int(x), int(y + 5), 30, 30, color)
font = self._bold_font if is_active else self._font
text_color = rl.WHITE if is_active else style.ITEM_DESC_TEXT_COLOR
rl.draw_text_ex(font, f"- {text}", rl.Vector2(x + 50, y - 7), 40, 0, text_color)
y += 50
class PlatformSelector(Button):
def __init__(self, on_platform_change: Callable[[], None] | None = None):
super().__init__(tr("Vehicle"), self._on_clicked, button_style=ButtonStyle.NORMAL)
self.set_rect(rl.Rectangle(0, 0, 0, 120))
with open(CAR_LIST_JSON_OUT) as car_list_json:
self._platforms = json.load(car_list_json)
self._on_platform_change = on_platform_change
self.refresh()
@property
def text(self):
return self._label._text
def set_parent_rect(self, parent_rect):
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _on_clicked(self):
if ui_state.params.get("CarPlatformBundle"):
ui_state.params.remove("CarPlatformBundle")
self.refresh()
if self._on_platform_change:
self._on_platform_change()
else:
self._show_platform_dialog()
def _set_platform(self, platform_name):
if data := self._platforms.get(platform_name):
ui_state.params.put("CarPlatformBundle", {**data, "name": platform_name})
self.refresh()
if self._on_platform_change:
self._on_platform_change()
def _on_platform_selected(self, dialog, res):
if res == DialogResult.CONFIRM and dialog.selection_ref:
offroad_msg = tr("This setting will take effect immediately.") if ui_state.is_offroad else \
tr("This setting will take effect once the device enters offroad state.")
confirm_dialog = ConfirmDialog(offroad_msg, tr("Confirm"))
callback = partial(self._confirm_platform, dialog.selection_ref)
gui_app.set_modal_overlay(confirm_dialog, callback=callback)
def _confirm_platform(self, platform_name, res):
if res == DialogResult.CONFIRM:
self._set_platform(platform_name)
def _show_platform_dialog(self):
platforms = sorted(self._platforms.keys())
makes = sorted({self._platforms[p].get('make') for p in platforms})
folders = [TreeFolder(make, [TreeNode(p, {
'display_name': p,
'search_tags': f"{p} {self._platforms[p].get('make')} {' '.join(map(str, self._platforms[p].get('year', [])))} {self._platforms[p].get('model', p)}"
}) for p in platforms if self._platforms[p].get('make') == make]) for make in makes]
dialog = TreeOptionDialog(
tr("Select a vehicle"),
folders,
search_title=tr("Search your vehicle"),
search_subtitle=tr("Enter model year (e.g., 2021) and model (Toyota Corolla):"),
search_funcs=[lambda node: node.data.get('display_name', ''), lambda node: node.data.get('search_tags', '')]
)
callback = partial(self._on_platform_selected, dialog)
dialog.on_exit = callback
gui_app.set_modal_overlay(dialog, callback=callback)
def refresh(self):
self.color = style.YELLOW
self._platform = tr("Unrecognized Vehicle")
self.set_text(tr("No vehicle selected"))
if bundle := ui_state.params.get("CarPlatformBundle"):
self._platform = bundle.get("name", "")
self.set_text(self._platform)
self.color = style.BLUE
elif ui_state.CP and ui_state.CP.carFingerprint != "MOCK":
self._platform = ui_state.CP.carFingerprint
self.set_text(self._platform)
self.color = style.GREEN
self.set_enabled(True)
@@ -1,12 +1,6 @@
"""
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.common.params import Params
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.common.params import Params
class VisualsLayout(Widget):
@@ -0,0 +1,40 @@
"""
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 pyray as rl
from openpilot.selfdrive.ui.mici.layouts.settings import settings as OP
from openpilot.selfdrive.ui.mici.widgets.button import BigButton
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici
OP.PANEL_COLOR = rl.Color(10, 10, 10, 255)
ICON_SIZE = 70
OP.PanelType = IntEnum( # type: ignore
"PanelType",
[es.name for es in OP.PanelType] + [
"SUNNYLINK",
],
start=0,
)
class SettingsLayoutSP(OP.SettingsLayout):
def __init__(self):
OP.SettingsLayout.__init__(self)
sunnylink_btn = BigButton("sunnylink", "", "icons_mici/settings/developer/ssh.png")
sunnylink_btn.set_click_callback(lambda: self._set_current_panel(OP.PanelType.SUNNYLINK))
self._panels.update({
OP.PanelType.SUNNYLINK: OP.PanelInfo("sunnylink", SunnylinkLayoutMici(back_callback=lambda: self._set_current_panel(None))),
})
items = self._scroller._items.copy()
items.insert(1, sunnylink_btn)
self._scroller._items.clear()
for item in items:
self._scroller.add_widget(item)
@@ -0,0 +1,170 @@
import pyray as rl
from collections.abc import Callable
from cereal import log, custom
from openpilot.common.params_pyx import Params
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialogV2
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigButton, BigToggle
from openpilot.system.ui.lib.application import gui_app, MousePos
from openpilot.system.ui.widgets import NavWidget
from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
from openpilot.selfdrive.ui.sunnypilot.ui_state import ui_state_sp
class SunnylinkLayoutMici(NavWidget):
def __init__(self, back_callback: Callable):
super().__init__()
self.set_back_callback(back_callback)
self._restore_in_progress = False
self._backup_in_progress = False
self._sunnylink_enabled = ui_state_sp.params.get("SunnylinkEnabled")
self._sunnylink_toggle = BigToggle(text="enable sunnylink",
initial_state=self._sunnylink_enabled,
toggle_callback=self._sunnylink_toggle_callback)
self._sunnylink_pair_button = SunnylinkPairBigButton()
self._sunnylink_sponsor_button = SunnylinkPairBigButton(sponsor_pairing=True)
self._backup_btn = BigButton("backup settings", "", "")
self._backup_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=False))
self._restore_btn = BigButton("restore settings", "", "")
self._restore_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=True))
self._scroller = Scroller([
self._sunnylink_toggle,
self._sunnylink_pair_button,
self._sunnylink_sponsor_button,
self._backup_btn,
self._restore_btn,
], snap_items=False)
def _update_state(self):
super()._update_state()
self._sunnylink_enabled = ui_state_sp.sunnylink_enabled
self._sunnylink_toggle.set_text("disable sunnylink" if self._sunnylink_enabled else "enable sunnylink")
self._sunnylink_pair_button.set_visible(self._sunnylink_enabled)
self._sunnylink_sponsor_button.set_visible(self._sunnylink_enabled)
self._backup_btn.set_visible(self._sunnylink_enabled)
self._restore_btn.set_visible(self._sunnylink_enabled)
self.handle_backup_restore_progress()
def show_event(self):
super().show_event()
self._scroller.show_event()
ui_state_sp.update_params()
def _render(self, rect: rl.Rectangle):
self._scroller.render(rect)
def _sunnylink_toggle_callback(self, state: bool):
ui_state_sp.params.put_bool("SunnylinkEnabled", state)
ui_state_sp.update_params()
def _handle_backup_restore_btn(self, restore:bool = False):
lbl = tr("slide to restore") if restore else tr("slide to backup")
icon = "icons_mici/settings/network/new/trash.png"
dlg = BigConfirmationDialogV2(lbl, icon, confirm_callback=None)
gui_app.set_modal_overlay(dlg, callback=self._restore_handler if restore else self._backup_handler)
def _backup_handler(self, dialog_result: int):
self._backup_in_progress = True
self._backup_btn.set_enabled(False)
ui_state_sp.params.put_bool("BackupManager_CreateBackup", True)
def _restore_handler(self, dialog_result: int):
self._restore_in_progress = True
self._restore_btn.set_enabled(False)
ui_state_sp.params.put("BackupManager_RestoreVersion", "latest")
def handle_backup_restore_progress(self):
sunnylink_backup_manager = ui_state_sp.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:
if backup_status == custom.BackupManagerSP.Status.inProgress:
text = tr(f"{backup_progress}%")
self._backup_btn.set_value(text)
elif backup_status == custom.BackupManagerSP.Status.failed:
self._backup_in_progress = False
self._backup_btn.set_enabled(not ui_state_sp.is_onroad())
self._backup_btn.set_value(tr("Failed"))
elif backup_status == custom.BackupManagerSP.Status.completed:
self._backup_in_progress = False
dialog = BigDialog(title=tr("Settings backed up"), description="")
gui_app.set_modal_overlay(dialog)
self._backup_btn.set_enabled(not ui_state_sp.is_onroad())
elif self._restore_in_progress:
if restore_status == custom.BackupManagerSP.Status.inProgress:
self._restore_btn.set_enabled(False)
text = tr(f"{restore_progress}%")
self._restore_btn.set_value(text)
elif restore_status == custom.BackupManagerSP.Status.failed:
self._restore_in_progress = False
self._restore_btn.set_enabled(not ui_state_sp.is_onroad())
self._restore_btn.set_value(tr("Failed"))
dialog = BigDialog(title=tr("Unable to restore"), description="Try again later.")
gui_app.set_modal_overlay(dialog)
elif restore_status == custom.BackupManagerSP.Status.completed:
self._restore_in_progress = False
dialog = BigConfirmationDialogV2("slide to restart", "", confirm_callback=None)
gui_app.set_modal_overlay(dialog, callback=lambda: {
gui_app.request_close()
})
else:
can_enable = self._sunnylink_enabled and not ui_state_sp.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"))
class SunnylinkPairBigButton(BigButton):
def __init__(self, sponsor_pairing: bool = False):
self.sponsor_pairing = sponsor_pairing
super().__init__("", "", "")
def _update_state(self):
self.set_value("")
self.set_enabled(True)
if self.sponsor_pairing:
if ui_state_sp.sunnylink_state.is_sponsor():
self.set_text("thanks")
self.set_value("for sponsoring")
self.set_enabled(False)
else:
self.set_text("sponsor")
else:
if ui_state_sp.sunnylink_state.is_paired():
self.set_text(tr("paired"))
self.set_enabled(False)
else:
self.set_text(tr("pair"))
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
dlg: BigDialog | SunnylinkPairingDialog | None = None
if UNREGISTERED_SUNNYLINK_DONGLE_ID == (ui_state_sp.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID):
dlg = BigDialog(tr("sunnylink Dongle ID not found. Please reboot & try again."), "")
elif self.sponsor_pairing and not ui_state_sp.sunnylink_state.is_sponsor():
dlg = SunnylinkPairingDialog(sponsor_pairing=True)
elif not self.sponsor_pairing and not ui_state_sp.sunnylink_state.is_paired():
dlg = SunnylinkPairingDialog(sponsor_pairing=False)
if dlg:
gui_app.set_modal_overlay(dlg)
@@ -0,0 +1,53 @@
import base64
import pyray as rl
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.sunnypilot.ui_state import ui_state_sp
from openpilot.selfdrive.ui.mici.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.widgets.label import MiciLabel
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
label_text = tr("become a sunnypilot sponsor") if sponsor_pairing else tr("pair with sunnylink")
self._pair_label = MiciLabel(label_text, 48, font_weight=FontWeight.BOLD,
color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True)
def _get_pairing_url(self) -> str:
qr_string = "https://github.com/sponsors/sunnyhaibin"
if self._sponsor_pairing:
try:
sl_dongle_id = ui_state_sp.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):
if ui_state_sp.sunnylink_state.is_paired():
self._playing_dismiss_animation = True
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
-41
View File
@@ -1,41 +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.
"""
def update_item_from_param(item, key, params):
if not (action := getattr(item, 'action_item', None)):
return
if hasattr(action, 'set_state'):
action.set_state(params.get_bool(key))
elif hasattr(action, 'set_value'):
action.set_value(params.get(key, return_default=True))
else:
try:
val = int(params.get(key, return_default=True))
if hasattr(action, 'selected_button'):
action.selected_button = val
if hasattr(action, 'current_value'):
action.current_value = val
except (ValueError, TypeError):
pass
def sync_layout_params(layout, param_name, params):
targets = []
if toggles := getattr(layout, '_toggles', None):
targets.extend([(item, k) for k, item in toggles.items()])
items = getattr(layout, 'items', []) or getattr(getattr(layout, '_scroller', None), '_items', [])
for item in items:
action = getattr(item, 'action_item', None)
if key := getattr(action, 'param_key', None) or getattr(getattr(action, 'toggle', None), 'param_key', None):
targets.append((item, key))
for item, key in targets:
if param_name is None or key == param_name:
update_item_from_param(item, key, params)
+29 -40
View File
@@ -1,55 +1,44 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
from collections.abc import Callable
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 cereal import messaging, custom
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.common.params import Params
from openpilot.selfdrive.ui.ui_state import UIState
from cereal import messaging
from openpilot.common.params import Params
from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState
from openpilot.selfdrive.ui.sunnypilot.ui_helpers import sync_layout_params
class UIStateSP:
def __init__(self):
class UIStateSP(UIState):
_instance: 'UIStateSP | None' = None
def _initialize(self):
UIState._initialize(self)
self.params = Params()
self.params.add_watcher(self.on_param_change)
self.params.start()
self.sm_services_ext = [
"modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP",
"gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "liveDelay"
]
self.sunnylink_state = SunnylinkState()
self.active_layout = None
self.changed_params = set()
op_services = self.sm.services
sp_services = [
"modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP",
"carControl", "gpsLocationExternal", "gpsLocation", "liveTorqueParameters",
"carStateSP", "liveParameters", "liveMapDataSP", "carParamsSP"
]
self.sm = messaging.SubMaster(op_services + sp_services)
def set_active_layout(self, layout):
self.active_layout = layout
if layout:
sync_layout_params(layout, None, self.params)
# Callbacks
self._ui_update_callbacks: list[Callable[[], None]] = []
def on_param_change(self, param_name):
self.changed_params.add(param_name)
def add_ui_update_callback(self, callback: Callable[[], None]):
self._ui_update_callbacks.append(callback)
def update(self) -> None:
UIState.update(self)
self.sunnylink_state.start()
for callback in self._ui_update_callbacks:
callback()
if not self.params.is_watching():
cloudlog.warning("ParamWatcher thread died, restarting...")
self.params.start()
if self.changed_params:
while self.changed_params:
self.changed_params.pop()
if self.active_layout:
sync_layout_params(self.active_layout, None, self.params)
def _update_status(self) -> None:
UIState._update_status(self)
def update_params(self) -> None:
CP_SP_bytes = self.params.get("CarParamsSPPersistent")
if CP_SP_bytes is not None:
self.CP_SP = messaging.log_from_bytes(CP_SP_bytes, custom.CarParamsSP)
UIState.update_params(self)
self.sunnylink_enabled = self.params.get_bool("SunnylinkEnabled")
# Global instance
ui_state_sp = UIStateSP()
@@ -57,7 +57,7 @@ def setup_settings(click, pm: PubMaster, scroll=None):
def close_settings(click, pm: PubMaster, scroll=None):
click(140, 120)
click(240, 216)
def setup_settings_network(click, pm: PubMaster, scroll=None):
@@ -72,13 +72,13 @@ def setup_settings_network_advanced(click, pm: PubMaster, scroll=None):
def setup_settings_toggles(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
click(278, 620)
click(278, 720)
def setup_settings_software(click, pm: PubMaster, scroll=None):
put_update_params(Params())
setup_settings(click, pm)
click(278, 730)
click(278, 845)
def setup_settings_software_download(click, pm: PubMaster, scroll=None):
@@ -105,8 +105,8 @@ def setup_settings_software_branch_switcher(click, pm: PubMaster, scroll=None):
def setup_settings_firehose(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
scroll(-20, 278, 950)
click(278, 850)
scroll(-1000, 278, 950)
click(278, 950)
def setup_settings_developer(click, pm: PubMaster, scroll=None):
@@ -115,8 +115,8 @@ def setup_settings_developer(click, pm: PubMaster, scroll=None):
Params().put("CarParamsPersistent", CP.to_bytes())
setup_settings(click, pm)
scroll(-20, 278, 950)
click(278, 950)
scroll(-1000, 278, 950)
click(278, 1040)
def setup_keyboard(click, pm: PubMaster, scroll=None):
@@ -151,59 +151,49 @@ def setup_experimental_mode_description(click, pm: PubMaster, scroll=None):
def setup_openpilot_long_confirmation_dialog(click, pm: PubMaster, scroll=None):
setup_settings_developer(click, pm, scroll=scroll)
click(650, 960) # toggle sunnypilot longitudinal control
click(650, 960) # toggle openpilot longitudinal control
def setup_settings_sunnylink(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
click(278, 510)
click(278, 600)
def setup_settings_models(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
click(278, 840)
click(278, 950)
def setup_settings_steering(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
click(278, 950)
click(278, 1040)
def setup_settings_cruise(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
scroll(-4, 278, 950)
click(278, 860)
scroll(-40, 278, 950)
click(278, 1040)
def setup_settings_visuals(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
scroll(-20, 278, 950)
scroll(-1000, 278, 950)
click(278, 330)
def setup_settings_display(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
scroll(-20, 278, 950)
click(278, 420)
scroll(-1000, 278, 950)
click(278, 450)
def setup_settings_osm(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
scroll(-20, 278, 950)
click(278, 520)
scroll(-1000, 278, 950)
click(278, 600)
def setup_settings_trips(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
scroll(-20, 278, 950)
click(278, 630)
scroll(-1000, 278, 950)
click(278, 720)
def setup_settings_vehicle(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
scroll(-20, 278, 950)
click(278, 750)
scroll(-1000, 278, 950)
click(278, 845)
def setup_onroad(click, pm: PubMaster, scroll=None):
ds = messaging.new_message('deviceState')
@@ -342,14 +332,9 @@ class TestUI:
time.sleep(0.01)
pyautogui.mouseUp(self.ui.left + x, self.ui.top + y, *args, **kwargs)
def scroll(self, clicks: int, x, y, *args, **kwargs):
if clicks == 0:
return
click = -1 if clicks < 0 else 1 # -1 = down, 1 = up
for _ in range(abs(clicks)):
pyautogui.scroll(click, self.ui.left + x, self.ui.top + y, *args, **kwargs) # scroll for individual clicks since we need to delay between clicks
time.sleep(0.01) # small delay between scroll clicks to work properly
time.sleep(2) # wait for scroll to fully settle
def scroll(self, clicks, x, y, *args, **kwargs):
pyautogui.scroll(clicks, self.ui.left + x, self.ui.top + y, *args, **kwargs)
time.sleep(UI_DELAY)
@with_processes(["ui"])
def test_ui(self, name, setup_case):
@@ -369,7 +354,6 @@ def create_screenshots():
with OpenpilotPrefix():
params = Params()
params.put("DongleId", "123456789012345")
params.put("SunnylinkDongleId", "123456789012345")
# Set branch name
params.put("UpdaterCurrentDescription", VERSION)
+2
View File
@@ -9,6 +9,8 @@ from openpilot.selfdrive.ui.layouts.main import MainLayout
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
from openpilot.selfdrive.ui.ui_state import ui_state
if gui_app.sunnypilot_ui():
from openpilot.selfdrive.ui.sunnypilot.ui_state import ui_state_sp as ui_state
def main():
cores = {5, }
+4 -7
View File
@@ -6,13 +6,12 @@ from collections.abc import Callable
from enum import Enum
from cereal import messaging, car, log
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.lib.prime_state import PrimeState
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.hardware import HARDWARE, PC
from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP
BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50
@@ -22,7 +21,7 @@ class UIStatus(Enum):
OVERRIDE = "override"
class UIState(UIStateSP):
class UIState:
_instance: 'UIState | None' = None
def __new__(cls):
@@ -32,7 +31,7 @@ class UIState(UIStateSP):
return cls._instance
def _initialize(self):
UIStateSP.__init__(self)
self.params = Params()
self.sm = messaging.SubMaster(
[
"modelV2",
@@ -56,7 +55,7 @@ class UIState(UIStateSP):
"carControl",
"liveParameters",
"rawAudioData",
] + self.sm_services_ext
]
)
self.prime_state = PrimeState()
@@ -112,7 +111,6 @@ class UIState(UIStateSP):
if time.monotonic() - self._param_update_time > 5.0:
self.update_params()
device.update()
UIStateSP.update(self)
def _update_state(self) -> None:
# Handle panda states updates
@@ -182,7 +180,6 @@ class UIState(UIStateSP):
self.has_longitudinal_control = self.params.get_bool("AlphaLongitudinalEnabled")
else:
self.has_longitudinal_control = self.CP.openpilotLongitudinalControl
UIStateSP.update_params(self)
self._param_update_time = time.monotonic()
-133
View File
@@ -1,133 +0,0 @@
# Comparative Analysis of Parameter Access Methods: `Params::get` vs. `ParamWatcher`
## Inefficiencies in Standard Parameter Access
The standard `Params::get()` method executes a full file I/O lifecycle—opening, allocating, reading, and closing—for every function call. This approach results in significant CPU overhead and memory churn due to the frequency of these operations in the user interface loop.
### System Overhead Analysis
* **System Call Overhead**: Every read operation requires context switches into kernel mode. The `Params::get` function calls `util::read_file` (sunnypilot, 2025), which subsequently invokes `std::ifstream` (sunnypilot, 2025).
* **Impact**: Frequent context switching degrades performance (Linux man-pages, 2025 -a; Linux man-pages, 2025 -b).
* **C++ Stream Overhead**: The use of `std::ifstream` introduces additional overhead for maintaining stream state and buffering compared to raw file descriptors (cppreference.com, n.d.-a; Codezup, 2025).
* **Memory Churn**: The instantiation of `std::string result(size, '\0');` forces heap allocation and deallocation during every call (sunnypilot, 2025). This stresses the memory allocator and can lead to fragmentation (cppreference.com, n.d.).
## The ParamWatcher Optimization
The `ParamWatcher` implementation utilizes OS-level file system events, such as `inotify` on Linux or `FSEvents` on macOS, to maintain a Random Access Memory (RAM) cache. This architecture eliminates the need for continuous polling.
### Performance Comparison
| Feature | Standard `Params::get` | Optimized `ParamWatcher` |
| :--- | :--- | :--- |
| **Workflow** | `open``malloc``read``close` | `dict.get()` (RAM lookup) |
| **Complexity** | **O(N * F)** (Linear to toggles & FPS) | **O(1)** (Constant time) |
| **Disk I/O** | ~1,000 reads/sec (50 toggles @ 20FPS) | **0 reads/sec** (Steady state) |
| **Memory** | New string object per call (High GC pressure) | Returns reference (Zero GC pressure) |
## Architectural Mismatch of Standard Modules
Standard C++ modules like `std::ifstream` are optimized for **throughput**—reading large files sequentially—rather than **latency** required for polling small files frequently.
* **The I/O Trap**: Even when a file resides in the OS page cache (RAM), invoking `open()` and `read()` forces a CPU mode switch (User → Kernel → User). Executing this sequence 1,000 times per second consumes CPU cycles merely to verify state constancy.
* **The Memory Trap**: The `std::string` class allocates memory on the heap. Repeated allocation creates short-lived objects, which in C++ fragments memory. In Python (which wraps this), it triggers the Garbage Collector, pausing the UI.
* **The Query Mismatch**: `Params::get` queries the current value every frame, whereas `ParamWatcher` waits for a notification of change, serving cached values in the interim.
## Implementation Analysis
The `ParamWatcher` class provides a cross-platform solution for monitoring file system changes, specifically targeting the parameter files used in Openpilot. The implementation leverages the `ctypes` library to interface directly with operating system kernels, bypassing higher-level abstractions for maximum performance.
### Linux Implementation (`_run_linux`)
The Linux implementation interacts directly with the kernel's `inotify` subsystem (Linux man-pages, 2025 -c).
* **Library Loading**: `libc = ctypes.CDLL('libc.so.6')` loads the standard C library to access system calls.
* **Initialization**: `inotify_init()` is called to create a new inotify instance, returning a file descriptor.
* **Watch Setup**: `inotify_add_watch(fd, path, mask)` registers the parameters directory. The mask includes `IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE` (Linux Kernel Organization, 2005) to capture all relevant file changes.
* **Event Loop**:
* **Polling**: `select.epoll()` is used to efficiently wait for activity on the file descriptor without busy-waiting.
* **Reading**: When events occur, `os.read(fd, 1024)` retrieves the raw binary event data.
* **Parsing**: The code uses Python's `struct` module (`struct.unpack_from("iIII", ...)`) to parse the C-style `inotify_event` structures directly from the buffer, avoiding the overhead of defining `ctypes` structures.
* **Handling**: Extracted filenames are passed to `_trigger_callbacks`, which invalidates the specific cache entry (`self._cache.pop(path, None)`), forcing a fresh read on the next access.
### macOS Implementation (`_run_darwin`)
The macOS implementation uses the `FSEvents` API from the `CoreServices` framework (Apple Inc., n.d.-a), which is more efficient than `kqueue` for directory monitoring.
* **Framework Loading**: `ctypes.cdll.LoadLibrary` loads `CoreServices` and `CoreFoundation`.
* **Callback Definition**: `CFUNCTYPE` is used to define a C-compatible callback function. This function is invoked by the OS whenever a change occurs in the watched directory.
* **Stream Creation**: `FSEventStreamCreate` creates a stream for the target directory. The `kFSEventStreamCreateFlagFileEvents` flag is used to request file-level granularity where available.
* **Event Filtering**: The callback filters events using flags such as `kFSEventStreamEventFlagItemCreated` and `kFSEventStreamEventFlagItemModified` to ensure only relevant file changes trigger updates (Apple Inc., n.d.-c).
* **Scheduling**: `FSEventStreamScheduleWithRunLoop` attaches the stream to the current thread's run loop (Apple Inc., n.d.-b).
* **Execution**: `CFRunLoopRun()` starts the event loop. This passes control to the OS, which wakes the thread only when necessary.
* **Handling**: Inside the callback, the code iterates through the changed paths provided by the OS. It extracts the filename and calls `_trigger_callbacks` to invalidate the cache for that specific parameter.
### Python ctypes Integration
The use of `ctypes` (Python Software Foundation, 2025) is a strategic choice. It allows the Python interpreter to load shared libraries (`libc.so.6` on Linux, `CoreServices` on macOS) and call C functions directly. This approach avoids the overhead of spawning subprocesses or compiling external C extensions, keeping the codebase pure Python while achieving C-level system integration.
### Memory Impact Analysis
With 232 defined parameters in `param_keys.h`, the maximum static RAM footprint of `ParamWatcher` is estimated to be **less than 250 KB**. Even if every single parameter were cached simultaneously, this static usage is negligible. Importantly, this stable footprint is likely more probable to maintain no trend of memory increase, whenc compared to the standard `Params::get` approach, which generates **megabytes** of short-lived "garbage" allocations per second, forcing the Python Garbage Collector to pause execution repeatedly.
## Architectural Integration: The Process-Local Singleton Pattern
To ensure resource efficiency within openpilot's multi-process architecture (e.g., `ui`, `controlsd`, `modeld`), `ParamWatcher` implements the Singleton design pattern (Gamma et al., 1994) using the Python `__new__` allocator.
### Process Isolation and Concurrency
In the context of Python's memory model, a Singleton ensures a single instance exists *per process*. This behavior aligns with openpilot's multiprocess design:
* **Intra-Process Efficiency**: Within a single heavy process like `ui`, multiple sub-components (e.g., `UIState`, `SunnylinkState`) import and use `Params`. The Singleton pattern ensures they share a single `inotify` thread and a unified RAM cache. This prevents the proliferation of redundant watcher threads, which would otherwise compete for the Global Interpreter Lock (GIL).
* **Inter-Process Safety**: Distinct processes (e.g., `modeld` vs. `ui`) maintain completely isolated `ParamWatcher` instances. This isolation eliminates the need for complex Inter-Process Communication (IPC) locking mechanisms for the cache, as each process synchronizes its independent state via the OS file system events.
### Empirical Verification
Runtime analysis demonstrates that multiple instantiation attempts result in a shared object reference, minimizing memory footprint.
* **Test Case**: Instantiating `ParamWatcher` in `UIStateSP` and subsequently in a standalone script within the same process.
* **Result**: Both instances report the exact same memory address (`4814358960`) and share the same background thread ID (`6114635776`).
* **Impact**: The system incurs the overhead of the watcher thread (measured at < 0.1% CPU idle usage) only once per active process, regardless of import frequency. The average CPU usage across one minute was 0.002%.
## Limitations and Trade-offs
While `ParamWatcher` offers superior performance for UI rendering, it presents specific trade-offs:
* **Static RAM Usage**: `ParamWatcher` maintains a persistent dictionary cache of all accessed parameters (~50KB), whereas `Params::get` uses zero static RAM but incurs high dynamic memory access.
* **Event Latency**: In high-load scenarios, `inotify` events may experience slight delays or coalescing compared to direct reads. However, for user interface applications, this latency (<10ms) is imperceptible.
* **Complexity**: The solution (the process singleton approach) requires managing a background thread and OS-specific event loops, increasing code complexity compared to the synchronous `Params::get` function.
## Alternative Architecture Considered: ZeroMQ Service (ZMQ)
During the development of `ParamWatcher`, a Client-Server architecture using ZMQ was evaluated. In this architecture, a single background service process would monitor file system events and publish changes over a ZMQ PUB socket to multiple client processes (SUB).
### Trade-off Analysis
| Metric | In-Process (Current) | ZMQ Service (Rejected) |
| :--- | :--- |:------------------------------------------------------|
| **Memory Usage** | Low (1 thread/process) | High (1 full Python process + ZMQ buffers per client) |
| **CPU Usage** | Low (Direct callback) | High (Serialization + TCP Stack + Deserialization) |
| **Latency** | Instant (<0.1ms) | Variable (TCP Loopback overhead) |
| **Scalability** | Limited by OS file handles | Limited by TCP ports/buffers |
| **Robustness** | Process-isolated failure | Single point of failure (Service crash affects all) |
### Decision Rationale
While the ZMQ approach offers better isolation and reduces the total number of OS file watchers (1 vs N), the overhead of inter-process communication (IPC) proved excessive for this use case.
* **Efficiency**: Even with 50+ processes, the memory footprint of 50 simple threads is significantly lower than the overhead of a dedicated Python service process plus the ZMQ context in every client.
* **Complexity**: The ZMQ architecture introduced synchronization challenges (e.g., service startup race conditions, "Address already in use" errors) that outweighed its benefits.
* **Performance**: The latency of serializing messages and passing them through the TCP stack is orders of magnitude higher than a direct function call within the same process memory space.
## Conclusion
Replacing polling mechanisms with event-driven caching shifts the computational load from kernel space (syscalls) to user space (RAM). This transition eliminates I/O overhead and UI stutters caused by garbage collection, resulting in a more responsive user experience. The In-Process Singleton approach was selected as the optimal balance between performance, complexity, and resource efficiency.
## References
Apple Inc. (n.d.-a). *File System Events*. Retrieved from https://developer.apple.com/documentation/coreservices/file_system_events
Apple Inc. (n.d.-b). *CFRunLoop*. Retrieved from https://developer.apple.com/documentation/corefoundation/cfrunloop
Apple Inc. (n.d.-c). *FSEventStreamEventFlags*. Retrieved from https://developer.apple.com/documentation/coreservices/1455361-fseventstreameventflags
Codezup. (2025). *Efficient File I/O in C++*. Retrieved from https://codezup.com/efficient-file-io-cpp-best-practices/
cppreference.com. (n.d.-a). *std::basic_ifstream*. Retrieved from https://en.cppreference.com/w/cpp/io/basic_ifstream
cppreference.com. (n.d.-b). *std::basic_string*. Retrieved from https://en.cppreference.com/w/cpp/string/basic_string/basic_string
Linux man-pages. (2025 -a). *open(2)*. Retrieved from https://man7.org/linux/man-pages/man2/open.2.html
Linux man-pages. (2025-b). *read(2)*. Retrieved from https://man7.org/linux/man-pages/man2/read.2.html
Linux man-pages. (2025 -c). *inotify(7)*. Retrieved from https://man7.org/linux/man-pages/man7/inotify.7.html
Linux Kernel Organization. (2005). *include/uapi/linux/inotify.h*. Retrieved from https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/uapi/linux/inotify.h
Python Software Foundation. (2025). *ctypes — A foreign function library for Python*. Retrieved from https://docs.python.org/3/library/ctypes.html
Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). *Design Patterns: Elements of Reusable Object-Oriented Software*. Addison-Wesley.
sunnypilot. (2025). *common/params.cc* [Source code]. GitHub. https://github.com/sunnypilot/sunnypilot/blob/master/common/params.cc#L180C1-L206C2
sunnypilot. (2025). *common/util.cc* [Source code]. GitHub. https://github.com/sunnypilot/sunnypilot/blob/master/common/util.cc#L79C1-L117C2
View File
-193
View File
@@ -1,193 +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 os
import platform
import struct
import select
import threading
import time
import ctypes
import ctypes.util
import traceback
from ctypes import c_void_p, c_size_t, POINTER, c_uint32, c_uint64
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware.hw import Paths
IN_MODIFY = 0x00000002
IN_CREATE = 0x00000100
IN_DELETE = 0x00000200
IN_MOVED_TO = 0x00000080
IN_CLOSE_WRITE = 0x00000008
class ParamWatcher(Params):
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
super().__init__()
cloudlog.warning("ParamWatcher initialized")
self._cache = {}
self._last_trigger = {}
self._version = {}
self._lock = threading.Lock()
self._callbacks = []
self.last_accessed_param = None
self._initialized = True
self.start()
def start(self):
if getattr(self, '_thread', None) and self._thread.is_alive():
return
self._thread = threading.Thread(target=self._run_watcher, daemon=True)
self._thread.start()
def is_watching(self):
return getattr(self, '_thread', None) and self._thread.is_alive()
def add_watcher(self, callback):
if callback not in self._callbacks:
self._callbacks.append(callback)
def _trigger_callbacks(self, path):
with self._lock:
if (now := time.monotonic()) - self._last_trigger.get(path, 0) < 0.1:
return
self._last_trigger[path] = now
self._version[path] = self._version.get(path, 0) + 1
self._cache.pop(path, None)
for callback in self._callbacks:
try:
callback(path)
except Exception:
cloudlog.exception("Param watcher callback failed")
def _get_cached(self, key, getter, sig):
k = str(key)
with self._lock:
bucket = self._cache.get(k)
if bucket and sig in bucket:
if bucket[sig][0] == self._version.get(k, 0):
return bucket[sig][1]
start_ver = self._version.get(k, 0)
val = getter()
with self._lock:
if self._version.get(k, 0) != start_ver:
val = getter()
self._cache.setdefault(k, {})[sig] = (self._version.get(k, 0), val)
return val
def get(self, key, block=False, return_default=False):
self.last_accessed_param = key
if block:
return super().get(key, block, return_default)
fetcher = super().get
return self._get_cached(key, lambda: fetcher(key, block, return_default), (block, return_default))
def get_bool(self, key, block=False):
self.last_accessed_param = key
if block:
return super().get_bool(key, block)
fetcher = super().get_bool
return self._get_cached(key, lambda: fetcher(key, block), ("bool", block))
def _run_watcher(self):
system = platform.system()
while True:
try:
if system == "Linux":
self._run_linux()
elif system == "Darwin":
self._run_darwin()
except Exception:
cloudlog.exception("Param watcher crashed")
time.sleep(2)
def _run_linux(self):
path = Paths.params_root()
libc = ctypes.CDLL('libc.so.6')
fd = libc.inotify_init()
libc.inotify_add_watch(fd, path.encode(), IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE)
try:
poll = select.epoll()
poll.register(fd, select.EPOLLIN)
while True:
for fileno, _ in poll.poll():
if fileno == fd:
buffer = os.read(fd, 2048)
i = 0
while i + 16 <= len(buffer):
_, mask, _, name_len = struct.unpack_from("iIII", buffer, i)
if mask & (IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE):
name = buffer[i+16:i+16+name_len].rstrip(b"\0").decode()
if not name.startswith("."):
self._trigger_callbacks(name)
i += 16 + name_len
finally:
if 'poll' in locals():
poll.unregister(fd)
poll.close()
os.close(fd)
def _run_darwin(self):
CS = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreServices"))
CF = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation"))
kCFAllocatorDefault = c_void_p(0)
kCFStringEncodingUTF8 = 0x08000100
kFSEventStreamCreateFlagFileEvents = 0x00000010
kFSEventStreamEventFlagItemCreated = 0x00000100
kFSEventStreamEventFlagItemRemoved = 0x00000200
kFSEventStreamEventFlagItemRenamed = 0x00000800
kFSEventStreamEventFlagItemModified = 0x00001000
CF.CFStringCreateWithCString.restype = c_void_p
CF.CFStringCreateWithCString.argtypes = [c_void_p, ctypes.c_char_p, c_uint32]
CF.CFArrayCreate.restype = c_void_p
CF.CFArrayCreate.argtypes = [c_void_p, POINTER(c_void_p), c_size_t, c_void_p]
CS.FSEventStreamCreate.restype = c_void_p
CS.FSEventStreamCreate.argtypes = [c_void_p, c_void_p, c_void_p, c_void_p, c_uint64, ctypes.c_double, c_uint32]
CS.FSEventStreamScheduleWithRunLoop.argtypes = [c_void_p, c_void_p, c_void_p]
CS.FSEventStreamStart.argtypes = [c_void_p]
CF.CFRunLoopGetCurrent.restype = c_void_p
def _cb(stream, ctx, num, paths, flags, ids):
try:
paths_arr = ctypes.cast(paths, POINTER(c_void_p))
flags_arr = ctypes.cast(flags, POINTER(c_uint32))
for i in range(num):
path = ctypes.cast(paths_arr[i], ctypes.c_char_p).value
if path and (flags_arr[i] & (kFSEventStreamEventFlagItemCreated | kFSEventStreamEventFlagItemRemoved |
kFSEventStreamEventFlagItemRenamed | kFSEventStreamEventFlagItemModified)):
self._trigger_callbacks(os.path.basename(path.decode('utf-8').rstrip('/')))
except Exception:
traceback.print_exc()
self._darwin_cb = ctypes.CFUNCTYPE(None, c_void_p, c_void_p, c_size_t, c_void_p, POINTER(c_uint32), POINTER(c_uint64))(_cb)
path_str = Paths.params_root().encode('utf-8')
cf_path = CF.CFStringCreateWithCString(kCFAllocatorDefault, path_str, kCFStringEncodingUTF8)
cf_paths = CF.CFArrayCreate(kCFAllocatorDefault, (c_void_p * 1)(cf_path), 1, None)
stream = CS.FSEventStreamCreate(kCFAllocatorDefault, self._darwin_cb, None, cf_paths, -1, 0.05, kFSEventStreamCreateFlagFileEvents)
run_loop = CF.CFRunLoopGetCurrent()
kDefaultMode = CF.CFStringCreateWithCString(kCFAllocatorDefault, b"kCFRunLoopDefaultMode", kCFStringEncodingUTF8)
CS.FSEventStreamScheduleWithRunLoop(stream, run_loop, kDefaultMode)
CS.FSEventStreamStart(stream)
CF.CFRunLoopRun()
-4
View File
@@ -1,4 +0,0 @@
from openpilot.common.params_pyx import ParamKeyFlag, ParamKeyType, UnknownKeyName
from openpilot.sunnypilot.common.param_watcher import ParamWatcher as Params
__all__ = ["Params", "ParamKeyFlag", "ParamKeyType", "UnknownKeyName"]
View File
@@ -1,94 +0,0 @@
import time
import pytest
import threading
import tracemalloc
from openpilot.common.params import Params
from openpilot.common.params_pyx import UnknownKeyName
from openpilot.sunnypilot.common.param_watcher import ParamWatcher
class TestParamWatcher:
BYTES_KEYS = ["LocationFilterInitialState", "UpdaterCurrentReleaseNotes", "UpdaterNewReleaseNotes"]
BOOL_KEYS = [
"IsMetric", "AdbEnabled", "AlwaysOnDM", "ExperimentalMode",
"ExperimentalModeConfirmed", "DisengageOnAccelerator",
"OpenpilotEnabledToggle", "RecordAudio", "RecordFront"
]
_key_counter = 0
@pytest.fixture(autouse=True)
def setup_method(self):
self.params = Params()
self.key_index = TestParamWatcher._key_counter
TestParamWatcher._key_counter += 1
self.bytes_key = self.BYTES_KEYS[self.key_index % len(self.BYTES_KEYS)]
self.bool_key = self.BOOL_KEYS[self.key_index % len(self.BOOL_KEYS)]
@pytest.fixture
def param_watcher(self):
ParamWatcher._instance = None
param_watch = ParamWatcher()
param_watch.start()
assert param_watch.is_watching(), "ParamWatcher thread died"
return param_watch
def teardown_method(self):
for key in (self.bytes_key, self.bool_key):
try:
self.params.remove(key)
except UnknownKeyName:
pass
def test_watcher_detects_change(self, param_watcher):
val = b"123"
self.params.put(self.bytes_key, val)
assert param_watcher.get(self.bytes_key) == val
def test_watcher_get_bool(self, param_watcher):
self.params.put_bool(self.bool_key, True)
assert param_watcher.get_bool(self.bool_key) is True # First read should populate internal cache
def test_performance_comparison(self, param_watcher):
plain_params = self.params
for key in self.BYTES_KEYS:
plain_params.put(key, b"x" * 10000)
param_watcher.get(key)
for key in self.BOOL_KEYS:
plain_params.put_bool(key, True)
param_watcher.get_bool(key)
def bench(get_bytes, get_bool):
tracemalloc.start()
start_time = time.process_time()
for _ in range(1000):
for key in self.BYTES_KEYS:
get_bytes(key)
for key in self.BOOL_KEYS:
get_bool(key)
duration = time.process_time() - start_time
_, memory = tracemalloc.get_traced_memory()
tracemalloc.stop()
return duration, memory
plain_cpu, plain_memory = bench(plain_params.get, plain_params.get_bool)
watcher_cpu, watcher_memory = bench(param_watcher.get, param_watcher.get_bool)
# ParamWatcher *should* be significantly faster and use less memory than Params()
assert watcher_cpu < plain_cpu * 0.6, f"PW CPU ({watcher_cpu:.4f}s) should be < 60% of Param call ({plain_cpu:.4f}s)"
assert watcher_memory < plain_memory * 0.5, f"PW Memory ({watcher_memory}B) should be < 50% of Param call ({plain_memory}B)"
def test_cache_invalidation_simulation(self, param_watcher):
self.params.put(self.bytes_key, b"old")
assert param_watcher.get(self.bytes_key) == b"old"
time.sleep(0.2)
event = threading.Event()
param_watcher.add_watcher(lambda key: event.set())
param_watcher._trigger_callbacks(self.bytes_key)
assert event.wait(timeout=2), "Callback not triggered"
self.params.put(self.bytes_key, b"new")
assert param_watcher.get(self.bytes_key) == b"new"
+16 -1
View File
@@ -1,3 +1,5 @@
import glob
Import('env', 'envCython', 'arch', 'cereal', 'messaging', 'common', 'visionipc', 'transformations')
lenv = env.Clone()
lenvCython = envCython.Clone()
@@ -21,6 +23,12 @@ thneed_src_qcom = thneed_src_common + ["thneed/thneed_qcom2.cc"]
thneed_src_pc = thneed_src_common + ["thneed/thneed_pc.cc"]
thneed_src = thneed_src_qcom if arch == "larch64" else thneed_src_pc
# SNPE except on Mac and ARM Linux
snpe_lib = []
if arch != "Darwin" and arch != "aarch64":
common_src += ['runners/snpemodel.cc']
snpe_lib += ['SNPE']
# OpenCL is a framework on Mac
if arch == "Darwin":
frameworks += ['OpenCL']
@@ -32,13 +40,20 @@ for pathdef, fn in {'TRANSFORM': 'transforms/transform.cl', 'LOADYUV': 'transfor
for xenv in (lenv, lenvCython):
xenv['CXXFLAGS'].append(f'-D{pathdef}_PATH=\\"{File(fn).abspath}\\"')
# Compile cython
snpe_rpath_qcom = "/data/pythonpath/third_party/snpe/larch64"
snpe_rpath_pc = f"{Dir('#').abspath}/third_party/snpe/x86_64-linux-clang"
snpe_rpath = lenvCython['RPATH'] + [snpe_rpath_qcom if arch == "larch64" else snpe_rpath_pc]
cython_libs = envCython["LIBS"] + libs
snpemodel_lib = lenv.Library('snpemodel', ['runners/snpemodel.cc'])
commonmodel_lib = lenv.Library('commonmodel', common_src)
lenvCython.Program('runners/runmodel_pyx.so', 'runners/runmodel_pyx.pyx', LIBS=cython_libs, FRAMEWORKS=frameworks)
lenvCython.Program('runners/snpemodel_pyx.so', 'runners/snpemodel_pyx.pyx', LIBS=[snpemodel_lib, snpe_lib, *cython_libs], FRAMEWORKS=frameworks, RPATH=snpe_rpath)
lenvCython.Program('models/commonmodel_pyx.so', 'models/commonmodel_pyx.pyx', LIBS=[commonmodel_lib, *cython_libs], FRAMEWORKS=frameworks)
if arch == "larch64":
thneed_lib = env.SharedLibrary('thneed', thneed_src, LIBS=[common, 'OpenCL', 'dl'])
thneedmodel_lib = env.Library('thneedmodel', ['runners/thneedmodel.cc'])
lenvCython.Program('runners/thneedmodel_pyx.so', 'runners/thneedmodel_pyx.pyx', LIBS=envCython["LIBS"]+[thneedmodel_lib, thneed_lib, common, 'dl', 'OpenCL'])
lenvCython.Program('runners/thneedmodel_pyx.so', 'runners/thneedmodel_pyx.pyx', LIBS=envCython["LIBS"]+[thneedmodel_lib, thneed_lib, common, 'dl', 'OpenCL'])
+5
View File
@@ -4,15 +4,20 @@ from openpilot.sunnypilot.modeld.runners.runmodel_pyx import RunModel, Runtime
assert Runtime
USE_THNEED = int(os.getenv('USE_THNEED', str(int(TICI))))
USE_SNPE = int(os.getenv('USE_SNPE', str(int(TICI))))
class ModelRunner(RunModel):
THNEED = 'THNEED'
SNPE = 'SNPE'
ONNX = 'ONNX'
def __new__(cls, paths, *args, **kwargs):
if ModelRunner.THNEED in paths and USE_THNEED:
from openpilot.sunnypilot.modeld.runners.thneedmodel_pyx import ThneedModel as Runner
runner_type = ModelRunner.THNEED
elif ModelRunner.SNPE in paths and USE_SNPE:
from openpilot.sunnypilot.modeld.runners.snpemodel_pyx import SNPEModel as Runner
runner_type = ModelRunner.SNPE
elif ModelRunner.ONNX in paths:
from openpilot.sunnypilot.modeld.runners.onnxmodel import ONNXModel as Runner
runner_type = ModelRunner.ONNX
+1
View File
@@ -1,3 +1,4 @@
#pragma once
#include "sunnypilot/modeld/runners/runmodel.h"
#include "sunnypilot/modeld/runners/snpemodel.h"
+116
View File
@@ -0,0 +1,116 @@
#pragma clang diagnostic ignored "-Wexceptions"
#include "sunnypilot/modeld/runners/snpemodel.h"
#include <cstring>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include "common/util.h"
#include "common/timing.h"
void PrintErrorStringAndExit() {
std::cerr << zdl::DlSystem::getLastErrorString() << std::endl;
std::exit(EXIT_FAILURE);
}
SNPEModel::SNPEModel(const std::string path, float *_output, size_t _output_size, int runtime, bool _use_tf8, cl_context context) {
output = _output;
output_size = _output_size;
use_tf8 = _use_tf8;
#ifdef QCOM2
if (runtime == USE_GPU_RUNTIME) {
snpe_runtime = zdl::DlSystem::Runtime_t::GPU;
} else if (runtime == USE_DSP_RUNTIME) {
snpe_runtime = zdl::DlSystem::Runtime_t::DSP;
} else {
snpe_runtime = zdl::DlSystem::Runtime_t::CPU;
}
assert(zdl::SNPE::SNPEFactory::isRuntimeAvailable(snpe_runtime));
#endif
model_data = util::read_file(path);
assert(model_data.size() > 0);
// load model
std::unique_ptr<zdl::DlContainer::IDlContainer> container = zdl::DlContainer::IDlContainer::open((uint8_t*)model_data.data(), model_data.size());
if (!container) { PrintErrorStringAndExit(); }
LOGW("loaded model with size: %lu", model_data.size());
// create model runner
zdl::SNPE::SNPEBuilder snpe_builder(container.get());
while (!snpe) {
#ifdef QCOM2
snpe = snpe_builder.setOutputLayers({})
.setRuntimeProcessor(snpe_runtime)
.setUseUserSuppliedBuffers(true)
.setPerformanceProfile(zdl::DlSystem::PerformanceProfile_t::HIGH_PERFORMANCE)
.build();
#else
snpe = snpe_builder.setOutputLayers({})
.setUseUserSuppliedBuffers(true)
.setPerformanceProfile(zdl::DlSystem::PerformanceProfile_t::HIGH_PERFORMANCE)
.build();
#endif
if (!snpe) std::cerr << zdl::DlSystem::getLastErrorString() << std::endl;
}
// create output buffer
zdl::DlSystem::UserBufferEncodingFloat ub_encoding_float;
zdl::DlSystem::IUserBufferFactory &ub_factory = zdl::SNPE::SNPEFactory::getUserBufferFactory();
const auto &output_tensor_names_opt = snpe->getOutputTensorNames();
if (!output_tensor_names_opt) throw std::runtime_error("Error obtaining output tensor names");
const auto &output_tensor_names = *output_tensor_names_opt;
assert(output_tensor_names.size() == 1);
const char *output_tensor_name = output_tensor_names.at(0);
const zdl::DlSystem::TensorShape &buffer_shape = snpe->getInputOutputBufferAttributes(output_tensor_name)->getDims();
if (output_size != 0) {
assert(output_size == buffer_shape[1]);
} else {
output_size = buffer_shape[1];
}
std::vector<size_t> output_strides = {output_size * sizeof(float), sizeof(float)};
output_buffer = ub_factory.createUserBuffer(output, output_size * sizeof(float), output_strides, &ub_encoding_float);
output_map.add(output_tensor_name, output_buffer.get());
}
void SNPEModel::addInput(const std::string name, float *buffer, int size) {
const int idx = inputs.size();
const auto &input_tensor_names_opt = snpe->getInputTensorNames();
if (!input_tensor_names_opt) throw std::runtime_error("Error obtaining input tensor names");
const auto &input_tensor_names = *input_tensor_names_opt;
const char *input_tensor_name = input_tensor_names.at(idx);
const bool input_tf8 = use_tf8 && strcmp(input_tensor_name, "input_img") == 0; // TODO: This is a terrible hack, get rid of this name check both here and in onnx_runner.py
LOGW("adding index %d: %s", idx, input_tensor_name);
zdl::DlSystem::UserBufferEncodingFloat ub_encoding_float;
zdl::DlSystem::UserBufferEncodingTf8 ub_encoding_tf8(0, 1./255); // network takes 0-1
zdl::DlSystem::IUserBufferFactory &ub_factory = zdl::SNPE::SNPEFactory::getUserBufferFactory();
zdl::DlSystem::UserBufferEncoding *input_encoding = input_tf8 ? (zdl::DlSystem::UserBufferEncoding*)&ub_encoding_tf8 : (zdl::DlSystem::UserBufferEncoding*)&ub_encoding_float;
const auto &buffer_shape_opt = snpe->getInputDimensions(input_tensor_name);
const zdl::DlSystem::TensorShape &buffer_shape = *buffer_shape_opt;
size_t size_of_input = input_tf8 ? sizeof(uint8_t) : sizeof(float);
std::vector<size_t> strides(buffer_shape.rank());
strides[strides.size() - 1] = size_of_input;
size_t product = 1;
for (size_t i = 0; i < buffer_shape.rank(); i++) product *= buffer_shape[i];
size_t stride = strides[strides.size() - 1];
for (size_t i = buffer_shape.rank() - 1; i > 0; i--) {
stride *= buffer_shape[i];
strides[i-1] = stride;
}
auto input_buffer = ub_factory.createUserBuffer(buffer, product*size_of_input, strides, input_encoding);
input_map.add(input_tensor_name, input_buffer.get());
inputs.push_back(std::unique_ptr<SNPEModelInput>(new SNPEModelInput(name, buffer, size, std::move(input_buffer))));
}
void SNPEModel::execute() {
if (!snpe->execute(input_map, output_map)) {
PrintErrorStringAndExit();
}
}
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
#include <memory>
#include <string>
#include <utility>
#include <DlContainer/IDlContainer.hpp>
#include <DlSystem/DlError.hpp>
#include <DlSystem/ITensor.hpp>
#include <DlSystem/ITensorFactory.hpp>
#include <DlSystem/IUserBuffer.hpp>
#include <DlSystem/IUserBufferFactory.hpp>
#include <SNPE/SNPE.hpp>
#include <SNPE/SNPEBuilder.hpp>
#include <SNPE/SNPEFactory.hpp>
#include "sunnypilot/modeld/runners/runmodel.h"
struct SNPEModelInput : public ModelInput {
std::unique_ptr<zdl::DlSystem::IUserBuffer> snpe_buffer;
SNPEModelInput(const std::string _name, float *_buffer, int _size, std::unique_ptr<zdl::DlSystem::IUserBuffer> _snpe_buffer) : ModelInput(_name, _buffer, _size), snpe_buffer(std::move(_snpe_buffer)) {}
void setBuffer(float *_buffer, int _size) {
ModelInput::setBuffer(_buffer, _size);
assert(snpe_buffer->setBufferAddress(_buffer) == true);
}
};
class SNPEModel : public RunModel {
public:
SNPEModel(const std::string path, float *_output, size_t _output_size, int runtime, bool use_tf8 = false, cl_context context = NULL);
void addInput(const std::string name, float *buffer, int size);
void execute();
private:
std::string model_data;
#ifdef QCOM2
zdl::DlSystem::Runtime_t snpe_runtime;
#endif
// snpe model stuff
std::unique_ptr<zdl::SNPE::SNPE> snpe;
zdl::DlSystem::UserBufferMap input_map;
zdl::DlSystem::UserBufferMap output_map;
std::unique_ptr<zdl::DlSystem::IUserBuffer> output_buffer;
bool use_tf8;
float *output;
size_t output_size;
};
+9
View File
@@ -0,0 +1,9 @@
# distutils: language = c++
from libcpp.string cimport string
from msgq.visionipc.visionipc cimport cl_context
cdef extern from "sunnypilot/modeld/runners/snpemodel.h":
cdef cppclass SNPEModel:
SNPEModel(string, float*, size_t, int, bool, cl_context)
@@ -0,0 +1,17 @@
# distutils: language = c++
# cython: c_string_encoding=ascii, language_level=3
import os
from libcpp cimport bool
from libcpp.string cimport string
from .snpemodel cimport SNPEModel as cppSNPEModel
from openpilot.sunnypilot.modeld.models.commonmodel_pyx cimport CLContext
from openpilot.sunnypilot.modeld.runners.runmodel_pyx cimport RunModel
from openpilot.sunnypilot.modeld.runners.runmodel cimport RunModel as cppRunModel
os.environ['ADSP_LIBRARY_PATH'] = "/data/pythonpath/third_party/snpe/dsp/"
cdef class SNPEModel(RunModel):
def __cinit__(self, string path, float[:] output, int runtime, bool use_tf8, CLContext context):
self.model = <cppRunModel *> new cppSNPEModel(path, &output[0], len(output), runtime, use_tf8, context.context)
+1 -1
View File
@@ -116,7 +116,7 @@ class ModelCache:
class ModelFetcher:
"""Handles fetching and caching of model data from remote source"""
MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-docs/refs/heads/gh-pages/docs/driving_models_v10.json"
MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-docs/refs/heads/gh-pages/docs/driving_models_v9.json"
def __init__(self, params: Params):
self.params = params
+2 -2
View File
@@ -19,8 +19,8 @@ from openpilot.system.hardware.hw import Paths
from pathlib import Path
# see the README.md for more details on the model selector versioning
CURRENT_SELECTOR_VERSION = 12
REQUIRED_MIN_SELECTOR_VERSION = 12
CURRENT_SELECTOR_VERSION = 11
REQUIRED_MIN_SELECTOR_VERSION = 11
USE_ONNX = os.getenv('USE_ONNX', PC)
-4
View File
@@ -63,9 +63,6 @@ class ModelManagerSP:
f.write(chunk)
bytes_downloaded += len(chunk)
if not self.params.get("ModelManager_DownloadIndex"):
raise Exception("Download cancelled")
if total_size > 0:
progress = (bytes_downloaded / total_size) * 100
model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading
@@ -179,7 +176,6 @@ class ModelManagerSP:
cloudlog.exception(e)
finally:
self.params.remove("ModelManager_DownloadIndex")
self.selected_bundle = None
if self.params.get("ModelManager_ClearCache"):
self.clear_model_cache()
+4 -16
View File
@@ -4,27 +4,21 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import time
import cereal.messaging as messaging
from cereal import log, custom
from opendbc.car import structs
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
from openpilot.sunnypilot.modeld.modeld_base import ModelStateBase
from openpilot.sunnypilot.selfdrive.controls.lib.blinker_pause_lateral import BlinkerPauseLateral
class ControlsExt(ModelStateBase):
class ControlsExt:
def __init__(self, CP: structs.CarParams, params: Params):
ModelStateBase.__init__(self)
self.CP = CP
self.params = params
self._param_update_time: float = 0.0
self.blinker_pause_lateral = BlinkerPauseLateral()
self.get_params_sp()
cloudlog.info("controlsd_ext is waiting for CarParamsSP")
self.CP_SP = messaging.log_from_bytes(params.get("CarParamsSP", block=True), custom.CarParamsSP)
@@ -33,14 +27,8 @@ class ControlsExt(ModelStateBase):
self.sm_services_ext = ['radarState', 'selfdriveStateSP']
self.pm_services_ext = ['carControlSP']
def get_params_sp(self, sm: messaging.SubMaster) -> None:
if time.monotonic() - self._param_update_time > PARAMS_UPDATE_PERIOD:
self.blinker_pause_lateral.get_params()
if self.CP.lateralTuning.which() == 'torque':
self.lat_delay = get_lat_delay(self.params, sm["liveDelay"].lateralDelay)
self._param_update_time = time.monotonic()
def get_params_sp(self) -> None:
self.blinker_pause_lateral.get_params()
def get_lat_active(self, sm: messaging.SubMaster) -> bool:
if self.blinker_pause_lateral.update(sm['carState']):
+8 -3
View File
@@ -3,6 +3,7 @@ import os
import random
import time
from datetime import datetime, timedelta
from pathlib import Path
import jwt
from openpilot.common.api.base import BaseApi
@@ -80,19 +81,23 @@ class SunnylinkApi(BaseApi):
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()
privkey_path = Path(f"{Paths.persist_root()}/comma/id_rsa")
pubkey_path = Path(f"{Paths.persist_root()}/comma/id_rsa.pub")
start_time = time.monotonic()
successful_registration = False
if not public_key:
if not pubkey_path.is_file():
sunnylink_dongle_id = UNREGISTERED_SUNNYLINK_DONGLE_ID
self._status_update("Public key not found, setting dongle ID to unregistered.")
else:
Params().put("LastSunnylinkPingTime", 0) # Reset the last ping time to 0 if we are trying to register
with pubkey_path.open() as f1, privkey_path.open() as f2:
public_key = f1.read()
private_key = f2.read()
backoff = 1
while True:
register_token = jwt.encode({'register': True, 'exp': datetime.utcnow() + timedelta(hours=1)}, private_key, algorithm=jwt_algo)
register_token = jwt.encode({'register': True, 'exp': datetime.utcnow() + timedelta(hours=1)}, private_key, algorithm='RS256')
try:
if verbose or time.monotonic() - start_time < timeout / 2:
self._status_update("Registering device to sunnylink...")
+7 -25
View File
@@ -1,10 +1,5 @@
#!/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
@@ -37,11 +32,9 @@ 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()
METADATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "params_metadata.json")
params = Params()
def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None:
cloudlog.info("sunnylinkd.handle_long_poll started")
sm = messaging.SubMaster(['deviceState'])
@@ -187,30 +180,16 @@ def getParamsAllKeys() -> list[str]:
@dispatcher.add_method
def getParamsAllKeysV1() -> dict[str, str]:
try:
with open(METADATA_PATH) as f:
metadata = json.load(f)
except Exception:
cloudlog.exception("sunnylinkd.getParamsAllKeysV1.exception")
metadata = {}
available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()]
params_dict: dict[str, list[dict[str, str | bool | int | object | dict | None]]] = {"params": []}
params_dict: dict[str, list[dict[str, str | bool | int | None]]] = {"params": []}
for key in available_keys:
value = get_param_as_byte(key, get_default=True)
param_entry = {
params_dict["params"].append({
"key": key,
"type": int(params.get_type(key).value),
"default_value": base64.b64encode(value).decode('utf-8') if value else None,
}
if key in metadata:
meta_copy = metadata[key].copy()
param_entry["_extra"] = meta_copy
params_dict["params"].append(param_entry)
})
return {"keys": json.dumps(params_dict.get("params", []))}
@@ -259,7 +238,10 @@ def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local
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}
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)
File diff suppressed because it is too large Load Diff
+3 -29
View File
@@ -1,20 +1,12 @@
"""
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 time
import json
import pyray as rl
from 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):
@@ -22,7 +14,6 @@ class RoleType(IntEnum):
SPONSOR = 1
ADMIN = 2
class SponsorTier(IntEnum):
FREE = 0
NOVICE = 1
@@ -31,7 +22,6 @@ class SponsorTier(IntEnum):
BENEFACTOR = 4
GUARDIAN = 5
class User:
device_id: str
user_id: str
@@ -46,7 +36,6 @@ class User:
self.updated_at = json_data.get("updated_at")
self.token_hash = json_data.get("token_hash")
class Role:
role_type: str
role_tier: str
@@ -55,7 +44,6 @@ class Role:
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:
@@ -114,7 +102,7 @@ class SunnylinkState:
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()
self.sponsor_tier = self._get_highest_tier()
if users_cache is not None:
self._users = _parse_users(users_cache)
@@ -190,8 +178,8 @@ class SunnylinkState:
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)
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:
@@ -203,19 +191,5 @@ class SunnylinkState:
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 __del__(self):
self.stop()
@@ -1,86 +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.sunnypilot.sunnylink.athena.sunnylinkd import getParamsAllKeysV1, METADATA_PATH
def test_get_params_all_keys_v1():
"""
Test the getParamsAllKeysV1 API endpoint.
Why:
This endpoint is used by the UI (and potentially external tools) to fetch the list of
available parameters along with their metadata (titles, descriptions, options, constraints).
We need to ensure it returns the correct structure and that the metadata from
params_metadata.json is correctly merged into the response.
Expected:
- The response should contain a "keys" field which is a JSON string of a list of parameters.
- Each parameter object should have "key", "type", "default_value", and optionally "_extra".
- The "_extra" field should contain the rich metadata (title, options, min/max, etc.) matching
the source of truth (params_metadata.json).
"""
response = getParamsAllKeysV1()
assert "keys" in response
keys_json = response["keys"]
params_list = json.loads(keys_json)
assert isinstance(params_list, list)
assert len(params_list) > 0
# Check structure of first item
first_param = params_list[0]
assert "key" in first_param
assert "type" in first_param
assert "default_value" in first_param
if "_extra" in first_param:
assert isinstance(first_param["_extra"], dict)
assert "default" not in first_param["_extra"]
assert "type" not in first_param["_extra"]
# Load the source of truth
with open(METADATA_PATH) as f:
metadata = json.load(f)
# Verify that the API response matches the metadata file for a few sample keys
# This ensures the plumbing is working without being brittle to content changes
# 1. Check a key that should have metadata
keys_with_metadata = [k for k in params_list if k["key"] in metadata]
assert len(keys_with_metadata) > 0, "No parameters found that match metadata keys"
for param in keys_with_metadata[:5]: # Check first 5 matches
key = param["key"]
expected_meta = metadata[key]
assert "_extra" in param, f"Parameter {key} should have _extra field"
actual_meta = param["_extra"]
# Verify all fields in JSON are present in the API response
for meta_key, meta_val in expected_meta.items():
assert meta_key in actual_meta, f"Missing {meta_key} in API response for {key}"
assert actual_meta[meta_key] == meta_val, f"Mismatch for {key}.{meta_key}: expected {meta_val}, got {actual_meta[meta_key]}"
# 2. Check that we are correctly serving options if they exist
params_with_options = [k for k in keys_with_metadata if "options" in k.get("_extra", {})]
if params_with_options:
param = params_with_options[0]
key = param["key"]
assert isinstance(param["_extra"]["options"], list), f"Options for {key} should be a list"
assert param["_extra"]["options"] == metadata[key]["options"]
# 3. Check that we are correctly serving numeric constraints if they exist
params_with_constraints = [k for k in keys_with_metadata if "min" in k.get("_extra", {})]
if params_with_constraints:
param = params_with_constraints[0]
key = param["key"]
assert param["_extra"]["min"] == metadata[key]["min"]
assert param["_extra"]["max"] == metadata[key]["max"]
assert param["_extra"]["step"] == metadata[key]["step"]
@@ -1,202 +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
import os
import pytest
from openpilot.common.params import Params
from openpilot.sunnypilot.sunnylink.athena.sunnylinkd import METADATA_PATH
def test_metadata_json_exists():
"""
Test that the params_metadata.json file exists at the expected path.
Why:
The metadata file is the source of truth for parameter descriptions, options, and constraints.
If it's missing, the UI will not be able to display rich information for parameters.
Expected:
The file should exist at sunnypilot/sunnylink/params_metadata.json.
"""
assert os.path.exists(METADATA_PATH), f"Metadata file not found at {METADATA_PATH}"
def test_metadata_json_valid():
"""
Test that the params_metadata.json file contains valid JSON.
Why:
Invalid JSON will cause the metadata loading to fail, potentially crashing the UI or
resulting in missing metadata.
Expected:
The file content should be parseable as a JSON object (dictionary).
"""
with open(METADATA_PATH) as f:
try:
data = json.load(f)
except json.JSONDecodeError:
pytest.fail("Metadata file is not valid JSON")
assert isinstance(data, dict), "Metadata root must be a dictionary"
def test_all_params_have_metadata():
"""
Test that every parameter in the codebase has a corresponding entry in params_metadata.json.
Why:
We want to ensure 100% coverage of parameter metadata. Any parameter added to the codebase
should also be documented in the metadata file.
Expected:
There should be no parameters in Params() that are missing from the metadata file.
If this fails, run 'python3 sunnypilot/sunnylink/tools/update_params_metadata.py'.
"""
params = Params()
all_keys = [k.decode('utf-8') for k in params.all_keys()]
with open(METADATA_PATH) as f:
metadata = json.load(f)
missing_keys = [key for key in all_keys if key not in metadata]
if missing_keys:
pytest.fail(
f"The following parameters are missing from metadata: {missing_keys}. "
+ "Please run 'python3 sunnypilot/sunnylink/tools/update_params_metadata.py' to update."
)
def test_metadata_keys_exist_in_params():
"""
Test that all keys in params_metadata.json actually exist in the codebase.
Why:
We want to avoid stale metadata for parameters that have been removed or renamed.
This keeps the metadata file clean and relevant.
Expected:
There should be no keys in the metadata file that are not present in Params().
This prints a warning rather than failing, as it's less critical than missing metadata.
"""
params = Params()
all_keys = {k.decode('utf-8') for k in params.all_keys()}
with open(METADATA_PATH) as f:
metadata = json.load(f)
extra_keys = [key for key in metadata.keys() if key not in all_keys]
if extra_keys:
print(f"Warning: The following keys in metadata do not exist in Params: {extra_keys}")
def test_no_default_titles():
"""
Test that no parameter has a title that is identical to its key.
Why:
The default behavior of the update script is to set the title equal to the key.
We want to force developers to provide human-readable, descriptive titles for all parameters.
Expected:
No parameter metadata should have 'title' == 'key'.
"""
with open(METADATA_PATH) as f:
metadata = json.load(f)
default_title_keys = [key for key, meta in metadata.items() if meta.get("title") == key]
if default_title_keys:
pytest.fail(
f"The following parameters have default titles (title == key): {default_title_keys}. "
+ "Please update 'params_metadata.json' with descriptive titles."
)
def test_options_structure():
"""
Test that the 'options' field in metadata follows the correct structure.
Why:
The UI expects 'options' to be a list of objects with 'value' and 'label' keys.
Incorrect structure will break the UI rendering for dropdowns/toggles.
Expected:
If 'options' is present, it must be a list of dicts, and each dict must have 'value' and 'label'.
"""
with open(METADATA_PATH) as f:
metadata = json.load(f)
for key, meta in metadata.items():
if "options" in meta:
options = meta["options"]
assert isinstance(options, list), f"Options for {key} must be a list"
for option in options:
assert isinstance(option, dict), f"Option in {key} must be a dictionary"
assert "value" in option, f"Option in {key} must have a 'value' key"
assert "label" in option, f"Option in {key} must have a 'label' key"
def test_numeric_constraints():
"""
Test that numeric parameters have valid 'min', 'max', and 'step' constraints.
Why:
The UI uses these constraints to validate user input and render sliders/steppers.
Missing or invalid constraints can lead to UI bugs or invalid parameter values.
Expected:
If any of min/max/step is present, ALL of them must be present.
They must be numbers (int/float), and min must be less than max.
"""
with open(METADATA_PATH) as f:
metadata = json.load(f)
for key, meta in metadata.items():
if "min" in meta or "max" in meta or "step" in meta:
assert "min" in meta, f"Numeric param {key} must have 'min'"
assert "max" in meta, f"Numeric param {key} must have 'max'"
assert "step" in meta, f"Numeric param {key} must have 'step'"
assert isinstance(meta["min"], (int, float)), f"Min for {key} must be number"
assert isinstance(meta["max"], (int, float)), f"Max for {key} must be number"
assert isinstance(meta["step"], (int, float)), f"Step for {key} must be number"
assert meta["min"] < meta["max"], f"Min must be less than max for {key}"
def test_known_params_metadata():
"""
Test specific known parameters to ensure they have the expected rich metadata.
Why:
This acts as a spot check to ensure that our rich metadata population logic is working correctly
and that critical parameters (like LongitudinalPersonality) have their options and constraints preserved.
Expected:
'LongitudinalPersonality' should have 3 options (Aggressive, Standard, Relaxed).
'CustomAccLongPressIncrement' should have min=1, max=10, step=1.
"""
with open(METADATA_PATH) as f:
metadata = json.load(f)
# Check an enum-like param
lp = metadata.get("LongitudinalPersonality")
assert lp is not None
assert "options" in lp
assert len(lp["options"]) == 3
assert lp["options"][0]["label"] == "Aggressive"
assert lp["options"][0]["value"] == 0
# Check a numeric param
acc_long = metadata.get("CustomAccLongPressIncrement")
assert acc_long is not None
assert acc_long["min"] == 1
assert acc_long["max"] == 10
assert acc_long["step"] == 1
@@ -1,56 +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.
"""
import json
import os
from openpilot.common.params import Params
METADATA_PATH = os.path.join(os.path.dirname(__file__), "../params_metadata.json")
def main():
params = Params()
all_keys = params.all_keys()
if os.path.exists(METADATA_PATH):
with open(METADATA_PATH) as f:
try:
data = json.load(f)
except json.JSONDecodeError:
data = {}
else:
data = {}
# Add new keys
for key in all_keys:
key_str = key.decode("utf-8")
if key_str not in data:
print(f"Adding new key: {key_str}")
data[key_str] = {
"title": key_str,
"description": "",
}
# Remove deleted keys
# keys_to_remove = [k for k in data.keys() if k.encode("utf-8") not in all_keys]
# for k in keys_to_remove:
# print(f"Removing deleted key: {k}")
# del data[k]
# Sort keys
sorted_data = dict(sorted(data.items()))
with open(METADATA_PATH, "w") as f:
json.dump(sorted_data, f, indent=2)
f.write("\n")
print(f"Updated {METADATA_PATH}")
if __name__ == "__main__":
main()
View File
-171
View File
@@ -1,171 +0,0 @@
#!/usr/bin/env python3
import argparse
import ctypes
import csv
import os
import platform
import random
import select
import struct
import sys
import time
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
from collections import defaultdict
from openpilot.system.hardware.hw import Paths
from openpilot.sunnypilot.common.param_watcher import ParamWatcher, IN_CLOSE_WRITE, IN_MOVED_TO
IN_ACCESS = 0x00000001
def get_linux_monitor(params_path, reads, writes):
libc = ctypes.CDLL('libc.so.6')
fd = libc.inotify_init()
if fd < 0:
return None
mask = IN_ACCESS | IN_MOVED_TO | IN_CLOSE_WRITE
if libc.inotify_add_watch(fd, params_path.encode(), mask) < 0:
return None
poll_obj = select.epoll()
poll_obj.register(fd, select.EPOLLIN)
def monitor():
for fileno, _ in poll_obj.poll(0.1):
if fileno == fd:
buffer = os.read(fd, 2048)
i = 0
while i + 16 <= len(buffer):
wd, mask, cookie, name_len = struct.unpack_from("iIII", buffer, i)
name = buffer[i+16:i+16+name_len].rstrip(b"\0").decode('utf-8', 'ignore')
if name and not name.startswith("."):
if mask & IN_ACCESS:
reads[name] += 1
elif mask & (IN_MOVED_TO | IN_CLOSE_WRITE):
writes[name] += 1
i += 16 + name_len
def cleanup():
os.close(fd)
return monitor, cleanup
def get_darwin_monitor(params_path, reads, writes):
print("WARNING: macOS only reports WRITES.")
def callback(name):
writes[name] += 1
watcher = ParamWatcher()
watcher.add_watcher(callback)
def monitor():
time.sleep(0.1)
def cleanup():
if callback in watcher._callbacks:
watcher._callbacks.remove(callback)
return monitor, cleanup
def profile_params():
parser = argparse.ArgumentParser(description="Profile Params I/O")
parser.add_argument("--timeout", type=int, default=30, help="Timeout in minutes (default: 30 mins)")
default_out = os.path.join(os.path.dirname(os.path.abspath(__file__)), f"params_profile_{random.randrange(99999)}.csv")
parser.add_argument("--out", type=str, default=default_out, help="Output CSV file")
args = parser.parse_args()
path = Paths.params_root()
if not os.path.exists(path):
return print(f"Error: {path} not found")
print(f"Profiling Params I/O at: {path}\nPress CTRL+C to stop.")
reads, writes = defaultdict(int), defaultdict(int)
setup = get_linux_monitor if platform.system() == "Linux" else \
get_darwin_monitor if platform.system() == "Darwin" else None
if not setup:
return print("Unsupported platform")
monitor, cleanup = setup(path, reads, writes) or (None, None)
if not monitor:
return print("Failed to initialize monitor")
start_time = time.monotonic()
timeout_seconds = args.timeout * 60
last_print = start_time
try:
while True:
monitor()
if time.monotonic() - last_print > 1.0:
sys.stdout.write(".")
sys.stdout.flush()
last_print = time.monotonic()
if args.timeout > 0 and (time.monotonic() - start_time) > timeout_seconds:
print("\nTimeout reached.")
break
except KeyboardInterrupt:
print("\n\nStopping...")
finally:
cleanup()
duration = time.monotonic() - start_time
with open(args.out, 'w', newline='') as csvfile:
writer = csv.writer(csvfile)
writer.writerow(['Param Name', 'Reads/sec', 'Writes/sec', 'Total Reads', 'Total Writes'])
for k in sorted(set(reads) | set(writes), key=lambda k: reads[k] + writes[k], reverse=True):
writer.writerow([k, f"{reads[k]/duration:.1f}", f"{writes[k]/duration:.1f}", reads[k], writes[k]])
print(f"CSV report saved to {args.out}")
data = []
for k in sorted(set(reads) | set(writes), key=lambda k: reads[k] + writes[k], reverse=True):
data.append((k, reads[k]/duration, writes[k]/duration))
if data:
data = data[:10]
names = [x[0] for x in data]
read_rates = [x[1] for x in data]
write_rates = [x[2] for x in data]
bar_height = 0.35
plt.figure(figsize=(12, len(names) * 0.5 + 2), dpi=150)
y_pos = range(len(names))
y_pos_reads = [y - bar_height/2 for y in y_pos]
y_pos_writes = [y + bar_height/2 for y in y_pos]
plt.barh(y_pos_reads, read_rates, height=bar_height, align='center', color='dodgerblue', alpha=0.8, label='Reads/sec')
plt.barh(y_pos_writes, write_rates, height=bar_height, align='center', color='red', alpha=0.8, label='Writes/sec')
for i, (r_rate, w_rate) in enumerate(zip(read_rates, write_rates, strict=False)):
if r_rate > 0:
plt.text(r_rate, y_pos_reads[i], f"{r_rate:.2f}", va='center', fontsize=8, color='#005a9e', fontweight='bold')
if w_rate > 0:
plt.text(w_rate, y_pos_writes[i], f"{w_rate:.2f}", va='center', fontsize=8, color='#a30000', fontweight='bold')
max_val = max(max(read_rates), max(write_rates)) if read_rates else 0
plt.xlim(0, max_val * 1.15)
plt.yticks(y_pos, names)
plt.xlabel('Rate (Hz)')
plt.title('Top 10 Params I/O Profile')
plt.legend()
plt.grid(axis='x', linestyle='--', alpha=0.5)
plt.gca().xaxis.set_major_locator(ticker.MaxNLocator(integer=True, nbins='auto'))
plt.tight_layout()
plt.gca().invert_yaxis()
plot_filename = os.path.splitext(args.out)[0] + ".png"
plt.savefig(plot_filename)
print(f"Plot saved to {plot_filename}")
if __name__ == "__main__":
profile_params()
-7
View File
@@ -95,10 +95,3 @@ class Paths:
return str(Path(Paths.comma_home()) / "media" / "0" / "osm")
else:
return "/data/media/0/osm"
@staticmethod
def params_root() -> str:
if PC:
return str(Path(Paths.comma_home()) / "params" / "d")
else:
return "/data/params/d"
-6
View File
@@ -94,7 +94,6 @@ class FontWeight(StrEnum):
BOLD = "Inter-Bold.fnt"
SEMI_BOLD = "Inter-SemiBold.fnt"
UNIFONT = "unifont.fnt"
AUDIOWIDE = "Audiowide-Regular.fnt"
# Small UI fonts
DISPLAY_REGULAR = "Inter-Regular.fnt"
@@ -228,8 +227,6 @@ class GuiApplication(GuiApplicationExt):
self._render_profiler = None
self._render_profile_start_time = None
GuiApplicationExt.__init__(self)
@property
def frame(self):
return self._frame
@@ -470,9 +467,6 @@ class GuiApplication(GuiApplicationExt):
if self._show_touches:
self._draw_touch_points()
if self._show_mouse_coords:
self._draw_mouse_coordinates(gui_app.font(FontWeight.SEMI_BOLD))
if self._grid_size > 0:
self._draw_grid()
+2 -6
View File
@@ -41,12 +41,8 @@ class GuiScrollPanel:
if DEBUG:
rl.draw_rectangle_lines(0, 0, abs(int(self._velocity_filter_y.x)), 10, rl.RED)
# Handle mouse wheel only when the mouse cursor is over this panel
mouse_wheel = rl.get_mouse_wheel_move()
if mouse_wheel != 0:
mouse_pos = rl.get_mouse_position()
if rl.check_collision_point_rec(mouse_pos, bounds):
self._offset_filter_y.x += mouse_wheel * MOUSE_WHEEL_SCROLL_SPEED
# Handle mouse wheel
self._offset_filter_y.x += rl.get_mouse_wheel_move() * MOUSE_WHEEL_SCROLL_SPEED
max_scroll_distance = max(0, content.height - bounds.height)
if self._scroll_state == ScrollState.IDLE:
+1 -1
View File
@@ -383,7 +383,7 @@ class WifiManager:
'connection': {
'type': ('s', '802-11-wireless'),
'uuid': ('s', str(uuid.uuid4())),
'id': ('s', f'sunnypilot connection {ssid}'),
'id': ('s', f'openpilot connection {ssid}'),
'autoconnect-retries': ('i', 0),
},
'802-11-wireless': {
+1 -1
View File
@@ -701,7 +701,7 @@ class Setup(Widget):
except urllib.error.HTTPError as e:
if e.code == 409:
error_msg = "Incompatible sunnypilot version"
error_msg = "Incompatible openpilot version"
self.download_failed(self.download_url, error_msg)
except Exception:
error_msg = "Invalid URL"
-25
View File
@@ -6,35 +6,10 @@ See the LICENSE.md file in the root directory for more details.
"""
import os
import pyray as rl
SHOW_MOUSE_COORDS = os.getenv("SHOW_MOUSE_COORDS") == "1"
SUNNYPILOT_UI = os.getenv("SUNNYPILOT_UI", "1") == "1"
class GuiApplicationExt:
def __init__(self):
self._show_mouse_coords = SHOW_MOUSE_COORDS
@staticmethod
def sunnypilot_ui() -> bool:
return SUNNYPILOT_UI
def _draw_mouse_coordinates(self, font):
coords_text = f"X:{int(rl.get_mouse_x())}, Y:{int(rl.get_mouse_y())}"
green_color = rl.Color(0, 159, 47, 255) # Match the green color of FPS counter
# Calculate text width to position it at the right edge; estimate width based on text length
# Each character is approximately 10-12 pixels wide at font size 20
estimated_text_width = len(coords_text) * 11
# Position text at the top right corner, 10px from the top
screen_width = self._scaled_width if self._scale != 1.0 else self._width
text_pos = rl.Vector2(screen_width - estimated_text_width - 10, 6)
# Draw the text
rl.draw_text_ex(font, coords_text, text_pos, 20, 0, green_color)
def set_show_mouse_coords(self, show: bool):
self._show_mouse_coords = show

Some files were not shown because too many files have changed in this diff Show More