mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-06-11 03:34:38 +08:00
Compare commits
102 Commits
sync-20251
...
watcher
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d63ed8cd6 | ||
|
|
debe933d79 | ||
|
|
ebdfc60cb5 | ||
|
|
1a6e0c2781 | ||
|
|
e5e56614c9 | ||
|
|
1eb82fcc85 | ||
|
|
987f53e69a | ||
|
|
9a04a5eaae | ||
|
|
50b8ae9e09 | ||
|
|
a30fc9bcd2 | ||
|
|
19a7d1d5d7 | ||
|
|
fb8f46cba9 | ||
|
|
70386c6b00 | ||
|
|
edeede5e82 | ||
|
|
6df313b974 | ||
|
|
9442bc9aec | ||
|
|
e989633aba | ||
|
|
593a48f808 | ||
|
|
763049f068 | ||
|
|
c6c644a3a6 | ||
|
|
c876a83a31 | ||
|
|
a04a5b4284 | ||
|
|
373894a81f | ||
|
|
34ce746869 | ||
|
|
e96b0da9d7 | ||
|
|
6d7910ed74 | ||
|
|
e54ddf30b8 | ||
|
|
3093bb0b66 | ||
|
|
9c7c84bd03 | ||
|
|
6c6be573c7 | ||
|
|
8904300565 | ||
|
|
09c4b933a8 | ||
|
|
1a1178140f | ||
|
|
452aa67581 | ||
|
|
5bf2ac1657 | ||
|
|
f42dbf0c34 | ||
|
|
40f838260b | ||
|
|
f8487cae23 | ||
|
|
2e576178cb | ||
|
|
5578b7e754 | ||
|
|
57e7c0b2c1 | ||
|
|
c324123e8b | ||
|
|
9f6264eca9 | ||
|
|
f77457cebc | ||
|
|
6a193ed2d8 | ||
|
|
a416de477a | ||
|
|
785e87376a | ||
|
|
72bc73d4fe | ||
|
|
7692d35a63 | ||
|
|
e0a27514e2 | ||
|
|
6e06bcb14f | ||
|
|
bc07cecfa0 | ||
|
|
b737989e64 | ||
|
|
5fbc358fd5 | ||
|
|
ce30d815f7 | ||
|
|
fdde1aa6a1 | ||
|
|
961b2a2d30 | ||
|
|
f3d39d481a | ||
|
|
6e037d80ff | ||
|
|
907bc5cf06 | ||
|
|
b3ff268f89 | ||
|
|
42e08515e6 | ||
|
|
d0ec46dc5d | ||
|
|
48a8802298 | ||
|
|
79971b9eb2 | ||
|
|
7ba21f9f1b | ||
|
|
6b4118ab27 | ||
|
|
0844424ad1 | ||
|
|
5901c9b41f | ||
|
|
d52ce19c15 | ||
|
|
05cc9a14e2 | ||
|
|
18f8956e0e | ||
|
|
0aa6f22c26 | ||
|
|
c90f262ce7 | ||
|
|
e8ee5a23f0 | ||
|
|
4a189f828a | ||
|
|
072e18faef | ||
|
|
3b1fddfde9 | ||
|
|
bddec6971e | ||
|
|
34e02b6ae5 | ||
|
|
c98cc5d40a | ||
|
|
4a0d8063e5 | ||
|
|
e2e52bcccb | ||
|
|
ccf86b7b72 | ||
|
|
483894cfc8 | ||
|
|
a678554122 | ||
|
|
bfd3eab260 | ||
|
|
f5aedbce6e | ||
|
|
4f860dd397 | ||
|
|
f308d9ab17 | ||
|
|
9226222ad4 | ||
|
|
3e317a8b4d | ||
|
|
0eae4e0b3b | ||
|
|
37ffa5ed21 | ||
|
|
05e3eaf2fc | ||
|
|
c8fc344d68 | ||
|
|
264948e5ff | ||
|
|
9d87beac8e | ||
|
|
2e0bc80f94 | ||
|
|
4b8781886a | ||
|
|
97edff5e5c | ||
|
|
a81570a6c2 |
@@ -174,6 +174,24 @@ jobs:
|
||||
echo ' pushurl = ${{ env.LFS_PUSH_URL }}' >> .lfsconfig
|
||||
echo ' locksverify = false' >> .lfsconfig
|
||||
|
||||
- name: Restore workflows from source
|
||||
run: |
|
||||
TARGET_BRANCH="${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}"
|
||||
SOURCE_BRANCH="${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }}"
|
||||
|
||||
# Ensure we are on the target branch
|
||||
git checkout $TARGET_BRANCH
|
||||
|
||||
echo "Restoring .github/workflows from $SOURCE_BRANCH"
|
||||
git checkout origin/$SOURCE_BRANCH -- .github/workflows
|
||||
|
||||
if ! git diff --cached --quiet; then
|
||||
echo "Workflows differ. Committing restoration."
|
||||
git commit -m "chore: restore .github/workflows from $SOURCE_BRANCH"
|
||||
else
|
||||
echo "Workflows match $SOURCE_BRANCH."
|
||||
fi
|
||||
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: ci-token
|
||||
with:
|
||||
|
||||
2
.github/workflows/tests.yaml
vendored
2
.github/workflows/tests.yaml
vendored
@@ -107,8 +107,8 @@ jobs:
|
||||
|
||||
build_mac:
|
||||
name: build macOS
|
||||
if: false # tmp disable due to brew install not working
|
||||
runs-on: ${{ ((github.repository == 'commaai/openpilot') && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))) && 'namespace-profile-macos-8x14' || 'macos-latest' }}
|
||||
if: false # There'll be one day that this works. That day is not today.
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
60
README.md
60
README.md
@@ -11,66 +11,10 @@ Join the official sunnypilot community forum to stay up to date with all the lat
|
||||
https://docs.sunnypilot.ai/ is your one stop shop for everything from features to installation to FAQ about the sunnypilot
|
||||
|
||||
## 🚘 Running on a dedicated device in a car
|
||||
* A supported device to run this software
|
||||
* a [comma three](https://comma.ai/shop/products/three) or a [C3X](https://comma.ai/shop/comma-3x)
|
||||
* This software
|
||||
* One of [the 325+ supported cars](https://github.com/sunnypilot/sunnypilot/blob/master/docs/CARS.md). We support Honda, Toyota, Hyundai, Nissan, Kia, Chrysler, Lexus, Acura, Audi, VW, Ford, and more. If your car is not supported but has adaptive cruise control and lane-keeping assist, it's likely able to run sunnypilot.
|
||||
* A [car harness](https://comma.ai/shop/products/car-harness) to connect to your car
|
||||
|
||||
Detailed instructions for [how to mount the device in a car](https://comma.ai/setup).
|
||||
First, check out this list of items you'll need to [get started](https://community.sunnypilot.ai/t/getting-started-using-sunnypilot-in-your-supported-car/251).
|
||||
|
||||
## Installation
|
||||
Please refer to [Recommended Branches](#recommended-branches) to find your preferred/supported branch. This guide will assume you want to install the latest `staging` branch.
|
||||
|
||||
### If you want to use our newest branches (our rewrite)
|
||||
> [!TIP]
|
||||
>You can see the rewrite state on our [rewrite project board](https://github.com/orgs/sunnypilot/projects/2), and to install the new branches, you can use the following links
|
||||
|
||||
* sunnypilot not installed or you installed a version before 0.8.17?
|
||||
1. [Factory reset/uninstall](https://github.com/commaai/openpilot/wiki/FAQ#how-can-i-reset-the-device) the previous software if you have another software/fork installed.
|
||||
2. After factory reset/uninstall and upon reboot, select `Custom Software` when given the option.
|
||||
3. Input the installation URL per [Recommended Branches](#recommended-branches). Example: ```https://staging.sunnypilot.ai```.
|
||||
4. Complete the rest of the installation following the onscreen instructions.
|
||||
|
||||
* sunnypilot already installed and you installed a version after 0.8.17?
|
||||
1. On the comma three/3X, go to `Settings` ▶️ `Software`.
|
||||
2. At the `Download` option, press `CHECK`. This will fetch the list of latest branches from sunnypilot.
|
||||
3. At the `Target Branch` option, press `SELECT` to open the Target Branch selector.
|
||||
4. Scroll to select the desired branch per Recommended Branches (see below). Example: `staging`
|
||||
|
||||
### Recommended Branches
|
||||
| Branch | Installation URL |
|
||||
|:---------------:|:---------------------------------------------:|
|
||||
| `release` | `https://release.sunnypilot.ai` |
|
||||
| `staging` | `https://staging.sunnypilot.ai` |
|
||||
| `dev` | `https://dev.sunnypilot.ai` |
|
||||
| `custom-branch` | `https://install.sunnypilot.ai/{branch_name}` |
|
||||
|
||||
> [!TIP]
|
||||
> You can use sunnypilot/targetbranch as an install URL. Example: 'sunnypilot/staging'.
|
||||
|
||||
> [!NOTE]
|
||||
> Do you require further assistance with software installation? Join the [sunnypilot community forum](https://community.sunnypilot.ai/new-topic?category=general/qa) and create a topic in the General/Q&A Category channel.
|
||||
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Older legacy branches</summary>
|
||||
|
||||
### If you want to use our older legacy branches (*not recommended*)
|
||||
|
||||
> [**IMPORTANT**]
|
||||
> It is recommended to [re-flash AGNOS](https://flash.comma.ai/) if you intend to downgrade from the new branches.
|
||||
> You can still restore the latest sunnylink backup made on the old branches.
|
||||
|
||||
| Branch | Installation URL |
|
||||
|:------------:|:--------------------------------:|
|
||||
| `release-c3` | https://release-c3.sunnypilot.ai |
|
||||
| `staging-c3` | https://staging-c3.sunnypilot.ai |
|
||||
| `dev-c3` | https://dev-c3.sunnypilot.ai |
|
||||
|
||||
</details>
|
||||
|
||||
Next, refer to the sunnypilot community forum for [installation instructions](https://community.sunnypilot.ai/t/read-before-installing-sunnypilot/254), as well as a complete list of [Recommended Branch Installations](https://community.sunnypilot.ai/t/recommended-branch-installations/235).
|
||||
|
||||
## 🎆 Pull Requests
|
||||
We welcome both pull requests and issues on GitHub. Bug fixes are encouraged.
|
||||
|
||||
@@ -145,6 +145,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"CarParamsSPPersistent", {PERSISTENT, BYTES}},
|
||||
{"CarPlatformBundle", {PERSISTENT | BACKUP, JSON}},
|
||||
{"ChevronInfo", {PERSISTENT | BACKUP, INT, "4"}},
|
||||
{"CompletedSunnylinkConsentVersion", {PERSISTENT, STRING, "0"}},
|
||||
{"CustomAccIncrementsEnabled", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"CustomAccLongPressIncrement", {PERSISTENT | BACKUP, INT, "5"}},
|
||||
{"CustomAccShortPressIncrement", {PERSISTENT | BACKUP, INT, "1"}},
|
||||
@@ -154,6 +155,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"EnableGithubRunner", {PERSISTENT | BACKUP, BOOL}},
|
||||
{"GreenLightAlert", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"GithubRunnerSufficientVoltage", {CLEAR_ON_MANAGER_START , BOOL}},
|
||||
{"HasAcceptedTermsSP", {PERSISTENT, STRING, "0"}},
|
||||
{"HideVEgoUI", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"IntelligentCruiseButtonManagement", {PERSISTENT | BACKUP , BOOL}},
|
||||
{"InteractivityTimeout", {PERSISTENT | BACKUP, INT, "0"}},
|
||||
@@ -213,16 +215,19 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"SubaruStopAndGo", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"SubaruStopAndGoManualParkingBrake", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"TeslaCoopSteering", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ToyotaEnforceStockLongitudinal", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
{"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
// sunnypilot model params
|
||||
{"CameraOffset", {PERSISTENT | BACKUP, FLOAT, "0.0"}},
|
||||
{"LagdToggle", {PERSISTENT | BACKUP, BOOL, "1"}},
|
||||
{"LagdToggleDelay", {PERSISTENT | BACKUP, FLOAT, "0.2"}},
|
||||
{"LagdValueCache", {PERSISTENT, FLOAT, "0.2"}},
|
||||
{"LaneTurnDesire", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"LaneTurnValue", {PERSISTENT | BACKUP, FLOAT, "19.0"}},
|
||||
{"PlanplusControl", {PERSISTENT | BACKUP, FLOAT, "1.0"}},
|
||||
|
||||
// mapd
|
||||
{"MapAdvisorySpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT}},
|
||||
|
||||
Submodule opendbc_repo updated: a76d28a231...e03fbf9be8
@@ -449,7 +449,7 @@ class DriverMonitoring:
|
||||
rpyCalib = [0., 0., 0.]
|
||||
else:
|
||||
highway_speed = sm['carState'].vEgo
|
||||
enabled = sm['selfdriveState'].enabled
|
||||
enabled = sm['selfdriveState'].enabled or sm['carControl'].latActive
|
||||
wrong_gear = sm['carState'].gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low)
|
||||
standstill = sm['carState'].standstill
|
||||
driver_engaged = sm['carState'].steeringPressed or sm['carState'].gasPressed
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from cereal import log
|
||||
from cereal import log, car
|
||||
from openpilot.common.realtime import DT_DMON
|
||||
from openpilot.selfdrive.monitoring.helpers import DriverMonitoring, DRIVER_MONITOR_SETTINGS
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
@@ -204,3 +205,66 @@ class TestMonitoring:
|
||||
assert EventName.driverUnresponsive in \
|
||||
events[int((INVISIBLE_SECONDS_TO_RED-1+DT_DMON*d_status.settings._HI_STD_FALLBACK_TIME+0.1)/DT_DMON)].names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled_state, lat_active_state, expected", [
|
||||
(False, False, False), # Both Disabled
|
||||
(True, False, True), # OP Enabled, Lat Inactive
|
||||
(False, True, True), # OP Disabled, Lat Active (e.g. MADS)
|
||||
(True, True, True) # Both Active
|
||||
])
|
||||
def test_enabled_states(enabled_state, lat_active_state, expected):
|
||||
"""
|
||||
Test DriverMonitoring.run_step with all 4 combinations of:
|
||||
- selfdriveState.enabled (True/False)
|
||||
- carControl.latActive (True/False)
|
||||
"""
|
||||
cs = car.CarState.new_message()
|
||||
cs.vEgo = 30.0
|
||||
cs.gearShifter = car.CarState.GearShifter.drive
|
||||
cs.standstill = False
|
||||
cs.steeringPressed = False
|
||||
cs.gasPressed = False
|
||||
|
||||
ss = log.SelfdriveState.new_message()
|
||||
ss.enabled = enabled_state
|
||||
|
||||
cc = car.CarControl.new_message()
|
||||
cc.latActive = lat_active_state
|
||||
|
||||
mv2 = log.ModelDataV2.new_message()
|
||||
mv2.meta.disengagePredictions.brakeDisengageProbs = [0.0]
|
||||
|
||||
lc = log.LiveCalibrationData.new_message()
|
||||
lc.rpyCalib = [0.0, 0.0, 0.0]
|
||||
|
||||
ds = make_msg(False)
|
||||
|
||||
sm = {
|
||||
'carState': cs,
|
||||
'selfdriveState': ss,
|
||||
'carControl': cc,
|
||||
'modelV2': mv2,
|
||||
'liveCalibration': lc,
|
||||
'driverStateV2': ds
|
||||
}
|
||||
|
||||
driver_monitoring = DriverMonitoring()
|
||||
|
||||
# run_test doesn't assign enabled to a variable, so we need to spy on _update_events to see its value
|
||||
captured_args = []
|
||||
original_update_events = driver_monitoring._update_events
|
||||
|
||||
def spy_update_events(driver_engaged, op_engaged, standstill, wrong_gear, car_speed):
|
||||
captured_args.append(op_engaged)
|
||||
return original_update_events(driver_engaged, op_engaged, standstill, wrong_gear, car_speed)
|
||||
|
||||
driver_monitoring._update_events = spy_update_events
|
||||
|
||||
driver_monitoring.run_step(sm, demo=False)
|
||||
|
||||
# Assertion
|
||||
assert len(captured_args) == 1, "Expected _update_events to be called exactly once"
|
||||
actual_enabled = captured_args[0]
|
||||
|
||||
assert actual_enabled == expected, f"Expected op_engaged={expected}, but got {actual_enabled}"
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
from openpilot.system.ui.widgets.label import Label
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.version import terms_version, training_version
|
||||
from openpilot.system.version import terms_version, training_version, terms_version_sp
|
||||
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.onboarding import SunnylinkOnboarding
|
||||
|
||||
DEBUG = False
|
||||
|
||||
@@ -33,6 +35,7 @@ class OnboardingState(IntEnum):
|
||||
TERMS = 0
|
||||
ONBOARDING = 1
|
||||
DECLINE = 2
|
||||
SUNNYLINK_CONSENT = 3
|
||||
|
||||
|
||||
class TrainingGuide(Widget):
|
||||
@@ -110,14 +113,14 @@ class TermsPage(Widget):
|
||||
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._desc = Label(tr("You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.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)
|
||||
self._accept_btn = Button(tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept)
|
||||
|
||||
def _render(self, _):
|
||||
welcome_x = self._rect.x + 165
|
||||
welcome_x = self._rect.x + 95
|
||||
welcome_y = self._rect.y + 165
|
||||
welcome_rect = rl.Rectangle(welcome_x, welcome_y, self._rect.width - welcome_x, 90)
|
||||
self._title.render(welcome_rect)
|
||||
@@ -143,7 +146,7 @@ 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 of Service in order to use sunnypilot."),
|
||||
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,
|
||||
@@ -180,9 +183,21 @@ class OnboardingWindow(Widget):
|
||||
self._training_guide: TrainingGuide | None = None
|
||||
self._decline_page = DeclinePage(back_callback=self._on_decline_back)
|
||||
|
||||
# sunnylink consent pages
|
||||
self._accepted_terms = self._accepted_terms and ui_state.params.get("HasAcceptedTermsSP") == terms_version_sp
|
||||
self._sunnylink = SunnylinkOnboarding()
|
||||
if not self._accepted_terms:
|
||||
self._state = OnboardingState.TERMS
|
||||
elif not self._sunnylink.completed:
|
||||
self._state = OnboardingState.SUNNYLINK_CONSENT
|
||||
elif not self._training_done:
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
else:
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
|
||||
@property
|
||||
def completed(self) -> bool:
|
||||
return self._accepted_terms and self._training_done
|
||||
return self._accepted_terms and self._sunnylink.completed and self._training_done
|
||||
|
||||
def _on_terms_declined(self):
|
||||
self._state = OnboardingState.DECLINE
|
||||
@@ -192,8 +207,12 @@ class OnboardingWindow(Widget):
|
||||
|
||||
def _on_terms_accepted(self):
|
||||
ui_state.params.put("HasAcceptedTerms", terms_version)
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
if self._training_done:
|
||||
ui_state.params.put("HasAcceptedTermsSP", terms_version_sp)
|
||||
if not self._sunnylink.completed:
|
||||
self._state = OnboardingState.SUNNYLINK_CONSENT
|
||||
elif not self._training_done:
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
else:
|
||||
gui_app.set_modal_overlay(None)
|
||||
|
||||
def _on_completed_training(self):
|
||||
@@ -206,8 +225,18 @@ class OnboardingWindow(Widget):
|
||||
|
||||
if self._state == OnboardingState.TERMS:
|
||||
self._terms.render(self._rect)
|
||||
if self._state == OnboardingState.ONBOARDING:
|
||||
self._training_guide.render(self._rect)
|
||||
elif self._state == OnboardingState.SUNNYLINK_CONSENT:
|
||||
self._sunnylink.render(self._rect)
|
||||
if self._sunnylink.completed:
|
||||
if not self._training_done:
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
else:
|
||||
gui_app.set_modal_overlay(None)
|
||||
elif self._state == OnboardingState.ONBOARDING:
|
||||
if not self._training_done:
|
||||
self._training_guide.render(self._rect)
|
||||
else:
|
||||
gui_app.set_modal_overlay(None)
|
||||
elif self._state == OnboardingState.DECLINE:
|
||||
self._decline_page.render(self._rect)
|
||||
return -1
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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
|
||||
@@ -35,7 +34,7 @@ DESCRIPTIONS = {
|
||||
class DeveloperLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._params = ui_state.params
|
||||
self._is_release = self._params.get_bool("IsReleaseBranch")
|
||||
|
||||
# Build items and keep references for callbacks/state updates
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -35,7 +34,7 @@ class DeviceLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._params = Params()
|
||||
self._params = ui_state.params
|
||||
self._select_language_dialog: MultiOptionDialog | None = None
|
||||
self._driver_camera: DriverCameraDialog | None = None
|
||||
self._pair_device_dialog: PairingDialog | None = None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from cereal import log
|
||||
from openpilot.common.params import Params, UnknownKeyName
|
||||
from openpilot.common.params import 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
|
||||
@@ -41,7 +41,7 @@ DESCRIPTIONS = {
|
||||
class TogglesLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._params = ui_state.params
|
||||
self._is_release = self._params.get_bool("IsReleaseBranch")
|
||||
|
||||
# param, title, desc, icon, needs_restart
|
||||
@@ -198,11 +198,6 @@ 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:
|
||||
|
||||
@@ -9,6 +9,8 @@ from openpilot.system.ui.lib.multilang import tr, tr_noop
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.sidebar import SidebarSP
|
||||
|
||||
SIDEBAR_WIDTH = 300
|
||||
METRIC_HEIGHT = 126
|
||||
METRIC_WIDTH = 240
|
||||
@@ -62,9 +64,10 @@ class MetricData:
|
||||
self.color = color
|
||||
|
||||
|
||||
class Sidebar(Widget):
|
||||
class Sidebar(Widget, SidebarSP):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
Widget.__init__(self)
|
||||
SidebarSP.__init__(self)
|
||||
self._net_type = NETWORK_TYPES.get(NetworkType.none)
|
||||
self._net_strength = 0
|
||||
|
||||
@@ -112,6 +115,7 @@ class Sidebar(Widget):
|
||||
self._update_temperature_status(device_state)
|
||||
self._update_connection_status(device_state)
|
||||
self._update_panda_status()
|
||||
SidebarSP._update_sunnylink_status(self)
|
||||
|
||||
def _update_network_status(self, device_state):
|
||||
self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown"))
|
||||
@@ -200,6 +204,13 @@ class Sidebar(Widget):
|
||||
rl.draw_text_ex(self._font_regular, tr(self._net_type), text_pos, FONT_SIZE, 0, Colors.WHITE)
|
||||
|
||||
def _draw_metrics(self, rect: rl.Rectangle):
|
||||
if gui_app.sunnypilot_ui():
|
||||
metrics, start_y, spacing = SidebarSP._draw_metrics_w_sunnylink(self, rect, self._temp_status, self._panda_status, self._connect_status)
|
||||
for idx, metric in enumerate(metrics):
|
||||
self._draw_metric(rect, metric, start_y + idx * spacing)
|
||||
|
||||
return
|
||||
|
||||
metrics = [(self._temp_status, 338), (self._panda_status, 496), (self._connect_status, 654)]
|
||||
|
||||
for metric, y_offset in metrics:
|
||||
|
||||
@@ -109,7 +109,7 @@ 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("sunnypilot", font_size=90, color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.AUDIOWIDE)
|
||||
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)
|
||||
|
||||
@@ -11,6 +11,9 @@ from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.scroller import Scroller
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.settings import SettingsLayoutSP as SettingsLayout
|
||||
|
||||
|
||||
ONROAD_DELAY = 2.5 # seconds
|
||||
|
||||
|
||||
@@ -17,13 +17,16 @@ from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
|
||||
from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog
|
||||
from openpilot.system.ui.widgets.label import gui_label
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.version import terms_version, training_version
|
||||
from openpilot.system.version import terms_version, training_version, terms_version_sp
|
||||
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkOnboarding
|
||||
|
||||
|
||||
class OnboardingState(IntEnum):
|
||||
TERMS = 0
|
||||
ONBOARDING = 1
|
||||
DECLINE = 2
|
||||
SUNNYLINK_CONSENT = 3
|
||||
|
||||
|
||||
class DriverCameraSetupDialog(DriverCameraDialog):
|
||||
@@ -412,10 +415,10 @@ class TermsPage(SetupTermsPage):
|
||||
super().__init__(on_accept, on_decline, "decline")
|
||||
|
||||
info_txt = gui_app.texture("icons_mici/setup/green_info.png", 60, 60)
|
||||
self._title_header = TermsHeader("terms & conditions", info_txt)
|
||||
self._title_header = TermsHeader("terms of service", info_txt)
|
||||
|
||||
self._terms_label = UnifiedLabel("You must accept the Terms and Conditions to use sunnypilot. " +
|
||||
"Read the latest terms at https://comma.ai/terms before continuing.", 36,
|
||||
self._terms_label = UnifiedLabel("You must accept the Terms of Service to use sunnypilot. " +
|
||||
"Read the latest terms at https://sunnypilot.ai/terms before continuing.", 36,
|
||||
FontWeight.ROMAN)
|
||||
|
||||
@property
|
||||
@@ -449,6 +452,18 @@ class OnboardingWindow(Widget):
|
||||
self._training_guide = TrainingGuide(completed_callback=self._on_completed_training)
|
||||
self._decline_page = DeclinePage(back_callback=self._on_decline_back)
|
||||
|
||||
# sunnylink consent pages
|
||||
self._accepted_terms = self._accepted_terms and ui_state.params.get("HasAcceptedTermsSP") == terms_version_sp
|
||||
self._sunnylink = SunnylinkOnboarding()
|
||||
if not self._accepted_terms:
|
||||
self._state = OnboardingState.TERMS
|
||||
elif not self._sunnylink.completed:
|
||||
self._state = OnboardingState.SUNNYLINK_CONSENT
|
||||
elif not self._training_done:
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
else:
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
device.set_override_interactive_timeout(300)
|
||||
@@ -459,7 +474,7 @@ class OnboardingWindow(Widget):
|
||||
|
||||
@property
|
||||
def completed(self) -> bool:
|
||||
return self._accepted_terms and self._training_done
|
||||
return self._accepted_terms and self._sunnylink.completed and self._training_done
|
||||
|
||||
def _on_terms_declined(self):
|
||||
self._state = OnboardingState.DECLINE
|
||||
@@ -473,7 +488,13 @@ class OnboardingWindow(Widget):
|
||||
|
||||
def _on_terms_accepted(self):
|
||||
ui_state.params.put("HasAcceptedTerms", terms_version)
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
ui_state.params.put("HasAcceptedTermsSP", terms_version_sp)
|
||||
if not self._sunnylink.completed:
|
||||
self._state = OnboardingState.SUNNYLINK_CONSENT
|
||||
elif not self._training_done:
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
else:
|
||||
self.close()
|
||||
|
||||
def _on_completed_training(self):
|
||||
ui_state.params.put("CompletedTrainingVersion", training_version)
|
||||
@@ -482,8 +503,18 @@ class OnboardingWindow(Widget):
|
||||
def _render(self, _):
|
||||
if self._state == OnboardingState.TERMS:
|
||||
self._terms.render(self._rect)
|
||||
elif self._state == OnboardingState.SUNNYLINK_CONSENT:
|
||||
self._sunnylink.render(self._rect)
|
||||
if self._sunnylink.completed:
|
||||
if not self._training_done:
|
||||
self._state = OnboardingState.ONBOARDING
|
||||
else:
|
||||
self.close()
|
||||
elif self._state == OnboardingState.ONBOARDING:
|
||||
self._training_guide.render(self._rect)
|
||||
if not self._training_done:
|
||||
self._training_guide.render(self._rect)
|
||||
else:
|
||||
self.close()
|
||||
elif self._state == OnboardingState.DECLINE:
|
||||
self._decline_page.render(self._rect)
|
||||
return -1
|
||||
|
||||
@@ -6,6 +6,8 @@ from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.confidence_ball import ConfidenceBallSP
|
||||
|
||||
|
||||
def draw_circle_gradient(center_x: float, center_y: float, radius: int,
|
||||
top: rl.Color, bottom: rl.Color) -> None:
|
||||
@@ -21,9 +23,10 @@ def draw_circle_gradient(center_x: float, center_y: float, radius: int,
|
||||
20, rl.BLACK)
|
||||
|
||||
|
||||
class ConfidenceBall(Widget):
|
||||
class ConfidenceBall(Widget, ConfidenceBallSP):
|
||||
def __init__(self, demo: bool = False):
|
||||
super().__init__()
|
||||
Widget.__init__(self)
|
||||
ConfidenceBallSP.__init__(self)
|
||||
self._demo = demo
|
||||
self._confidence_filter = FirstOrderFilter(-0.5, 0.5, 1 / gui_app.target_fps)
|
||||
|
||||
@@ -37,6 +40,8 @@ class ConfidenceBall(Widget):
|
||||
# animate status dot in from bottom
|
||||
if ui_state.status == UIStatus.DISENGAGED:
|
||||
self._confidence_filter.update(-0.5)
|
||||
elif ui_state.status in (UIStatus.LAT_ONLY, UIStatus.LONG_ONLY):
|
||||
self._confidence_filter.update(1 - max(self.get_animate_status_probs() or [1]))
|
||||
else:
|
||||
self._confidence_filter.update((1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs or [1])) *
|
||||
(1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs or [1])))
|
||||
@@ -65,6 +70,9 @@ class ConfidenceBall(Widget):
|
||||
top_dot_color = rl.Color(255, 0, 21, 255)
|
||||
bottom_dot_color = rl.Color(255, 0, 89, 255)
|
||||
|
||||
elif ui_state.status in (UIStatus.LAT_ONLY, UIStatus.LONG_ONLY):
|
||||
top_dot_color = bottom_dot_color = self.get_lat_long_dot_color()
|
||||
|
||||
elif ui_state.status == UIStatus.OVERRIDE:
|
||||
top_dot_color = rl.Color(255, 255, 255, 255)
|
||||
bottom_dot_color = rl.Color(82, 82, 82, 255)
|
||||
|
||||
@@ -12,6 +12,8 @@ from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.model_renderer import LANE_LINE_COLORS_SP
|
||||
|
||||
CLIP_MARGIN = 500
|
||||
MIN_DRAW_DISTANCE = 10.0
|
||||
MAX_DRAW_DISTANCE = 100.0
|
||||
@@ -32,6 +34,7 @@ LANE_LINE_COLORS = {
|
||||
UIStatus.DISENGAGED: rl.Color(200, 200, 200, 255),
|
||||
UIStatus.OVERRIDE: rl.Color(255, 255, 255, 255),
|
||||
UIStatus.ENGAGED: rl.Color(0, 255, 64, 255),
|
||||
**LANE_LINE_COLORS_SP,
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +80,9 @@ class ModelRenderer(Widget):
|
||||
self._transform_dirty = True
|
||||
self._clip_region = None
|
||||
|
||||
self._counter = -1
|
||||
self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0
|
||||
|
||||
self._exp_gradient = Gradient(
|
||||
start=(0.0, 1.0), # Bottom of path
|
||||
end=(0.0, 0.0), # Top of path
|
||||
@@ -96,6 +102,10 @@ class ModelRenderer(Widget):
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
sm = ui_state.sm
|
||||
|
||||
if self._counter % 180 == 0: # This runs at 60fps, so we query every 3 seconds
|
||||
self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0
|
||||
self._counter += 1
|
||||
|
||||
self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque)
|
||||
|
||||
# Check if data is up-to-date
|
||||
@@ -147,13 +157,13 @@ class ModelRenderer(Widget):
|
||||
|
||||
def _update_raw_points(self, model):
|
||||
"""Update raw 3D points from model data"""
|
||||
self._path.raw_points = np.array([model.position.x, model.position.y, model.position.z], dtype=np.float32).T
|
||||
self._path.raw_points = np.array([model.position.x, np.array(model.position.y) + self._camera_offset, model.position.z], dtype=np.float32).T
|
||||
|
||||
for i, lane_line in enumerate(model.laneLines):
|
||||
self._lane_lines[i].raw_points = np.array([lane_line.x, lane_line.y, lane_line.z], dtype=np.float32).T
|
||||
self._lane_lines[i].raw_points = np.array([lane_line.x, np.array(lane_line.y) + self._camera_offset, lane_line.z], dtype=np.float32).T
|
||||
|
||||
for i, road_edge in enumerate(model.roadEdges):
|
||||
self._road_edges[i].raw_points = np.array([road_edge.x, road_edge.y, road_edge.z], dtype=np.float32).T
|
||||
self._road_edges[i].raw_points = np.array([road_edge.x, np.array(road_edge.y) + self._camera_offset, road_edge.z], dtype=np.float32).T
|
||||
|
||||
self._lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32)
|
||||
self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32)
|
||||
@@ -171,7 +181,7 @@ class ModelRenderer(Widget):
|
||||
|
||||
# Get z-coordinate from path at the lead vehicle position
|
||||
z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0
|
||||
point = self._map_to_screen(d_rel, -y_rel, z + self._path_offset_z)
|
||||
point = self._map_to_screen(d_rel, -y_rel + self._camera_offset, z + self._path_offset_z)
|
||||
if point:
|
||||
self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect)
|
||||
|
||||
|
||||
@@ -185,13 +185,13 @@ class TorqueBar(Widget):
|
||||
|
||||
# animate alpha and angle span
|
||||
if not self._demo:
|
||||
self._torque_line_alpha_filter.update(ui_state.status != UIStatus.DISENGAGED)
|
||||
self._torque_line_alpha_filter.update(ui_state.status not in (UIStatus.DISENGAGED, UIStatus.LONG_ONLY))
|
||||
else:
|
||||
self._torque_line_alpha_filter.update(1.0)
|
||||
|
||||
torque_line_bg_alpha = np.interp(abs(self._torque_filter.x), [0.5, 1.0], [0.25, 0.5])
|
||||
torque_line_bg_color = rl.Color(255, 255, 255, int(255 * torque_line_bg_alpha * self._torque_line_alpha_filter.x))
|
||||
if ui_state.status != UIStatus.ENGAGED and not self._demo:
|
||||
if ui_state.status not in (UIStatus.ENGAGED, UIStatus.LAT_ONLY) and not self._demo:
|
||||
torque_line_bg_color = rl.Color(255, 255, 255, int(255 * 0.15 * self._torque_line_alpha_filter.x))
|
||||
|
||||
# draw curved line polygon torque bar
|
||||
@@ -234,7 +234,7 @@ class TorqueBar(Widget):
|
||||
max(0, abs(self._torque_filter.x) - 0.75) * 4,
|
||||
)
|
||||
|
||||
if ui_state.status != UIStatus.ENGAGED and not self._demo:
|
||||
if ui_state.status not in (UIStatus.ENGAGED, UIStatus.LAT_ONLY) and not self._demo:
|
||||
start_color = end_color = rl.Color(255, 255, 255, int(255 * 0.35 * self._torque_line_alpha_filter.x))
|
||||
|
||||
gradient = Gradient(
|
||||
|
||||
@@ -16,6 +16,9 @@ from openpilot.common.transformations.orientation import rot_from_euler
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.hud_renderer import HudRendererSP as HudRenderer
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.driver_state import DriverStateRendererSP as DriverStateRenderer
|
||||
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.augmented_road_view import BORDER_COLORS_SP
|
||||
|
||||
OpState = log.SelfdriveState.OpenpilotState
|
||||
CALIBRATED = log.LiveCalibrationData.Status.calibrated
|
||||
@@ -27,6 +30,7 @@ BORDER_COLORS = {
|
||||
UIStatus.DISENGAGED: rl.Color(0x12, 0x28, 0x39, 0xFF), # Blue for disengaged state
|
||||
UIStatus.OVERRIDE: rl.Color(0x89, 0x92, 0x8D, 0xFF), # Gray for override state
|
||||
UIStatus.ENGAGED: rl.Color(0x16, 0x7F, 0x40, 0xFF), # Green for engaged state
|
||||
**BORDER_COLORS_SP,
|
||||
}
|
||||
|
||||
WIDE_CAM_MAX_SPEED = 10.0 # m/s (22 mph)
|
||||
|
||||
@@ -11,6 +11,8 @@ from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.model_renderer import ChevronMetrics, ModelRendererSP
|
||||
|
||||
CLIP_MARGIN = 500
|
||||
MIN_DRAW_DISTANCE = 10.0
|
||||
MAX_DRAW_DISTANCE = 100.0
|
||||
@@ -41,9 +43,11 @@ class LeadVehicle:
|
||||
fill_alpha: int = 0
|
||||
|
||||
|
||||
class ModelRenderer(Widget):
|
||||
class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
Widget.__init__(self)
|
||||
ChevronMetrics.__init__(self)
|
||||
ModelRendererSP.__init__(self)
|
||||
self._longitudinal_control = False
|
||||
self._experimental_mode = False
|
||||
self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps)
|
||||
@@ -52,7 +56,8 @@ class ModelRenderer(Widget):
|
||||
self._road_edge_stds = np.zeros(2, dtype=np.float32)
|
||||
self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
|
||||
self._path_offset_z = HEIGHT_INIT[0]
|
||||
|
||||
self._counter = -1
|
||||
self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0
|
||||
# Initialize ModelPoints objects
|
||||
self._path = ModelPoints()
|
||||
self._lane_lines = [ModelPoints() for _ in range(4)]
|
||||
@@ -99,6 +104,10 @@ class ModelRenderer(Widget):
|
||||
live_calib = sm['liveCalibration']
|
||||
self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0]
|
||||
|
||||
if self._counter % 60 == 0:
|
||||
self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0
|
||||
self._counter += 1
|
||||
|
||||
if sm.updated['carParams']:
|
||||
self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl
|
||||
|
||||
@@ -128,16 +137,17 @@ class ModelRenderer(Widget):
|
||||
|
||||
if render_lead_indicator and radar_state:
|
||||
self._draw_lead_indicator()
|
||||
self.chevron_metrics.draw_lead_status(sm, radar_state, self._rect, self._lead_vehicles)
|
||||
|
||||
def _update_raw_points(self, model):
|
||||
"""Update raw 3D points from model data"""
|
||||
self._path.raw_points = np.array([model.position.x, model.position.y, model.position.z], dtype=np.float32).T
|
||||
self._path.raw_points = np.array([model.position.x, np.array(model.position.y) + self._camera_offset, model.position.z], dtype=np.float32).T
|
||||
|
||||
for i, lane_line in enumerate(model.laneLines):
|
||||
self._lane_lines[i].raw_points = np.array([lane_line.x, lane_line.y, lane_line.z], dtype=np.float32).T
|
||||
self._lane_lines[i].raw_points = np.array([lane_line.x, np.array(lane_line.y) + self._camera_offset, lane_line.z], dtype=np.float32).T
|
||||
|
||||
for i, road_edge in enumerate(model.roadEdges):
|
||||
self._road_edges[i].raw_points = np.array([road_edge.x, road_edge.y, road_edge.z], dtype=np.float32).T
|
||||
self._road_edges[i].raw_points = np.array([road_edge.x, np.array(road_edge.y) + self._camera_offset, road_edge.z], dtype=np.float32).T
|
||||
|
||||
self._lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32)
|
||||
self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32)
|
||||
@@ -155,7 +165,7 @@ class ModelRenderer(Widget):
|
||||
|
||||
# Get z-coordinate from path at the lead vehicle position
|
||||
z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0
|
||||
point = self._map_to_screen(d_rel, -y_rel, z + self._path_offset_z)
|
||||
point = self._map_to_screen(d_rel, -y_rel + self._camera_offset, z + self._path_offset_z)
|
||||
if point:
|
||||
self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect)
|
||||
|
||||
@@ -281,6 +291,10 @@ class ModelRenderer(Widget):
|
||||
allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control
|
||||
self._blend_filter.update(int(allow_throttle))
|
||||
|
||||
if ui_state.rainbow_path:
|
||||
self.rainbow_path.draw_rainbow_path(self._rect, self._path)
|
||||
return
|
||||
|
||||
if self._experimental_mode:
|
||||
# Draw with acceleration coloring
|
||||
if len(self._exp_gradient.colors) > 1:
|
||||
|
||||
116
selfdrive/ui/sunnypilot/layouts/onboarding.py
Normal file
116
selfdrive/ui/sunnypilot/layouts/onboarding.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import pyray as rl
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
from openpilot.system.ui.widgets.label import Label
|
||||
from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined
|
||||
|
||||
|
||||
class SunnylinkConsentPage(Widget):
|
||||
def __init__(self, done_callback=None):
|
||||
super().__init__()
|
||||
self._done_callback = done_callback
|
||||
self._step = 0
|
||||
|
||||
self._title = Label(tr("sunnylink"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
|
||||
|
||||
self._content = [
|
||||
{
|
||||
"text": tr("sunnylink enables secured remote access to your comma device from anywhere, " +
|
||||
"including settings management, remote monitoring, real-time dashboard, etc."),
|
||||
"primary_btn": tr("Enable"),
|
||||
"secondary_btn": tr("Disable"),
|
||||
"highlight_primary": True
|
||||
},
|
||||
{
|
||||
"text": tr("sunnylink is designed to be enabled as part of sunnypilot's core functionality. " +
|
||||
"If sunnylink is disabled, features such as settings management, remote monitoring, " +
|
||||
"real-time dashboards will be unavailable."),
|
||||
"secondary_btn": tr("Back"),
|
||||
"danger_btn": tr("Disable"),
|
||||
"highlight_primary": True
|
||||
}
|
||||
]
|
||||
|
||||
self._primary_btn = Button("", button_style=ButtonStyle.PRIMARY, click_callback=lambda: self._handle_choice("enable"))
|
||||
self._secondary_btn = Button("", button_style=ButtonStyle.NORMAL, click_callback=lambda: self._handle_choice("secondary"))
|
||||
self._danger_btn = Button("", button_style=ButtonStyle.DANGER, click_callback=lambda: self._handle_choice("disable"))
|
||||
|
||||
def _handle_choice(self, choice):
|
||||
if choice == "enable":
|
||||
ui_state.params.put_bool("SunnylinkEnabled", True)
|
||||
ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version)
|
||||
if self._done_callback:
|
||||
self._done_callback()
|
||||
elif choice == "secondary":
|
||||
if self._step == 0:
|
||||
self._step = 1
|
||||
elif self._step == 1:
|
||||
self._step = 0
|
||||
elif choice == "disable":
|
||||
ui_state.params.put_bool("SunnylinkEnabled", False)
|
||||
ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined)
|
||||
if self._done_callback:
|
||||
self._done_callback()
|
||||
|
||||
def _render(self, _):
|
||||
step_data = self._content[self._step]
|
||||
|
||||
welcome_x = self._rect.x + 95
|
||||
welcome_y = self._rect.y + 165
|
||||
welcome_rect = rl.Rectangle(welcome_x, welcome_y, self._rect.width - welcome_x, 90)
|
||||
self._title.render(welcome_rect)
|
||||
|
||||
desc_x = welcome_x
|
||||
desc_y = welcome_y + 120
|
||||
desc_rect = rl.Rectangle(desc_x, desc_y, self._rect.width - desc_x, self._rect.height - desc_y - 250)
|
||||
|
||||
desc_label = Label(step_data["text"], font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
|
||||
desc_label.render(desc_rect)
|
||||
|
||||
btn_y = self._rect.y + self._rect.height - 160 - 45
|
||||
|
||||
if "danger_btn" in step_data:
|
||||
btn_width = (self._rect.width - 45 * 3) / 2
|
||||
|
||||
self._secondary_btn.set_text(step_data["secondary_btn"])
|
||||
self._secondary_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160))
|
||||
|
||||
self._danger_btn.set_text(step_data["danger_btn"])
|
||||
self._danger_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160))
|
||||
|
||||
else:
|
||||
btn_width = (self._rect.width - 45 * 3) / 2
|
||||
|
||||
self._secondary_btn.set_text(step_data["secondary_btn"])
|
||||
self._secondary_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160))
|
||||
|
||||
self._primary_btn.set_text(step_data["primary_btn"])
|
||||
self._primary_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160))
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
class SunnylinkOnboarding:
|
||||
def __init__(self):
|
||||
self.consent_page = SunnylinkConsentPage(done_callback=self._on_done)
|
||||
self.consent_done: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") in {sunnylink_consent_version, sunnylink_consent_declined}
|
||||
|
||||
@property
|
||||
def completed(self) -> bool:
|
||||
return self.consent_done
|
||||
|
||||
def _on_done(self):
|
||||
self.consent_done = True
|
||||
|
||||
def render(self, rect):
|
||||
if not self.consent_done:
|
||||
self.consent_page.render(rect)
|
||||
@@ -5,8 +5,216 @@ 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
|
||||
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
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import option_item_sp, multiple_button_item_sp, button_item_sp, \
|
||||
dual_button_item_sp, Spacer
|
||||
from openpilot.system.ui.widgets import DialogResult
|
||||
from openpilot.system.ui.widgets.button import ButtonStyle
|
||||
from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog
|
||||
from openpilot.system.ui.widgets.list_view import text_item
|
||||
from openpilot.system.ui.widgets.scroller_tici import LineSeparator
|
||||
|
||||
offroad_time_options = {
|
||||
0: 0,
|
||||
1: 5,
|
||||
2: 10,
|
||||
3: 15,
|
||||
4: 30,
|
||||
5: 60,
|
||||
6: 120,
|
||||
7: 180,
|
||||
8: 300,
|
||||
9: 600,
|
||||
10: 1440,
|
||||
11: 1800,
|
||||
}
|
||||
|
||||
|
||||
class DeviceLayoutSP(DeviceLayout):
|
||||
def __init__(self):
|
||||
DeviceLayout.__init__(self)
|
||||
self._scroller._line_separator = None
|
||||
|
||||
def _initialize_items(self):
|
||||
DeviceLayout._initialize_items(self)
|
||||
|
||||
# Using dual button with no right button for better alignment
|
||||
self._always_offroad_btn = dual_button_item_sp(
|
||||
left_text=lambda: tr("Enable Always Offroad"),
|
||||
left_callback=self._handle_always_offroad,
|
||||
right_text="",
|
||||
right_callback=None,
|
||||
)
|
||||
self._always_offroad_btn.action_item.right_button.set_visible(False)
|
||||
|
||||
self._max_time_offroad = option_item_sp(
|
||||
title=lambda: tr("Max Time Offroad"),
|
||||
description=lambda: tr("Device will automatically shutdown after set time once the engine is turned off.\n(30h is the default)"),
|
||||
param="MaxTimeOffroad",
|
||||
min_value=0,
|
||||
max_value=11,
|
||||
value_change_step=1,
|
||||
on_value_changed=None,
|
||||
enabled=True,
|
||||
icon="",
|
||||
value_map=offroad_time_options,
|
||||
label_width=360,
|
||||
use_float_scaling=False,
|
||||
inline=True,
|
||||
label_callback=self._update_max_time_offroad_label
|
||||
)
|
||||
|
||||
self._device_wake_mode = multiple_button_item_sp(
|
||||
title=lambda: tr("Wake Up Behavior"),
|
||||
description=self.wake_mode_description,
|
||||
param="DeviceBootMode",
|
||||
buttons=[lambda: tr("Default"), lambda: tr("Offroad")],
|
||||
button_width=364,
|
||||
callback=None,
|
||||
inline=True,
|
||||
)
|
||||
|
||||
self._quiet_mode_and_dcam = dual_button_item_sp(
|
||||
left_text=lambda: tr("Quiet Mode"),
|
||||
right_text=lambda: tr("Driver Camera Preview"),
|
||||
left_callback=lambda: ui_state.params.put_bool("QuietMode", not ui_state.params.get_bool("QuietMode")),
|
||||
right_callback=self._show_driver_camera
|
||||
)
|
||||
self._quiet_mode_and_dcam.action_item.right_button.set_button_style(ButtonStyle.NORMAL)
|
||||
|
||||
self._reg_and_training = dual_button_item_sp(
|
||||
left_text=lambda: tr("Regulatory"),
|
||||
left_callback=self._on_regulatory,
|
||||
right_text=lambda: tr("Training Guide"),
|
||||
right_callback=self._on_review_training_guide
|
||||
)
|
||||
self._reg_and_training.action_item.right_button.set_button_style(ButtonStyle.NORMAL)
|
||||
|
||||
self._onroad_uploads_and_reset_settings = dual_button_item_sp(
|
||||
left_text=lambda: tr("Onroad Uploads"),
|
||||
left_callback=lambda: ui_state.params.put_bool("OnroadUploads", not ui_state.params.get_bool("OnroadUploads")),
|
||||
right_text=lambda: tr("Reset Settings"),
|
||||
right_callback=self._reset_settings
|
||||
)
|
||||
|
||||
self._power_buttons = dual_button_item_sp(
|
||||
left_text=lambda: tr("Reboot"),
|
||||
right_text=lambda: tr("Power Off"),
|
||||
left_callback=self._reboot_prompt,
|
||||
right_callback=self._power_off_prompt
|
||||
)
|
||||
|
||||
items = [
|
||||
text_item(lambda: tr("Dongle ID"), self._params.get("DongleId") or (lambda: tr("N/A"))),
|
||||
LineSeparator(),
|
||||
text_item(lambda: tr("Serial"), self._params.get("HardwareSerial") or (lambda: tr("N/A"))),
|
||||
LineSeparator(),
|
||||
self._pair_device_btn,
|
||||
LineSeparator(),
|
||||
self._reset_calib_btn,
|
||||
LineSeparator(),
|
||||
button_item_sp(lambda: tr("Change Language"), lambda: tr("CHANGE"), callback=self._show_language_dialog),
|
||||
LineSeparator(),
|
||||
self._device_wake_mode,
|
||||
LineSeparator(),
|
||||
self._max_time_offroad,
|
||||
LineSeparator(height=10),
|
||||
self._quiet_mode_and_dcam,
|
||||
self._reg_and_training,
|
||||
self._onroad_uploads_and_reset_settings,
|
||||
Spacer(10),
|
||||
LineSeparator(height=10),
|
||||
self._power_buttons,
|
||||
]
|
||||
|
||||
return items
|
||||
|
||||
def _offroad_transition(self):
|
||||
self._power_buttons.action_item.right_button.set_visible(ui_state.is_offroad())
|
||||
|
||||
@staticmethod
|
||||
def wake_mode_description() -> str:
|
||||
def_str = tr("Default: Device will boot/wake-up normally & will be ready to engage.")
|
||||
offrd_str = tr("Offroad: Device will be in Always Offroad mode after boot/wake-up.")
|
||||
header = tr("Controls state of the device after boot/sleep.")
|
||||
|
||||
return f"{header}\n\n{def_str}\n{offrd_str}"
|
||||
|
||||
@staticmethod
|
||||
def _reset_settings():
|
||||
def _do_reset(result: int):
|
||||
if result == DialogResult.CONFIRM:
|
||||
for _key in ui_state.params.all_keys():
|
||||
ui_state.params.remove(_key)
|
||||
HARDWARE.reboot()
|
||||
|
||||
def _second_confirm(result: int):
|
||||
if result == DialogResult.CONFIRM:
|
||||
gui_app.set_modal_overlay(ConfirmDialog(
|
||||
text=tr("The reset cannot be undone. You have been warned."),
|
||||
confirm_text=tr("Confirm")
|
||||
), callback=_do_reset)
|
||||
|
||||
gui_app.set_modal_overlay(ConfirmDialog(
|
||||
text=tr("Are you sure you want to reset all sunnypilot settings to default? Once the settings are reset, there is no going back."),
|
||||
confirm_text=tr("Reset")
|
||||
), callback=_second_confirm)
|
||||
|
||||
@staticmethod
|
||||
def _handle_always_offroad():
|
||||
if ui_state.engaged:
|
||||
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Enter Always Offroad Mode")))
|
||||
return
|
||||
|
||||
_offroad_mode_state = ui_state.params.get_bool("OffroadMode")
|
||||
_offroad_mode_str = tr("Are you sure you want to exit Always Offroad mode?") if _offroad_mode_state else \
|
||||
tr("Are you sure you want to enter Always Offroad mode?")
|
||||
|
||||
def _set_always_offroad(result: int):
|
||||
if result == DialogResult.CONFIRM and not ui_state.engaged:
|
||||
ui_state.params.put_bool("OffroadMode", not _offroad_mode_state)
|
||||
|
||||
gui_app.set_modal_overlay(ConfirmDialog(_offroad_mode_str, tr("Confirm")), callback=lambda result: _set_always_offroad(result))
|
||||
|
||||
@staticmethod
|
||||
def _update_max_time_offroad_label(value: int) -> str:
|
||||
label = tr("Always On") if value == 0 else f"{value}" + tr("m") if value < 60 else f"{value // 60}" + tr("h")
|
||||
label += tr(" (Default)") if value == 1800 else ""
|
||||
return label
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
# Handle Always Offroad button
|
||||
always_offroad = ui_state.params.get_bool("OffroadMode")
|
||||
|
||||
# Text & Color
|
||||
offroad_mode_btn_text = tr("Exit Always Offroad") if always_offroad else tr("Enable Always Offroad")
|
||||
offroad_mode_btn_style = ButtonStyle.NORMAL if always_offroad else ButtonStyle.DANGER
|
||||
self._always_offroad_btn.action_item.left_button.set_text(offroad_mode_btn_text)
|
||||
self._always_offroad_btn.action_item.left_button.set_button_style(offroad_mode_btn_style)
|
||||
|
||||
# Position
|
||||
if self._scroller._items.__contains__(self._always_offroad_btn):
|
||||
self._scroller._items.remove(self._always_offroad_btn)
|
||||
if ui_state.is_offroad() and not always_offroad:
|
||||
self._scroller._items.insert(len(self._scroller._items) - 1, self._always_offroad_btn)
|
||||
else:
|
||||
self._scroller._items.insert(0, self._always_offroad_btn)
|
||||
|
||||
# Quiet Mode button
|
||||
self._quiet_mode_and_dcam.action_item.left_button.set_button_style(ButtonStyle.PRIMARY if ui_state.params.get_bool("QuietMode") else ButtonStyle.NORMAL)
|
||||
|
||||
# Onroad Uploads
|
||||
self._onroad_uploads_and_reset_settings.action_item.left_button.set_button_style(
|
||||
ButtonStyle.PRIMARY if ui_state.params.get_bool("OnroadUploads") else ButtonStyle.NORMAL
|
||||
)
|
||||
|
||||
# Offroad only buttons
|
||||
self._quiet_mode_and_dcam.action_item.right_button.set_enabled(ui_state.is_offroad())
|
||||
self._reg_and_training.action_item.left_button.set_enabled(ui_state.is_offroad())
|
||||
self._reg_and_training.action_item.right_button.set_enabled(ui_state.is_offroad())
|
||||
self._onroad_uploads_and_reset_settings.action_item.right_button.set_enabled(ui_state.is_offroad())
|
||||
|
||||
@@ -226,9 +226,7 @@ class ModelsLayout(Widget):
|
||||
turn_desire: bool = ui_state.params.get_bool("LaneTurnDesire")
|
||||
live_delay: bool = ui_state.params.get_bool("LagdToggle")
|
||||
|
||||
self.lane_turn_desire_toggle.action_item.set_state(turn_desire)
|
||||
self.lane_turn_value_control.set_visible(turn_desire and advanced_controls)
|
||||
self.lagd_toggle.action_item.set_state(live_delay)
|
||||
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:
|
||||
|
||||
@@ -9,28 +9,29 @@ from enum import IntEnum
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.selfdrive.ui.layouts.settings import settings as OP
|
||||
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.toggles import TogglesLayout
|
||||
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.lib.wifi_manager import WifiManager
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.cruise import CruiseLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.developer import DeveloperLayoutSP
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.device import DeviceLayoutSP
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.display import DisplayLayout
|
||||
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.software import SoftwareLayoutSP
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.steering import SteeringLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.sunnylink import SunnylinkLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.trips import TripsLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle import VehicleLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.steering import SteeringLayout
|
||||
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.sunnypilot.layouts.settings.developer import DeveloperLayoutSP
|
||||
from openpilot.system.ui.lib.application import gui_app, MousePos
|
||||
from openpilot.system.ui.lib.multilang import tr_noop
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.lib.wifi_manager import WifiManager
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.sunnypilot.lib.styles import style
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
|
||||
# from openpilot.selfdrive.ui.sunnypilot.layouts.settings.navigation import NavigationLayout
|
||||
|
||||
@@ -196,6 +197,10 @@ class SettingsLayoutSP(OP.SettingsLayout):
|
||||
|
||||
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()
|
||||
|
||||
@@ -4,23 +4,23 @@ 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 cereal import custom
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.onboarding import SunnylinkConsentPage
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
|
||||
from openpilot.system.ui.sunnypilot.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog
|
||||
from openpilot.system.ui.widgets import Widget, DialogResult
|
||||
from openpilot.system.ui.widgets.button import ButtonStyle, Button
|
||||
from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.widgets.list_view import button_item, dual_button_item
|
||||
from openpilot.system.ui.widgets.list_view import dual_button_item
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller, LineSeparator
|
||||
from openpilot.system.ui.widgets import Widget, DialogResult
|
||||
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
|
||||
from openpilot.system.version import sunnylink_consent_version
|
||||
|
||||
|
||||
class SunnylinkHeader(Widget):
|
||||
@@ -160,14 +160,14 @@ class SunnylinkLayout(Widget):
|
||||
self._sunnylink_description = SunnylinkDescriptionItem()
|
||||
self._sunnylink_description.set_visible(False)
|
||||
|
||||
self._sponsor_btn = button_item(
|
||||
self._sponsor_btn = button_item_sp(
|
||||
title=tr("Sponsor Status"),
|
||||
button_text=tr("SPONSOR"),
|
||||
description=tr(
|
||||
"Become a sponsor of sunnypilot to get early access to sunnylink features when they become available."),
|
||||
callback=lambda: self._handle_pair_btn(False)
|
||||
)
|
||||
self._pair_btn = button_item(
|
||||
self._pair_btn = button_item_sp(
|
||||
title=tr("Pair GitHub Account"),
|
||||
button_text=tr("Not Paired"),
|
||||
description=tr(
|
||||
@@ -209,8 +209,8 @@ class SunnylinkLayout(Widget):
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _get_sunnylink_dongle_id() -> str | None:
|
||||
return str(ui_state.params.get("SunnylinkDongleId") or (lambda: tr("N/A")))
|
||||
def _get_sunnylink_dongle_id() -> str:
|
||||
return ui_state.params.get("SunnylinkDongleId") or tr("N/A")
|
||||
|
||||
def _handle_pair_btn(self, sponsor_pairing: bool = False):
|
||||
sunnylink_dongle_id = self._get_sunnylink_dongle_id()
|
||||
@@ -302,6 +302,22 @@ class SunnylinkLayout(Widget):
|
||||
self._restore_btn.set_text(tr("Restore Settings"))
|
||||
|
||||
def _sunnylink_toggle_callback(self, state: bool):
|
||||
sl_consent: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") == sunnylink_consent_version
|
||||
sl_enabled: bool = ui_state.params.get_bool("SunnylinkEnabled")
|
||||
|
||||
if state and not sl_consent and not sl_enabled:
|
||||
def on_consent_done():
|
||||
enabled = ui_state.params.get_bool("SunnylinkEnabled")
|
||||
self._update_description(enabled)
|
||||
gui_app.set_modal_overlay(None)
|
||||
|
||||
sl_terms_dlg = SunnylinkConsentPage(done_callback=on_consent_done)
|
||||
gui_app.set_modal_overlay(sl_terms_dlg)
|
||||
else:
|
||||
ui_state.params.put_bool("SunnylinkEnabled", state)
|
||||
self._update_description(state)
|
||||
|
||||
def _update_description(self, state: bool):
|
||||
if state:
|
||||
description = tr(
|
||||
"Welcome back!! We're excited to see you've enabled sunnylink again!")
|
||||
@@ -320,7 +336,6 @@ class SunnylinkLayout(Widget):
|
||||
self._sunnylink_enabled = ui_state.params.get_bool("SunnylinkEnabled")
|
||||
self._sunnylink_toggle.set_right_value(tr("Dongle ID") + ": " + self._get_sunnylink_dongle_id())
|
||||
self._sunnylink_toggle.action_item.set_enabled(not ui_state.is_onroad())
|
||||
self._sunnylink_toggle.action_item.set_state(self._sunnylink_enabled)
|
||||
self._sunnylink_uploader_toggle.action_item.set_enabled(self._sunnylink_enabled)
|
||||
self.handle_backup_restore_progress()
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class VehicleLayout(Widget):
|
||||
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")),
|
||||
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)
|
||||
@@ -42,7 +42,7 @@ class VehicleLayout(Widget):
|
||||
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")
|
||||
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()
|
||||
|
||||
@@ -55,5 +55,4 @@ class HyundaiSettings(BrandSettings):
|
||||
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.action_item.set_selected_button(tuning_param)
|
||||
self.longitudinal_tuning_item.set_visible(self.alpha_long_available)
|
||||
|
||||
@@ -5,11 +5,55 @@ 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.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
|
||||
|
||||
|
||||
DESCRIPTIONS = {
|
||||
'enforce_stock_longitudinal': tr_noop(
|
||||
'sunnypilot will not take over control of gas and brakes. Factory Toyota longitudinal control will be used.'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ToyotaSettings(BrandSettings):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.enforce_stock_longitudinal = toggle_item_sp(
|
||||
lambda: tr("Enforce Factory Longitudinal Control"),
|
||||
description=lambda: tr(DESCRIPTIONS["enforce_stock_longitudinal"]),
|
||||
initial_state=ui_state.params.get_bool("ToyotaEnforceStockLongitudinal"),
|
||||
callback=self._on_enable_enforce_stock_longitudinal,
|
||||
enabled=lambda: not ui_state.engaged,
|
||||
)
|
||||
|
||||
self.items = [self.enforce_stock_longitudinal, ]
|
||||
|
||||
def _on_enable_enforce_stock_longitudinal(self, state: bool):
|
||||
if state:
|
||||
def confirm_callback(result: int):
|
||||
if result == DialogResult.CONFIRM:
|
||||
ui_state.params.put_bool("ToyotaEnforceStockLongitudinal", True)
|
||||
if ui_state.params.get_bool("AlphaLongitudinalEnabled"):
|
||||
ui_state.params.put_bool("AlphaLongitudinalEnabled", False)
|
||||
ui_state.params.put_bool("OnroadCycleRequested", True)
|
||||
else:
|
||||
self.enforce_stock_longitudinal.action_item.set_state(False)
|
||||
|
||||
content = (f"<h1>{self.enforce_stock_longitudinal.title}</h1><br>" +
|
||||
f"<p>{self.enforce_stock_longitudinal.description}</p>")
|
||||
|
||||
dlg = ConfirmDialog(content, tr("Enable"), rich=True)
|
||||
gui_app.set_modal_overlay(dlg, callback=confirm_callback)
|
||||
|
||||
else:
|
||||
ui_state.params.put_bool("ToyotaEnforceStockLongitudinal", False)
|
||||
ui_state.params.put_bool("OnroadCycleRequested", True)
|
||||
|
||||
def update_settings(self):
|
||||
pass
|
||||
|
||||
87
selfdrive/ui/sunnypilot/layouts/sidebar.py
Normal file
87
selfdrive/ui/sunnypilot/layouts/sidebar.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
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
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
|
||||
from openpilot.system.ui.lib.multilang import tr_noop
|
||||
|
||||
|
||||
PING_TIMEOUT_NS = 80_000_000_000 # 80 seconds in nanoseconds
|
||||
METRIC_HEIGHT = 126
|
||||
METRIC_MARGIN = 30
|
||||
METRIC_START_Y = 300
|
||||
HOME_BTN = rl.Rectangle(60, 860, 180, 180)
|
||||
|
||||
|
||||
# Color scheme
|
||||
class Colors:
|
||||
WHITE = rl.WHITE
|
||||
WHITE_DIM = rl.Color(255, 255, 255, 85)
|
||||
GRAY = rl.Color(84, 84, 84, 255)
|
||||
|
||||
# Status colors
|
||||
GOOD = rl.WHITE
|
||||
WARNING = rl.Color(218, 202, 37, 255)
|
||||
DANGER = rl.Color(201, 34, 49, 255)
|
||||
PROGRESS = rl.Color(0, 134, 233, 255)
|
||||
DISABLED = rl.Color(128, 128, 128, 255)
|
||||
|
||||
# UI elements
|
||||
METRIC_BORDER = rl.Color(255, 255, 255, 85)
|
||||
BUTTON_NORMAL = rl.WHITE
|
||||
BUTTON_PRESSED = rl.Color(255, 255, 255, 166)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MetricData:
|
||||
label: str
|
||||
value: str
|
||||
color: rl.Color
|
||||
|
||||
def update(self, label: str, value: str, color: rl.Color):
|
||||
self.label = label
|
||||
self.value = value
|
||||
self.color = color
|
||||
|
||||
|
||||
class SidebarSP:
|
||||
def __init__(self):
|
||||
self._sunnylink_status = MetricData(tr_noop("SUNNYLINK"), tr_noop("OFFLINE"), Colors.WARNING)
|
||||
|
||||
def _update_sunnylink_status(self):
|
||||
if not ui_state.params.get_bool("SunnylinkEnabled"):
|
||||
self._sunnylink_status.update(tr_noop("SUNNYLINK"), tr_noop("DISABLED"), Colors.DISABLED)
|
||||
return
|
||||
|
||||
last_ping = ui_state.params.get("LastSunnylinkPingTime") or 0
|
||||
dongle_id = ui_state.params.get("SunnylinkDongleId")
|
||||
|
||||
is_online = last_ping and (time.monotonic_ns() - last_ping) < PING_TIMEOUT_NS
|
||||
is_temp_fault = ui_state.params.get_bool("SunnylinkTempFault")
|
||||
is_registering = not is_temp_fault and dongle_id in (None, "", UNREGISTERED_SUNNYLINK_DONGLE_ID)
|
||||
|
||||
# Determine status/color pair based on priority
|
||||
if last_ping:
|
||||
status, color = (tr_noop("ONLINE"), Colors.GOOD) if is_online else (tr_noop("ERROR"), Colors.DANGER)
|
||||
elif is_temp_fault:
|
||||
status, color = (tr_noop("FAULT"), Colors.WARNING)
|
||||
elif is_registering:
|
||||
status, color = (tr_noop("REGIST..."), Colors.PROGRESS)
|
||||
else:
|
||||
status, color = (tr_noop("OFFLINE"), Colors.DANGER)
|
||||
|
||||
self._sunnylink_status.update(tr_noop("SUNNYLINK"), status, color)
|
||||
|
||||
def _draw_metrics_w_sunnylink(self, rect: rl.Rectangle, _temp, _panda, _connect):
|
||||
metrics = [_temp, _panda, _connect, self._sunnylink_status]
|
||||
start_y = int(rect.y) + METRIC_START_Y
|
||||
available_height = max(0, int(HOME_BTN.y) - METRIC_MARGIN - METRIC_HEIGHT - start_y)
|
||||
spacing = available_height / max(1, len(metrics) - 1)
|
||||
|
||||
return metrics, start_y, spacing
|
||||
0
selfdrive/ui/sunnypilot/mici/__init__.py
Normal file
0
selfdrive/ui/sunnypilot/mici/__init__.py
Normal file
0
selfdrive/ui/sunnypilot/mici/layouts/__init__.py
Normal file
0
selfdrive/ui/sunnypilot/mici/layouts/__init__.py
Normal file
97
selfdrive/ui/sunnypilot/mici/layouts/onboarding.py
Normal file
97
selfdrive/ui/sunnypilot/mici/layouts/onboarding.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.widgets.slider import SmallSlider
|
||||
from openpilot.system.ui.mici_setup import TermsHeader, TermsPage as SetupTermsPage
|
||||
from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
|
||||
|
||||
class SunnylinkConsentPage(SetupTermsPage):
|
||||
def __init__(self, on_accept=None, on_decline=None, left_text: str = "disable", right_text: str = "enable"):
|
||||
super().__init__(on_accept, on_decline, left_text, continue_text=right_text)
|
||||
|
||||
self._title_header = TermsHeader("sunnylink",
|
||||
gui_app.texture("../../sunnypilot/selfdrive/assets/logo.png", 66, 60))
|
||||
|
||||
self._terms_label = UnifiedLabel("sunnylink enables secured remote access to your comma device from anywhere, " +
|
||||
"including settings management, remote monitoring, real-time dashboard, etc.",
|
||||
36, FontWeight.ROMAN)
|
||||
|
||||
@property
|
||||
def _content_height(self):
|
||||
return self._terms_label.rect.y + self._terms_label.rect.height - self._scroll_panel.get_offset()
|
||||
|
||||
def _render(self, _):
|
||||
super()._render(_)
|
||||
return -1
|
||||
|
||||
def _render_content(self, scroll_offset):
|
||||
self._title_header.set_position(self._rect.x + 16, self._rect.y + 12 + scroll_offset)
|
||||
self._title_header.render()
|
||||
|
||||
self._terms_label.render(rl.Rectangle(
|
||||
self._rect.x + 16,
|
||||
self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING,
|
||||
self._rect.width - 100,
|
||||
self._terms_label.get_content_height(int(self._rect.width - 100)),
|
||||
))
|
||||
|
||||
|
||||
class SunnylinkConsentDisableConfirmPage(SunnylinkConsentPage):
|
||||
def __init__(self, on_accept=None, on_decline=None):
|
||||
super().__init__(on_accept=on_decline, on_decline=on_accept, left_text="enable", right_text="disable")
|
||||
|
||||
# we flip the continue & disable buttons to use slider for disable
|
||||
self._continue_slider = True
|
||||
self._continue_button = SmallSlider("disable", confirm_callback=on_decline)
|
||||
self._scroll_panel.set_enabled(lambda: not self._continue_button.is_pressed)
|
||||
|
||||
self._title_header = TermsHeader("disable sunnylink?",
|
||||
gui_app.texture("icons_mici/setup/red_warning.png", 66, 60))
|
||||
|
||||
self._terms_label = UnifiedLabel("sunnylink is designed to be enabled as part of sunnypilot's core functionality. " +
|
||||
"If sunnylink is disabled, features such as settings management, " +
|
||||
"remote monitoring, real-time dashboards will be unavailable.",
|
||||
36, FontWeight.ROMAN)
|
||||
|
||||
|
||||
class SunnylinkOnboarding:
|
||||
def __init__(self):
|
||||
self.consent_done: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") in {sunnylink_consent_version, sunnylink_consent_declined}
|
||||
self.disable_confirm = False
|
||||
|
||||
self.consent_page = SunnylinkConsentPage(on_decline=self._on_decline, on_accept=self._on_accept)
|
||||
self.confirm_page = SunnylinkConsentDisableConfirmPage(on_decline=self._on_confirm_decline, on_accept=self._on_accept)
|
||||
|
||||
@property
|
||||
def completed(self) -> bool:
|
||||
return self.consent_done
|
||||
|
||||
def _on_accept(self):
|
||||
ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version)
|
||||
ui_state.params.put_bool("SunnylinkEnabled", True)
|
||||
self.consent_done = True
|
||||
|
||||
def _on_decline(self):
|
||||
self.disable_confirm = True
|
||||
|
||||
def _on_confirm_decline(self):
|
||||
ui_state.params.put_bool("SunnylinkEnabled", False)
|
||||
ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined)
|
||||
self.consent_done = True
|
||||
|
||||
def render(self, rect):
|
||||
if self.consent_done:
|
||||
return
|
||||
|
||||
if self.disable_confirm:
|
||||
self.confirm_page.render(rect)
|
||||
else:
|
||||
self.consent_page.render(rect)
|
||||
39
selfdrive/ui/sunnypilot/mici/layouts/settings.py
Normal file
39
selfdrive/ui/sunnypilot/mici/layouts/settings.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
211
selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
Normal file
211
selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
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 collections.abc import Callable
|
||||
|
||||
import pyray as rl
|
||||
from cereal import custom
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialogV2
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
|
||||
from openpilot.system.ui.lib.application import gui_app, MousePos
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import NavWidget
|
||||
from openpilot.system.ui.widgets.scroller import Scroller
|
||||
from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined
|
||||
|
||||
|
||||
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.params.get("SunnylinkEnabled")
|
||||
|
||||
self._sunnylink_toggle = BigToggle(text=tr("enable sunnylink"),
|
||||
initial_state=self._sunnylink_enabled,
|
||||
toggle_callback=self._sunnylink_toggle_callback)
|
||||
self._sunnylink_sponsor_button = SunnylinkPairBigButton(sponsor_pairing=False)
|
||||
self._sunnylink_pair_button = SunnylinkPairBigButton(sponsor_pairing=True)
|
||||
self._backup_btn = BigButton(tr("backup settings"), "", "")
|
||||
self._backup_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=False))
|
||||
self._restore_btn = BigButton(tr("restore settings"), "", "")
|
||||
self._restore_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=True))
|
||||
self._sunnylink_uploader_toggle = BigToggle(text=tr("sunnylink uploader"), initial_state=False,
|
||||
toggle_callback=self._sunnylink_uploader_callback)
|
||||
|
||||
self._scroller = Scroller([
|
||||
self._sunnylink_toggle,
|
||||
self._sunnylink_sponsor_button,
|
||||
self._sunnylink_pair_button,
|
||||
self._backup_btn,
|
||||
self._restore_btn,
|
||||
self._sunnylink_uploader_toggle
|
||||
], snap_items=False)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled")
|
||||
self._sunnylink_toggle.set_checked(self._sunnylink_enabled)
|
||||
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._sunnylink_uploader_toggle.set_visible(self._sunnylink_enabled)
|
||||
self.handle_backup_restore_progress()
|
||||
|
||||
if ui_state.sunnylink_state.is_sponsor():
|
||||
self._sunnylink_sponsor_button.set_text(tr("thanks"))
|
||||
self._sunnylink_sponsor_button.set_value(ui_state.sunnylink_state.get_sponsor_tier().name.lower())
|
||||
self._sunnylink_sponsor_button.set_enabled(False)
|
||||
else:
|
||||
self._sunnylink_sponsor_button.set_text(tr("sponsor"))
|
||||
self._sunnylink_sponsor_button.set_value("")
|
||||
|
||||
if ui_state.sunnylink_state.is_paired():
|
||||
self._sunnylink_pair_button.set_text(tr("paired"))
|
||||
else:
|
||||
self._sunnylink_pair_button.set_text(tr("pair"))
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._scroller.show_event()
|
||||
ui_state.update_params()
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
self._scroller.render(rect)
|
||||
|
||||
@staticmethod
|
||||
def _sunnylink_toggle_callback(state: bool):
|
||||
sl_consent: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") == sunnylink_consent_version
|
||||
sl_enabled: bool = ui_state.params.get("SunnylinkEnabled")
|
||||
|
||||
def sl_terms_accepted():
|
||||
ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version)
|
||||
ui_state.params.put_bool("SunnylinkEnabled", True)
|
||||
gui_app.set_modal_overlay(None)
|
||||
|
||||
def sl_terms_declined():
|
||||
ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined)
|
||||
ui_state.params.put_bool("SunnylinkEnabled", False)
|
||||
gui_app.set_modal_overlay(None)
|
||||
|
||||
if state and not sl_consent and not sl_enabled:
|
||||
sl_terms_dlg = SunnylinkConsentPage(on_accept=sl_terms_accepted, on_decline=sl_terms_declined)
|
||||
gui_app.set_modal_overlay(sl_terms_dlg)
|
||||
else:
|
||||
ui_state.params.put_bool("SunnylinkEnabled", state)
|
||||
|
||||
ui_state.update_params()
|
||||
|
||||
@staticmethod
|
||||
def _sunnylink_uploader_callback(state: bool):
|
||||
ui_state.params.put_bool("EnableSunnylinkUploader", state)
|
||||
|
||||
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/device/update.png"
|
||||
dlg = BigConfirmationDialogV2(lbl, icon, confirm_callback=self._restore_handler if restore else self._backup_handler)
|
||||
gui_app.set_modal_overlay(dlg)
|
||||
|
||||
def _backup_handler(self):
|
||||
self._backup_in_progress = True
|
||||
self._backup_btn.set_enabled(False)
|
||||
ui_state.params.put_bool("BackupManager_CreateBackup", True)
|
||||
|
||||
def _restore_handler(self):
|
||||
self._restore_in_progress = True
|
||||
self._restore_btn.set_enabled(False)
|
||||
ui_state.params.put("BackupManager_RestoreVersion", "latest")
|
||||
|
||||
def handle_backup_restore_progress(self):
|
||||
sunnylink_backup_manager = ui_state.sm["backupManagerSP"]
|
||||
|
||||
backup_status = sunnylink_backup_manager.backupStatus
|
||||
restore_status = sunnylink_backup_manager.restoreStatus
|
||||
backup_progress = sunnylink_backup_manager.backupProgress
|
||||
restore_progress = sunnylink_backup_manager.restoreProgress
|
||||
|
||||
if self._backup_in_progress:
|
||||
self._restore_btn.set_enabled(False)
|
||||
self._backup_btn.set_enabled(False)
|
||||
|
||||
if backup_status == custom.BackupManagerSP.Status.inProgress:
|
||||
self._backup_in_progress = True
|
||||
self._backup_btn.set_text(tr("backing up"))
|
||||
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.is_onroad())
|
||||
self._backup_btn.set_text(tr("backup"))
|
||||
self._backup_btn.set_value(tr("failed"))
|
||||
|
||||
elif (backup_status == custom.BackupManagerSP.Status.completed or
|
||||
(backup_status == custom.BackupManagerSP.Status.idle and backup_progress == 100.0)):
|
||||
self._backup_in_progress = False
|
||||
gui_app.set_modal_overlay(BigDialog(title=tr("settings backed up"), description=""))
|
||||
self._backup_btn.set_enabled(not ui_state.is_onroad())
|
||||
|
||||
elif self._restore_in_progress:
|
||||
self._restore_btn.set_enabled(False)
|
||||
self._backup_btn.set_enabled(False)
|
||||
|
||||
if restore_status == custom.BackupManagerSP.Status.inProgress:
|
||||
self._restore_in_progress = True
|
||||
self._restore_btn.set_text(tr("restoring"))
|
||||
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.is_onroad())
|
||||
self._restore_btn.set_text(tr("restore"))
|
||||
self._restore_btn.set_value(tr("failed"))
|
||||
gui_app.set_modal_overlay(BigDialog(title=tr("unable to restore"), description="try again later."))
|
||||
|
||||
elif (restore_status == custom.BackupManagerSP.Status.completed or
|
||||
(restore_status == custom.BackupManagerSP.Status.idle and restore_progress == 100.0)):
|
||||
self._restore_in_progress = False
|
||||
gui_app.set_modal_overlay(BigConfirmationDialogV2(
|
||||
title="slide to restart", icon="icons_mici/settings/device/reboot.png",
|
||||
confirm_callback=lambda: gui_app.request_close()))
|
||||
|
||||
else:
|
||||
can_enable = self._sunnylink_enabled and not ui_state.is_onroad()
|
||||
self._backup_btn.set_enabled(can_enable)
|
||||
self._backup_btn.set_text(tr("backup settings"))
|
||||
self._backup_btn.set_value("")
|
||||
self._restore_btn.set_enabled(can_enable)
|
||||
self._restore_btn.set_text(tr("restore settings"))
|
||||
self._restore_btn.set_value("")
|
||||
|
||||
|
||||
class SunnylinkPairBigButton(BigButton):
|
||||
def __init__(self, sponsor_pairing: bool = False):
|
||||
self.sponsor_pairing = sponsor_pairing
|
||||
super().__init__("", "", "")
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
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.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID):
|
||||
dlg = BigDialog(tr("sunnylink Dongle ID not found. Please reboot & try again."), "")
|
||||
elif self.sponsor_pairing:
|
||||
dlg = SunnylinkPairingDialog(sponsor_pairing=True)
|
||||
elif not self.sponsor_pairing:
|
||||
dlg = SunnylinkPairingDialog(sponsor_pairing=False)
|
||||
if dlg:
|
||||
gui_app.set_modal_overlay(dlg)
|
||||
0
selfdrive/ui/sunnypilot/mici/onroad/__init__.py
Normal file
0
selfdrive/ui/sunnypilot/mici/onroad/__init__.py
Normal file
26
selfdrive/ui/sunnypilot/mici/onroad/confidence_ball.py
Normal file
26
selfdrive/ui/sunnypilot/mici/onroad/confidence_ball.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
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.onroad.augmented_road_view import BORDER_COLORS
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
|
||||
|
||||
|
||||
class ConfidenceBallSP:
|
||||
@staticmethod
|
||||
def get_animate_status_probs():
|
||||
if ui_state.status == UIStatus.LAT_ONLY:
|
||||
return ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs
|
||||
|
||||
# UIStatus.LONG_ONLY
|
||||
return ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs
|
||||
|
||||
@staticmethod
|
||||
def get_lat_long_dot_color():
|
||||
if ui_state.status == UIStatus.LAT_ONLY:
|
||||
return BORDER_COLORS[UIStatus.LAT_ONLY]
|
||||
|
||||
# UIStatus.LONG_ONLY
|
||||
return BORDER_COLORS[UIStatus.LONG_ONLY]
|
||||
13
selfdrive/ui/sunnypilot/mici/onroad/model_renderer.py
Normal file
13
selfdrive/ui/sunnypilot/mici/onroad/model_renderer.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import pyray as rl
|
||||
from openpilot.selfdrive.ui.ui_state import UIStatus
|
||||
|
||||
LANE_LINE_COLORS_SP = {
|
||||
UIStatus.LAT_ONLY: rl.Color(0, 255, 64, 255),
|
||||
UIStatus.LONG_ONLY: rl.Color(0, 255, 64, 255),
|
||||
}
|
||||
0
selfdrive/ui/sunnypilot/mici/widgets/__init__.py
Normal file
0
selfdrive/ui/sunnypilot/mici/widgets/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import base64
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
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 import NavWidget
|
||||
from openpilot.system.ui.widgets.label import MiciLabel
|
||||
|
||||
|
||||
class SunnylinkPairingDialog(PairingDialog):
|
||||
"""Dialog for device pairing with QR code."""
|
||||
|
||||
def __init__(self, sponsor_pairing: bool = False):
|
||||
PairingDialog.__init__(self)
|
||||
self._sponsor_pairing = sponsor_pairing
|
||||
label_text = tr("pair with sunnylink") if sponsor_pairing else tr("become a sunnypilot sponsor")
|
||||
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 = self._params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID
|
||||
token = SunnylinkApi(sl_dongle_id).get_token()
|
||||
inner_string = f"1|{sl_dongle_id}|{token}"
|
||||
payload_bytes = base64.b64encode(inner_string.encode('utf-8')).decode('utf-8')
|
||||
qr_string = f"{API_HOST}/sso?state={payload_bytes}"
|
||||
except Exception:
|
||||
cloudlog.exception("Failed to get pairing token")
|
||||
|
||||
return qr_string
|
||||
|
||||
def _update_state(self):
|
||||
NavWidget._update_state(self)
|
||||
|
||||
|
||||
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
|
||||
13
selfdrive/ui/sunnypilot/onroad/augmented_road_view.py
Normal file
13
selfdrive/ui/sunnypilot/onroad/augmented_road_view.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import pyray as rl
|
||||
from openpilot.selfdrive.ui.ui_state import UIStatus
|
||||
|
||||
BORDER_COLORS_SP = {
|
||||
UIStatus.LAT_ONLY: rl.Color(0x00, 0xC8, 0xC8, 0xFF), # Cyan for lateral-only state
|
||||
UIStatus.LONG_ONLY: rl.Color(0x96, 0x1C, 0xA8, 0xFF), # Purple for longitudinal-only state
|
||||
}
|
||||
147
selfdrive/ui/sunnypilot/onroad/chevron_metrics.py
Normal file
147
selfdrive/ui/sunnypilot/onroad/chevron_metrics.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
|
||||
|
||||
class ChevronOptions:
|
||||
OFF = 0
|
||||
DISTANCE_ONLY = 1
|
||||
SPEED_ONLY = 2
|
||||
TTC_ONLY = 3
|
||||
ALL = 4
|
||||
|
||||
|
||||
class ChevronMetrics:
|
||||
def __init__(self):
|
||||
self._lead_status_alpha: float = 0.0
|
||||
self._font = gui_app.font(FontWeight.SEMI_BOLD)
|
||||
|
||||
def update_alpha(self, has_lead: bool):
|
||||
"""Update the alpha value for fade in/out animation"""
|
||||
if not has_lead:
|
||||
self._lead_status_alpha = max(0.0, self._lead_status_alpha - 0.05)
|
||||
else:
|
||||
self._lead_status_alpha = min(1.0, self._lead_status_alpha + 0.1)
|
||||
|
||||
def should_render(self) -> bool:
|
||||
"""Check if dev UI should be rendered"""
|
||||
return ui_state.chevron_metrics != ChevronOptions.OFF and self._lead_status_alpha > 0.0
|
||||
|
||||
def _draw_lead(self, lead_data, lead_vehicle, v_ego: float, rect: rl.Rectangle):
|
||||
"""Draw lead vehicle status information (distance, speed, TTC)"""
|
||||
if not self.should_render():
|
||||
return
|
||||
|
||||
d_rel = lead_data.dRel
|
||||
v_rel = lead_data.vRel
|
||||
|
||||
if not lead_vehicle.chevron or len(lead_vehicle.chevron) < 2:
|
||||
return
|
||||
|
||||
chevron_x = lead_vehicle.chevron[1][0]
|
||||
chevron_y = lead_vehicle.chevron[1][1]
|
||||
sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 2.35
|
||||
|
||||
text_lines = self._build_text_lines(d_rel, v_rel, v_ego)
|
||||
if not text_lines:
|
||||
return
|
||||
|
||||
self._render_text_lines(text_lines, chevron_x, chevron_y, sz, rect)
|
||||
|
||||
@staticmethod
|
||||
def _build_text_lines(d_rel: float, v_rel: float, v_ego: float) -> list[str]:
|
||||
"""Build text lines based on chevron info setting"""
|
||||
text_lines = []
|
||||
|
||||
# Distance
|
||||
if ui_state.chevron_metrics == ChevronOptions.DISTANCE_ONLY or ui_state.chevron_metrics == ChevronOptions.ALL:
|
||||
val = max(0.0, d_rel)
|
||||
unit = "m" if ui_state.is_metric else "ft"
|
||||
if not ui_state.is_metric:
|
||||
val *= 3.28084
|
||||
text_lines.append(f"{val:.0f} {unit}")
|
||||
|
||||
# Speed
|
||||
if ui_state.chevron_metrics == ChevronOptions.SPEED_ONLY or ui_state.chevron_metrics == ChevronOptions.ALL:
|
||||
multiplier = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
|
||||
val = max(0.0, (v_rel + v_ego) * multiplier)
|
||||
unit = "km/h" if ui_state.is_metric else "mph"
|
||||
text_lines.append(f"{val:.0f} {unit}")
|
||||
|
||||
# Time to collision
|
||||
if ui_state.chevron_metrics == ChevronOptions.TTC_ONLY or ui_state.chevron_metrics == ChevronOptions.ALL:
|
||||
val = (d_rel / v_ego) if (d_rel > 0 and v_ego > 0) else 0.0
|
||||
ttc_text = f"{val:.1f} s" if (0 < val < 200) else "---"
|
||||
text_lines.append(ttc_text)
|
||||
|
||||
return text_lines
|
||||
|
||||
def _render_text_lines(self, text_lines: list[str], chevron_x: float, chevron_y: float,
|
||||
sz: float, rect: rl.Rectangle):
|
||||
"""Render text lines with proper centering and positioning"""
|
||||
font_size = 40
|
||||
line_height = 50
|
||||
margin = 20
|
||||
|
||||
text_y = chevron_y + sz + 15
|
||||
total_height = len(text_lines) * line_height
|
||||
|
||||
# Adjust Y position if text would go off screen
|
||||
if text_y + total_height > rect.height - margin:
|
||||
y_max = min(chevron_y, rect.height - margin)
|
||||
text_y = y_max - 15 - total_height
|
||||
text_y = max(margin, text_y)
|
||||
|
||||
alpha = int(255 * self._lead_status_alpha)
|
||||
text_color = rl.Color(255, 255, 255, alpha)
|
||||
shadow_color = rl.Color(0, 0, 0, int(200 * self._lead_status_alpha))
|
||||
|
||||
for i, line in enumerate(text_lines):
|
||||
y = int(text_y + (i * line_height))
|
||||
if y + line_height > rect.height - margin:
|
||||
break
|
||||
|
||||
# Measure actual text width for proper centering
|
||||
text_size = measure_text_cached(self._font, line, font_size, 0)
|
||||
text_width = text_size.x
|
||||
|
||||
# Center the text horizontally on the chevron
|
||||
x = int(chevron_x - text_width / 2)
|
||||
x = int(np.clip(x, margin, rect.width - text_width - margin))
|
||||
|
||||
# Draw shadow
|
||||
rl.draw_text_ex(self._font, line, rl.Vector2(x + 2, y + 2), font_size, 0, shadow_color)
|
||||
# Draw text
|
||||
rl.draw_text_ex(self._font, line, rl.Vector2(x, y), font_size, 0, text_color)
|
||||
|
||||
def draw_lead_status(self, sm, radar_state, rect, lead_vehicles):
|
||||
lead_one = radar_state.leadOne
|
||||
lead_two = radar_state.leadTwo
|
||||
|
||||
has_lead_one = lead_one.status if lead_one else False
|
||||
has_lead_two = lead_two.status if lead_two else False
|
||||
|
||||
self.update_alpha(has_lead_one or has_lead_two)
|
||||
|
||||
if not self.should_render():
|
||||
return
|
||||
|
||||
v_ego = sm['carState'].vEgo
|
||||
|
||||
if has_lead_one and lead_vehicles[0].chevron:
|
||||
self._draw_lead(lead_one, lead_vehicles[0], v_ego, rect)
|
||||
|
||||
if has_lead_two and lead_vehicles[1].chevron:
|
||||
d_rel_diff = abs(lead_one.dRel - lead_two.dRel) if has_lead_one else float('inf')
|
||||
if d_rel_diff > 3.0:
|
||||
self._draw_lead(lead_two, lead_vehicles[1], v_ego, rect)
|
||||
@@ -22,6 +22,7 @@ class DeveloperUiRenderer(Widget):
|
||||
DEV_UI_RIGHT = 1
|
||||
DEV_UI_BOTTOM = 2
|
||||
DEV_UI_BOTH = 3
|
||||
BOTTOM_BAR_HEIGHT = 61
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -43,6 +44,12 @@ class DeveloperUiRenderer(Widget):
|
||||
self.bearing_elem = BearingDegElement()
|
||||
self.altitude_elem = AltitudeElement()
|
||||
|
||||
@staticmethod
|
||||
def get_bottom_dev_ui_offset():
|
||||
if ui_state.developer_ui in (DeveloperUiRenderer.DEV_UI_BOTTOM, DeveloperUiRenderer.DEV_UI_BOTH):
|
||||
return DeveloperUiRenderer.BOTTOM_BAR_HEIGHT
|
||||
return 0
|
||||
|
||||
def _update_state(self) -> None:
|
||||
self.dev_ui_mode = ui_state.developer_ui
|
||||
|
||||
@@ -78,10 +85,11 @@ class DeveloperUiRenderer(Widget):
|
||||
]
|
||||
if controls_state.lateralControlState.which() == 'torqueState':
|
||||
elements.append(self.desired_lat_accel_elem.update(sm, ui_state.is_metric))
|
||||
elements.append(self.actual_lat_accel_elem.update(sm, ui_state.is_metric))
|
||||
else:
|
||||
elements.append(self.desired_steer_elem.update(sm, ui_state.is_metric))
|
||||
|
||||
elements.append(self.actual_lat_accel_elem.update(sm, ui_state.is_metric))
|
||||
|
||||
current_y = y
|
||||
for element in elements:
|
||||
current_y += self._draw_right_dev_ui_element(x, current_y, element)
|
||||
@@ -105,7 +113,7 @@ class DeveloperUiRenderer(Widget):
|
||||
if element.unit:
|
||||
units_height = measure_text_cached(self._font_bold, element.unit, unit_size, 0).x
|
||||
|
||||
units_x = x + container_width - 10
|
||||
units_x = x + container_width
|
||||
units_y = y + (value_size / 2) + (units_height / 2)
|
||||
|
||||
rl.draw_text_pro(self._font_bold, element.unit, rl.Vector2(units_x, units_y), rl.Vector2(0, 0), -90.0, unit_size, 0, rl.WHITE)
|
||||
@@ -143,22 +151,35 @@ class DeveloperUiRenderer(Widget):
|
||||
if sm.valid['gpsLocationExternal'] or sm.valid['gpsLocation']:
|
||||
elements.append(self.altitude_elem.update(sm, ui_state.is_metric))
|
||||
|
||||
current_x = int(rect.x + 90)
|
||||
center_y = y + bar_height // 2
|
||||
for element in elements:
|
||||
current_x += self._draw_bottom_dev_ui_element(current_x, center_y, element)
|
||||
if not elements:
|
||||
return
|
||||
|
||||
def _draw_bottom_dev_ui_element(self, x: int, y: int, element: UiElement) -> int:
|
||||
font_size = 38
|
||||
element_widths = []
|
||||
for element in elements:
|
||||
element.measure(self._font_bold, font_size)
|
||||
element_widths.append(element.total_width)
|
||||
|
||||
label_text = f"{element.label} "
|
||||
label_width = measure_text_cached(self._font_bold, label_text, font_size, 0).x
|
||||
rl.draw_text_ex(self._font_bold, label_text, rl.Vector2(x, y - font_size // 2), font_size, 0, rl.WHITE)
|
||||
total_element_width = sum(element_widths)
|
||||
num_gaps = len(elements) + 1
|
||||
available_width = rect.width
|
||||
gap_width = (available_width - total_element_width) / num_gaps
|
||||
|
||||
value_width = measure_text_cached(self._font_bold, element.value, font_size, 0).x
|
||||
rl.draw_text_ex(self._font_bold, element.value, rl.Vector2(x + label_width + 10, y - font_size // 2), font_size, 0, element.color)
|
||||
center_y = y + bar_height // 2
|
||||
current_x = rect.x + gap_width
|
||||
|
||||
for i, element in enumerate(elements):
|
||||
element_center_x = int(current_x + element_widths[i] / 2)
|
||||
self._draw_bottom_dev_ui_element(element_center_x, center_y, element)
|
||||
current_x += element_widths[i] + gap_width
|
||||
|
||||
def _draw_bottom_dev_ui_element(self, center_x: int, y: int, element: UiElement) -> None:
|
||||
font_size = 38
|
||||
start_x = center_x - element.total_width / 2
|
||||
|
||||
rl.draw_text_ex(self._font_bold, element.label_text, rl.Vector2(start_x, y - font_size // 2), font_size, 0, rl.WHITE)
|
||||
rl.draw_text_ex(self._font_bold, element.val_text, rl.Vector2(start_x + element.label_width, y - font_size // 2), font_size, 0, element.color)
|
||||
|
||||
if element.unit:
|
||||
rl.draw_text_ex(self._font_bold, element.unit, rl.Vector2(x + label_width + value_width + 20, y - font_size // 2), font_size, 0, rl.WHITE)
|
||||
|
||||
return 400
|
||||
rl.draw_text_ex(self._font_bold, element.unit_text, rl.Vector2(start_x + element.label_width + element.val_width, y - font_size // 2),
|
||||
font_size, 0, rl.WHITE)
|
||||
|
||||
@@ -10,12 +10,33 @@ from dataclasses import dataclass
|
||||
from openpilot.common.constants import CV
|
||||
|
||||
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
|
||||
|
||||
@dataclass
|
||||
class UiElement:
|
||||
value: str
|
||||
label: str
|
||||
unit: str
|
||||
color: rl.Color
|
||||
val_text: str = ""
|
||||
label_text: str = ""
|
||||
unit_text: str = ""
|
||||
val_width: float = 0.0
|
||||
label_width: float = 0.0
|
||||
unit_width: float = 0.0
|
||||
total_width: float = 0.0
|
||||
|
||||
def measure(self, font, font_size: int):
|
||||
self.label_text = f"{self.label} "
|
||||
self.val_text = self.value
|
||||
self.unit_text = f" {self.unit}" if self.unit else ""
|
||||
|
||||
self.label_width = measure_text_cached(font, self.label_text, font_size, 0).x
|
||||
self.val_width = measure_text_cached(font, self.val_text, font_size, 0).x
|
||||
self.unit_width = measure_text_cached(font, self.unit_text, font_size, 0).x if self.unit else 0
|
||||
|
||||
self.total_width = self.label_width + self.val_width + self.unit_width
|
||||
|
||||
|
||||
class LeadInfoElement:
|
||||
|
||||
49
selfdrive/ui/sunnypilot/onroad/driver_state.py
Normal file
49
selfdrive/ui/sunnypilot/onroad/driver_state.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from openpilot.selfdrive.ui import UI_BORDER_SIZE
|
||||
from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer, BTN_SIZE, ARC_LENGTH
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.developer_ui import DeveloperUiRenderer
|
||||
|
||||
|
||||
class DriverStateRendererSP(DriverStateRenderer):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.dev_ui_offset = DeveloperUiRenderer.get_bottom_dev_ui_offset()
|
||||
|
||||
def _pre_calculate_drawing_elements(self):
|
||||
"""Pre-calculate all drawing elements based on the current rectangle"""
|
||||
# Calculate icon position (bottom-left or bottom-right)
|
||||
width, height = self._rect.width, self._rect.height
|
||||
offset = UI_BORDER_SIZE + BTN_SIZE // 2
|
||||
self.position_x = self._rect.x + (width - offset if self.is_rhd else offset)
|
||||
self.position_y = self._rect.y + height - offset - self.dev_ui_offset
|
||||
|
||||
# Pre-calculate the face lines positions
|
||||
positioned_keypoints = self.face_keypoints_transformed + np.array([self.position_x, self.position_y])
|
||||
for i in range(len(positioned_keypoints)):
|
||||
self.face_lines[i].x = positioned_keypoints[i][0]
|
||||
self.face_lines[i].y = positioned_keypoints[i][1]
|
||||
|
||||
# Calculate arc dimensions based on head rotation
|
||||
delta_x = -self.driver_pose_sins[1] * ARC_LENGTH / 2.0 # Horizontal movement
|
||||
delta_y = -self.driver_pose_sins[0] * ARC_LENGTH / 2.0 # Vertical movement
|
||||
|
||||
# Horizontal arc
|
||||
h_width = abs(delta_x)
|
||||
self.h_arc_data = self._calculate_arc_data(
|
||||
delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2,
|
||||
self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True
|
||||
)
|
||||
|
||||
# Vertical arc
|
||||
v_height = abs(delta_y)
|
||||
self.v_arc_data = self._calculate_arc_data(
|
||||
delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y,
|
||||
self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False
|
||||
)
|
||||
14
selfdrive/ui/sunnypilot/onroad/model_renderer.py
Normal file
14
selfdrive/ui/sunnypilot/onroad/model_renderer.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
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.onroad.chevron_metrics import ChevronMetrics
|
||||
from openpilot.selfdrive.ui.sunnypilot.onroad.rainbow_path import RainbowPath
|
||||
|
||||
|
||||
class ModelRendererSP:
|
||||
def __init__(self):
|
||||
self.rainbow_path = RainbowPath()
|
||||
self.chevron_metrics = ChevronMetrics()
|
||||
78
selfdrive/ui/sunnypilot/onroad/rainbow_path.py
Normal file
78
selfdrive/ui/sunnypilot/onroad/rainbow_path.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
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 colorsys
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
|
||||
|
||||
|
||||
class RainbowPath:
|
||||
DEFAULT_NUM_SEGMENTS = 8
|
||||
DEFAULT_SPEED = 50.0 # degrees per second
|
||||
DEFAULT_SATURATION = 0.9
|
||||
DEFAULT_LIGHTNESS = 0.6
|
||||
BASE_ALPHA = 0.8
|
||||
ALPHA_FADE = 0.3 # Alpha reduction from bottom to top
|
||||
|
||||
def __init__(self, num_segments: int = None, speed: float = None, saturation: float = None, lightness: float = None):
|
||||
self.num_segments = num_segments if num_segments is not None else self.DEFAULT_NUM_SEGMENTS
|
||||
self.speed = speed if speed is not None else self.DEFAULT_SPEED
|
||||
self.saturation = saturation if saturation is not None else self.DEFAULT_SATURATION
|
||||
self.lightness = lightness if lightness is not None else self.DEFAULT_LIGHTNESS
|
||||
|
||||
def set_speed(self, speed: float):
|
||||
self.speed = speed
|
||||
|
||||
def set_num_segments(self, num_segments: int):
|
||||
self.num_segments = num_segments
|
||||
|
||||
def set_saturation(self, saturation: float):
|
||||
self.saturation = max(0.0, min(1.0, saturation))
|
||||
|
||||
def set_lightness(self, lightness: float):
|
||||
self.lightness = max(0.0, min(1.0, lightness))
|
||||
|
||||
def get_gradient(self) -> Gradient:
|
||||
time_offset = time.monotonic()
|
||||
hue_offset = (time_offset * self.speed) % 360.0
|
||||
|
||||
segment_colors = []
|
||||
gradient_stops = []
|
||||
|
||||
for i in range(self.num_segments):
|
||||
position = i / (self.num_segments - 1)
|
||||
hue = (hue_offset + position * 360.0) % 360.0
|
||||
alpha = self.BASE_ALPHA * (1.0 - position * self.ALPHA_FADE)
|
||||
color = self._hsla_to_color(
|
||||
hue / 360.0,
|
||||
self.saturation,
|
||||
self.lightness,
|
||||
alpha
|
||||
)
|
||||
gradient_stops.append(position)
|
||||
segment_colors.append(color)
|
||||
|
||||
return Gradient(
|
||||
start=(0.0, 1.0), # Bottom of path
|
||||
end=(0.0, 0.0), # Top of path
|
||||
colors=segment_colors,
|
||||
stops=gradient_stops,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _hsla_to_color(h: float, s: float, l: float, a: float) -> rl.Color:
|
||||
rgb = colorsys.hls_to_rgb(h, l, s)
|
||||
return rl.Color(
|
||||
int(rgb[0] * 255),
|
||||
int(rgb[1] * 255),
|
||||
int(rgb[2] * 255),
|
||||
int(a * 255)
|
||||
)
|
||||
|
||||
def draw_rainbow_path(self, rect, path):
|
||||
gradient = self.get_gradient()
|
||||
draw_polygon(rect, path.projected_points, gradient=gradient)
|
||||
41
selfdrive/ui/sunnypilot/ui_helpers.py
Normal file
41
selfdrive/ui/sunnypilot/ui_helpers.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
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)
|
||||
@@ -4,23 +4,95 @@ 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 messaging, custom
|
||||
from openpilot.common.params import Params
|
||||
from cereal import messaging, log, custom
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
from openpilot.sunnypilot.common.params import Params
|
||||
from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState
|
||||
from openpilot.selfdrive.ui.sunnypilot.ui_helpers import sync_layout_params
|
||||
|
||||
OpenpilotState = log.SelfdriveState.OpenpilotState
|
||||
MADSState = custom.ModularAssistiveDrivingSystem.ModularAssistiveDrivingSystemState
|
||||
|
||||
|
||||
class UIStateSP:
|
||||
def __init__(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()
|
||||
|
||||
def set_active_layout(self, layout):
|
||||
self.active_layout = layout
|
||||
if layout:
|
||||
sync_layout_params(layout, None, self.params)
|
||||
|
||||
def on_param_change(self, param_name):
|
||||
self.changed_params.add(param_name)
|
||||
|
||||
self.custom_interactive_timeout: int = self.params.get("InteractivityTimeout", return_default=True)
|
||||
self.global_brightness_override: int = self.params.get("Brightness", return_default=True)
|
||||
|
||||
def update(self) -> None:
|
||||
self.sunnylink_state.start()
|
||||
if self.sunnylink_enabled:
|
||||
self.sunnylink_state.start()
|
||||
else:
|
||||
self.sunnylink_state.stop()
|
||||
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
def update_status(ss, ss_sp, onroad_evt) -> str:
|
||||
state = ss.state
|
||||
mads = ss_sp.mads
|
||||
mads_state = mads.state
|
||||
|
||||
if state == OpenpilotState.preEnabled:
|
||||
return "override"
|
||||
|
||||
if state == OpenpilotState.overriding:
|
||||
if not mads.available:
|
||||
return "override"
|
||||
|
||||
if any(e.overrideLongitudinal for e in onroad_evt):
|
||||
return "override"
|
||||
|
||||
if mads_state in (MADSState.paused, MADSState.overriding):
|
||||
return "override"
|
||||
|
||||
# MADS specific statuses
|
||||
if not mads.available:
|
||||
return "engaged" if ss.enabled else "disengaged"
|
||||
|
||||
if not mads.enabled and not ss.enabled:
|
||||
return "disengaged"
|
||||
|
||||
if mads.enabled and ss.enabled:
|
||||
return "engaged"
|
||||
|
||||
if mads.enabled:
|
||||
return "lat_only"
|
||||
|
||||
if ss.enabled:
|
||||
return "long_only"
|
||||
|
||||
return "disengaged"
|
||||
|
||||
def update_params(self) -> None:
|
||||
CP_SP_bytes = self.params.get("CarParamsSPPersistent")
|
||||
@@ -28,3 +100,17 @@ class UIStateSP:
|
||||
self.CP_SP = messaging.log_from_bytes(CP_SP_bytes, custom.CarParamsSP)
|
||||
self.sunnylink_enabled = self.params.get_bool("SunnylinkEnabled")
|
||||
self.developer_ui = self.params.get("DevUIInfo")
|
||||
self.rainbow_path = self.params.get_bool("RainbowMode")
|
||||
self.chevron_metrics = self.params.get("ChevronInfo")
|
||||
self.active_bundle = self.params.get("ModelManager_ActiveBundle")
|
||||
self.custom_interactive_timeout = self.params.get("InteractivityTimeout", return_default=True)
|
||||
self.global_brightness_override = self.params.get("Brightness", return_default=True)
|
||||
|
||||
|
||||
class DeviceSP:
|
||||
def __init__(self):
|
||||
self._params = Params()
|
||||
|
||||
def _set_awake(self, on: bool):
|
||||
if on and self._params.get("DeviceBootMode", return_default=True) == 1:
|
||||
self._params.put_bool("OffroadMode", True)
|
||||
|
||||
@@ -13,7 +13,7 @@ if "RECORD_OUTPUT" not in os.environ:
|
||||
os.environ["RECORD_OUTPUT"] = os.path.join(DIFF_OUT_DIR, os.environ["RECORD_OUTPUT"])
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.version import terms_version, training_version
|
||||
from openpilot.system.version import terms_version, training_version, terms_version_sp, sunnylink_consent_version
|
||||
from openpilot.system.ui.lib.application import gui_app, MousePos, MouseEvent
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
|
||||
@@ -45,6 +45,8 @@ def setup_state():
|
||||
params.put("CompletedTrainingVersion", training_version)
|
||||
params.put("DongleId", "test123456789")
|
||||
params.put("UpdaterCurrentDescription", "0.10.1 / test-branch / abc1234 / Nov 30")
|
||||
params.put("HasAcceptedTermsSP", terms_version_sp)
|
||||
params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from openpilot.common.prefix import OpenpilotPrefix
|
||||
from openpilot.selfdrive.test.helpers import with_processes
|
||||
from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
|
||||
from openpilot.system.updated.updated import parse_release_notes
|
||||
from openpilot.system.version import terms_version, training_version
|
||||
from openpilot.system.version import terms_version, training_version, terms_version_sp, sunnylink_consent_version
|
||||
|
||||
AlertSize = log.SelfdriveState.AlertSize
|
||||
AlertStatus = log.SelfdriveState.AlertStatus
|
||||
@@ -378,6 +378,8 @@ def create_screenshots():
|
||||
# Set terms and training version (to skip onboarding)
|
||||
params.put("HasAcceptedTerms", terms_version)
|
||||
params.put("CompletedTrainingVersion", training_version)
|
||||
params.put("HasAcceptedTermsSP", terms_version_sp)
|
||||
params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version)
|
||||
|
||||
if name == "homescreen_paired":
|
||||
params.put("PrimeType", 0) # NONE
|
||||
|
||||
@@ -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
|
||||
from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP, DeviceSP
|
||||
|
||||
BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50
|
||||
|
||||
@@ -21,6 +20,8 @@ class UIStatus(Enum):
|
||||
DISENGAGED = "disengaged"
|
||||
ENGAGED = "engaged"
|
||||
OVERRIDE = "override"
|
||||
LAT_ONLY = "lat_only"
|
||||
LONG_ONLY = "long_only"
|
||||
|
||||
|
||||
class UIState(UIStateSP):
|
||||
@@ -34,7 +35,6 @@ class UIState(UIStateSP):
|
||||
|
||||
def _initialize(self):
|
||||
UIStateSP.__init__(self)
|
||||
self.params = Params()
|
||||
self.sm = messaging.SubMaster(
|
||||
[
|
||||
"modelV2",
|
||||
@@ -98,7 +98,7 @@ class UIState(UIStateSP):
|
||||
|
||||
@property
|
||||
def engaged(self) -> bool:
|
||||
return self.started and self.sm["selfdriveState"].enabled
|
||||
return self.started and (self.sm["selfdriveState"].enabled or self.sm["selfdriveStateSP"].mads.enabled)
|
||||
|
||||
def is_onroad(self) -> bool:
|
||||
return self.started
|
||||
@@ -156,6 +156,8 @@ class UIState(UIStateSP):
|
||||
else:
|
||||
self.status = UIStatus.ENGAGED if ss.enabled else UIStatus.DISENGAGED
|
||||
|
||||
self.status = UIStatus(UIStateSP.update_status(ss, self.sm["selfdriveStateSP"], self.sm["onroadEvents"]))
|
||||
|
||||
# Check for engagement state changes
|
||||
if self.engaged != self._engaged_prev:
|
||||
for callback in self._engaged_transition_callbacks:
|
||||
@@ -188,8 +190,9 @@ class UIState(UIStateSP):
|
||||
self._param_update_time = time.monotonic()
|
||||
|
||||
|
||||
class Device:
|
||||
class Device(DeviceSP):
|
||||
def __init__(self):
|
||||
DeviceSP.__init__(self)
|
||||
self._ignition = False
|
||||
self._interaction_time: float = -1
|
||||
self._override_interactive_timeout: int | None = None
|
||||
@@ -216,6 +219,9 @@ class Device:
|
||||
if self._override_interactive_timeout is not None:
|
||||
return self._override_interactive_timeout
|
||||
|
||||
if gui_app.sunnypilot_ui() and ui_state.custom_interactive_timeout != 0:
|
||||
return ui_state.custom_interactive_timeout
|
||||
|
||||
ignition_timeout = 10 if gui_app.big_ui() else 5
|
||||
return ignition_timeout if ui_state.ignition else 30
|
||||
|
||||
@@ -250,9 +256,18 @@ class Device:
|
||||
else:
|
||||
clipped_brightness = ((clipped_brightness + 16.0) / 116.0) ** 3.0
|
||||
|
||||
clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [30, 100]))
|
||||
if gui_app.sunnypilot_ui():
|
||||
if ui_state.global_brightness_override <= 0:
|
||||
min_global_brightness = 1 if ui_state.global_brightness_override < 0 else 30
|
||||
clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [min_global_brightness, 100]))
|
||||
else:
|
||||
clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [30, 100]))
|
||||
|
||||
brightness = round(self._brightness_filter.update(clipped_brightness))
|
||||
|
||||
if gui_app.sunnypilot_ui() and ui_state.global_brightness_override > 0:
|
||||
brightness = ui_state.global_brightness_override
|
||||
|
||||
if not self._awake:
|
||||
brightness = 0
|
||||
|
||||
@@ -280,6 +295,7 @@ class Device:
|
||||
|
||||
def _set_awake(self, on: bool):
|
||||
if on != self._awake:
|
||||
DeviceSP._set_awake(self, on)
|
||||
self._awake = on
|
||||
cloudlog.debug(f"setting display power {int(on)}")
|
||||
HARDWARE.set_display_power(on)
|
||||
|
||||
129
sunnypilot/common/README.md
Normal file
129
sunnypilot/common/README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
<center>
|
||||
|
||||
# Comparative Analysis of Parameter Getter Methods: Params vs. ParamWatcher
|
||||
|
||||
James (sunnypilot Developer) <br>
|
||||
December 13, 2025
|
||||
|
||||
</center>
|
||||
|
||||
|
||||
## <br><br> Abstract
|
||||
|
||||
This research report examines the inefficiencies in standard parameter access methods within sunnypilot and proposes an optimized alternative, ParamWatcher. The standard `Params::get()` method incurs significant CPU and memory overhead due to repeated file I/O operations (sunnypilot, 2025). ParamWatcher utilizes OS-level file system events (inotify on Linux, FSEvents on macOS) to maintain an in-memory cache, reducing I/O to near zero (Linux man-pages, 2025-c; Apple Inc., n.d.-a). Empirical benchmarks with ~10 million parameter accesses demonstrate a 14.5x CPU speedup and flat memory usage (514.7 KB vs. 497.7 KB for base Params, with only 17 KB overhead). The implementation employs a process-local singleton pattern for efficiency in multi-process architectures (Gamma et al., 1994). Results indicate ParamWatcher eliminates UI stutters and GC pauses, enhancing system responsiveness without compromising data freshness.
|
||||
|
||||
**Keywords:** parameter access, file I/O optimization, event-driven caching, autonomous driving systems, performance benchmarking
|
||||
|
||||
## Introduction
|
||||
|
||||
In sunnypilot, efficient parameter management is important for real-time system access. The standard `Params::get()` method, implemented in C++ and wrapped in Python, performs full file I/O cycles for each access, leading to high CPU overhead and memory churn (sunnypilot, 2025). This is particularly problematic in UI loops where parameters are queried frequently (e.g., 50 toggles at 20 FPS equates to ~1,000 reads/second).
|
||||
|
||||
This inefficiency stems from architectural mismatches: C++ streams are designed for throughput, not latency (cppreference.com, n.d.-a). Each call triggers kernel mode switches, heap allocations, and garbage collection in Python, causing UI stutters (Linux man-pages, 2025-a; Linux man-pages, 2025-b).
|
||||
|
||||
### 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 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.-b).
|
||||
|
||||
This report introduces ParamWatcher, an event-driven caching solution using OS file system events. It shifts from polling to notifications, caching converted values in static RAM. I propose that ParamWatcher achieves minimum 10x+ CPU gains with bounded non increasing memory, improving sunnypilot's performance both on latency and responsiveness.
|
||||
|
||||
## Method
|
||||
|
||||
### Materials
|
||||
- **System:** sunnypilot, running on macOS, Ubuntu/Linux, comma 3x, and comma four.
|
||||
- **Parameters:** 231 defined keys in `param_keys.h`.
|
||||
- **Tools:** Python 3, tracemalloc for memory profiling, time.perf_counter for CPU timing, ctypes for OS integration (Python Software Foundation, 2025).
|
||||
|
||||
### Implementation Details
|
||||
ParamWatcher provides cross-platform file system monitoring using ctypes for direct OS integration (Python Software Foundation, 2025).
|
||||
|
||||
#### Linux Implementation
|
||||
On Linux, ParamWatcher uses the inotify subsystem for efficient file change detection (Linux man-pages, 2025-c). It loads `libc.so.6` to access system calls, initializes an inotify instance, and watches the parameters directory for events like `IN_MODIFY` and `IN_CLOSE_WRITE` (Linux Kernel Organization, 2005). Events are polled with `select.epoll()` and parsed using `struct.unpack_from()` to avoid ctypes overhead. Filenames are extracted and passed to cache invalidation, ensuring real-time updates without polling (Codezup, 2025).
|
||||
|
||||
- **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, 2048)` 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
|
||||
On macOS, ParamWatcher leverages FSEvents from CoreServices for directory monitoring (Apple Inc., n.d.-a). It defines a C-compatible callback using `CFUNCTYPE`, creates an `FSEventStream` with `kFSEventStreamCreateFlagFileEvents`, and schedules it on the run loop (Apple Inc., n.d.-b). Events are filtered for modifications, creations, and renames (Apple Inc., n.d.-c), triggering cache invalidation for affected parameters.
|
||||
|
||||
- **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.
|
||||
|
||||
### Procedure
|
||||
Benchmarks simulated heavy load:
|
||||
- **Memory Test:** ~10 million gets (43,290 loops over 231 keys), measured with tracemalloc.
|
||||
- **CPU Test:** Same load, timed with perf_counter.
|
||||
- Comparisons: Base Params vs. ParamWatcher.
|
||||
|
||||
## Results
|
||||
|
||||
### Memory Usage
|
||||
Using tracemalloc for peak memory measurement during ~10 million parameter accesses (43,290 loops over 231 keys), base Params peaked at 497.7 KB, while ParamWatcher peaked at 514.7 KB (17 KB overhead). ParamWatcher's memory remained flat post-initialization, preventing churn.
|
||||
|
||||
| Condition | Memory (KB) | Overhead |
|
||||
|-----------|-------------|----------|
|
||||
| Base Params | 497.7 | - |
|
||||
| ParamWatcher | 514.7 | 17 KB |
|
||||
### CPU Performance
|
||||
ParamWatcher was 14.5x faster: 4.52s vs. 65.43s to complete ~10 million param gets.
|
||||
|
||||
| Condition | Time (s) | Speedup |
|
||||
|-----------|----------|---------|
|
||||
| Base Params | 65.43 | 1x |
|
||||
| ParamWatcher | 4.52 | 14.5x |
|
||||
|
||||
### Scalability
|
||||
No degradation at scale; cache invalidation maintained freshness.
|
||||
|
||||
## Discussion
|
||||
|
||||
ParamWatcher successfully optimizes parameter access, delivering substantial CPU gains with minimal memory overhead. The event-driven approach eliminates I/O bottlenecks, reducing GC pressure and UI stutters (cppreference.com, n.d.-b). The 17 KB memory overhead is negligible compared to the megabytes of churn from base Params, ensuring bounded usage in multi-process environments via the singleton pattern (Gamma et al., 1994).
|
||||
|
||||
Results demonstrate scalability without degradation, with cache invalidation maintaining data freshness. This optimization enhances system responsiveness.
|
||||
Limitations include potential event latency in high-load scenarios (<10 ms, imperceptible for UI) and increased complexity from background threads.
|
||||
Trade-offs: Static RAM (~17 KB) vs. dynamic churn; benefits outweigh costs for param-heavy workloads.
|
||||
|
||||
## <br>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
|
||||
|
||||
Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). *Design Patterns: Elements of Reusable Object-Oriented Software*. Addison-Wesley.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
Python Software Foundation. (2025). *ctypes — A foreign function library for Python*. Retrieved from https://docs.python.org/3/library/ctypes.html
|
||||
|
||||
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
|
||||
0
sunnypilot/common/__init__.py
Normal file
0
sunnypilot/common/__init__.py
Normal file
193
sunnypilot/common/param_watcher.py
Normal file
193
sunnypilot/common/param_watcher.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
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
sunnypilot/common/params.py
Normal file
4
sunnypilot/common/params.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from openpilot.common.params_pyx import ParamKeyFlag, ParamKeyType, UnknownKeyName
|
||||
from openpilot.sunnypilot.common.param_watcher import ParamWatcher as Params
|
||||
|
||||
__all__ = ["Params", "ParamKeyFlag", "ParamKeyType", "UnknownKeyName"]
|
||||
0
sunnypilot/common/tests/__init__.py
Normal file
0
sunnypilot/common/tests/__init__.py
Normal file
94
sunnypilot/common/tests/test_param_watcher.py
Normal file
94
sunnypilot/common/tests/test_param_watcher.py
Normal file
@@ -0,0 +1,94 @@
|
||||
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"
|
||||
@@ -24,6 +24,7 @@ from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
|
||||
from openpilot.sunnypilot.modeld.runners import ModelRunner, Runtime
|
||||
from openpilot.sunnypilot.modeld.parse_model_outputs import Parser
|
||||
from openpilot.sunnypilot.modeld.fill_model_msg import fill_model_msg, fill_pose_msg, PublishState
|
||||
from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper
|
||||
from openpilot.sunnypilot.modeld.constants import ModelConstants, Plan
|
||||
from openpilot.sunnypilot.models.helpers import get_active_bundle, get_model_path, load_metadata, prepare_inputs, load_meta_constants
|
||||
from openpilot.sunnypilot.modeld.models.commonmodel_pyx import ModelFrame, CLContext
|
||||
@@ -195,6 +196,7 @@ def main(demo=False):
|
||||
buf_main, buf_extra = None, None
|
||||
meta_main = FrameMeta()
|
||||
meta_extra = FrameMeta()
|
||||
camera_offset_helper = CameraOffsetHelper()
|
||||
|
||||
|
||||
if demo:
|
||||
@@ -250,12 +252,14 @@ def main(demo=False):
|
||||
frame_id = sm["roadCameraState"].frameId
|
||||
if sm.frame % 60 == 0:
|
||||
model.lat_delay = get_lat_delay(params, sm["liveDelay"].lateralDelay)
|
||||
camera_offset_helper.set_offset(params.get("CameraOffset", return_default=True))
|
||||
lat_delay = model.lat_delay + model.LAT_SMOOTH_SECONDS
|
||||
if sm.updated["liveCalibration"] and sm.seen['roadCameraState'] and sm.seen['deviceState']:
|
||||
device_from_calib_euler = np.array(sm["liveCalibration"].rpyCalib, dtype=np.float32)
|
||||
dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))]
|
||||
model_transform_main = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics if main_wide_camera else dc.fcam.intrinsics, False).astype(np.float32)
|
||||
model_transform_extra = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics, True).astype(np.float32)
|
||||
model_transform_main, model_transform_extra = camera_offset_helper.update(model_transform_main, model_transform_extra, sm, main_wide_camera)
|
||||
live_calib_seen = True
|
||||
|
||||
traffic_convention = np.zeros(2)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import glob
|
||||
|
||||
Import('env', 'envCython', 'arch', 'cereal', 'messaging', 'common', 'visionipc', 'transformations')
|
||||
@@ -28,3 +29,38 @@ for pathdef, fn in {'TRANSFORM': 'transforms/transform.cl', 'LOADYUV': 'transfor
|
||||
cython_libs = envCython["LIBS"] + libs
|
||||
commonmodel_lib = lenv.Library('commonmodel', common_src)
|
||||
lenvCython.Program('models/commonmodel_pyx.so', 'models/commonmodel_pyx.pyx', LIBS=[commonmodel_lib, *cython_libs], FRAMEWORKS=frameworks)
|
||||
tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=env.Dir("#").abspath) if 'pycache' not in x]
|
||||
|
||||
# Get model metadata
|
||||
PC = not os.path.isfile('/TICI')
|
||||
if PC:
|
||||
inputs = tinygrad_files + [File(Dir("#sunnypilot/modeld_v2").File("install_models_pc.py").abspath)]
|
||||
outputs = []
|
||||
model_dir = Dir("models").abspath
|
||||
cmd = f'python3 {Dir("#sunnypilot/modeld_v2").abspath}/install_models_pc.py {model_dir}'
|
||||
|
||||
for model_name in ['supercombo', 'driving_vision', 'driving_policy']:
|
||||
if File(f"models/{model_name}.onnx").exists():
|
||||
inputs.append(File(f"models/{model_name}.onnx"))
|
||||
inputs.append(File(f"models/{model_name}_tinygrad.pkl"))
|
||||
outputs.append(File(f"models/{model_name}_metadata.pkl"))
|
||||
if outputs:
|
||||
lenv.Command(outputs, inputs, cmd)
|
||||
|
||||
def tg_compile(flags, model_name):
|
||||
pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + '"'
|
||||
fn = File(f"models/{model_name}").abspath
|
||||
return lenv.Command(
|
||||
fn + "_tinygrad.pkl",
|
||||
[fn + ".onnx"] + tinygrad_files,
|
||||
f'{pythonpath_string} {flags} python3 {Dir("#tinygrad_repo").abspath}/examples/openpilot/compile3.py {fn}.onnx {fn}_tinygrad.pkl'
|
||||
)
|
||||
|
||||
# Compile small models
|
||||
for model_name in ['supercombo', 'driving_vision', 'driving_policy']:
|
||||
if File(f"models/{model_name}.onnx").exists():
|
||||
flags = {
|
||||
'larch64': 'DEV=QCOM',
|
||||
'Darwin': f'DEV=CPU HOME={os.path.expanduser("~")} IMAGE=0', # tinygrad calls brew which needs a $HOME in the env
|
||||
}.get(arch, 'DEV=CPU CPU_LLVM=1 IMAGE=0')
|
||||
tg_compile(flags, model_name)
|
||||
|
||||
39
sunnypilot/modeld_v2/camera_offset_helper.py
Normal file
39
sunnypilot/modeld_v2/camera_offset_helper.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.transformations.camera import DEVICE_CAMERAS
|
||||
|
||||
|
||||
class CameraOffsetHelper:
|
||||
def __init__(self):
|
||||
self.camera_offset = 0.0
|
||||
self.actual_camera_offset = 0.0
|
||||
|
||||
@staticmethod
|
||||
def apply_camera_offset(model_transform, intrinsics, height, offset_param):
|
||||
cy = intrinsics[1, 2]
|
||||
shear = np.eye(3, dtype=np.float32)
|
||||
shear[0, 1] = offset_param / height
|
||||
shear[0, 2] = -offset_param / height * cy
|
||||
model_transform = (shear @ model_transform).astype(np.float32)
|
||||
return model_transform
|
||||
|
||||
def set_offset(self, offset):
|
||||
self.camera_offset = offset
|
||||
|
||||
def update(self, model_transform_main, model_transform_extra, sm, main_wide_camera):
|
||||
self.actual_camera_offset = (0.9 * self.actual_camera_offset) + (0.1 * self.camera_offset)
|
||||
dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))]
|
||||
height = sm["liveCalibration"].height[0] if sm['liveCalibration'].height else 1.22
|
||||
|
||||
intrinsics_main = dc.ecam.intrinsics if main_wide_camera else dc.fcam.intrinsics
|
||||
model_transform_main = self.apply_camera_offset(model_transform_main, intrinsics_main, height, self.actual_camera_offset)
|
||||
|
||||
intrinsics_extra = dc.ecam.intrinsics
|
||||
model_transform_extra = self.apply_camera_offset(model_transform_extra, intrinsics_extra, height, self.actual_camera_offset)
|
||||
return model_transform_main, model_transform_extra
|
||||
89
sunnypilot/modeld_v2/install_models_pc.py
Executable file
89
sunnypilot/modeld_v2/install_models_pc.py
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import shutil
|
||||
import pickle
|
||||
import codecs
|
||||
import onnx
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.system.hardware.hw import Paths
|
||||
|
||||
|
||||
def get_name_and_shape(value_info):
|
||||
shape = tuple([int(dim.dim_value) for dim in value_info.type.tensor_type.shape.dim])
|
||||
return value_info.name, shape
|
||||
|
||||
|
||||
def get_metadata_value_by_name(model, name):
|
||||
for prop in model.metadata_props:
|
||||
if prop.key == name:
|
||||
return prop.value
|
||||
return None
|
||||
|
||||
|
||||
def generate_metadata_pkl(model_path, output_path):
|
||||
try:
|
||||
model = onnx.load(str(model_path))
|
||||
output_slices = get_metadata_value_by_name(model, 'output_slices')
|
||||
|
||||
if output_slices:
|
||||
metadata = {
|
||||
'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'),
|
||||
'output_slices': pickle.loads(codecs.decode(output_slices.encode(), "base64")),
|
||||
'input_shapes': dict([get_name_and_shape(x) for x in model.graph.input]),
|
||||
'output_shapes': dict([get_name_and_shape(x) for x in model.graph.output])
|
||||
}
|
||||
with open(output_path, 'wb') as f:
|
||||
pickle.dump(metadata, f)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def install_models(model_dir):
|
||||
model_dir = Path(model_dir)
|
||||
models = ["driving_policy", "driving_vision"]
|
||||
found_models = []
|
||||
|
||||
for model in models:
|
||||
if (model_dir / f"{model}.onnx").exists():
|
||||
found_models.append(model)
|
||||
|
||||
if not found_models:
|
||||
return
|
||||
|
||||
try:
|
||||
custom_name = input(f"Found models ({', '.join(found_models)}). Enter model short name (e.g. wmiv4): ").strip()
|
||||
except EOFError:
|
||||
return
|
||||
|
||||
if not custom_name:
|
||||
print("No name provided, skipping installation.")
|
||||
return
|
||||
|
||||
dest_dir = Path(Paths.model_root())
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for model in found_models:
|
||||
onnx_path = model_dir / f"{model}.onnx"
|
||||
tinygrad_pkl = model_dir / f"{model}_tinygrad.pkl"
|
||||
metadata_pkl = model_dir / f"{model}_metadata.pkl"
|
||||
|
||||
if not metadata_pkl.exists():
|
||||
generate_metadata_pkl(onnx_path, metadata_pkl)
|
||||
|
||||
dest_tinygrad = dest_dir / f"{model}_{custom_name}_tinygrad.pkl"
|
||||
dest_metadata = dest_dir / f"{model}_{custom_name}_metadata.pkl"
|
||||
|
||||
if tinygrad_pkl.exists():
|
||||
shutil.move(str(tinygrad_pkl), str(dest_tinygrad))
|
||||
if metadata_pkl.exists():
|
||||
shutil.move(str(metadata_pkl), str(dest_metadata))
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: install_models_pc.py <model_dir>")
|
||||
sys.exit(1)
|
||||
install_models(sys.argv[1])
|
||||
@@ -21,6 +21,7 @@ from openpilot.sunnypilot.modeld_v2.fill_model_msg import fill_model_msg, fill_p
|
||||
from openpilot.sunnypilot.modeld_v2.constants import Plan
|
||||
from openpilot.sunnypilot.modeld_v2.models.commonmodel_pyx import DrivingModelFrame, CLContext
|
||||
from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants
|
||||
from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper
|
||||
|
||||
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
|
||||
from openpilot.sunnypilot.modeld.modeld_base import ModelStateBase
|
||||
@@ -28,7 +29,6 @@ from openpilot.sunnypilot.models.helpers import get_active_bundle
|
||||
from openpilot.sunnypilot.models.runners.helpers import get_model_runner
|
||||
|
||||
PROCESS_NAME = "selfdrive.modeld.modeld_tinygrad"
|
||||
RECOVERY_POWER = 1.0 # The higher this number the more aggressively the model will recover to lanecenter, too high and it will ping-pong
|
||||
|
||||
|
||||
class FrameMeta:
|
||||
@@ -63,6 +63,7 @@ class ModelState(ModelStateBase):
|
||||
self.LAT_SMOOTH_SECONDS = float(overrides.get('lat', ".0"))
|
||||
self.LONG_SMOOTH_SECONDS = float(overrides.get('long', ".0"))
|
||||
self.MIN_LAT_CONTROL_SPEED = 0.3
|
||||
self.PLANPLUS_CONTROL: float = 1.0
|
||||
|
||||
buffer_length = 5 if self.model_runner.is_20hz else 2
|
||||
self.frames = {name: DrivingModelFrame(context, buffer_length) for name in self.model_runner.vision_input_names}
|
||||
@@ -158,7 +159,8 @@ class ModelState(ModelStateBase):
|
||||
lat_action_t: float, long_action_t: float, v_ego: float) -> log.ModelDataV2.Action:
|
||||
plan = model_output['plan'][0]
|
||||
if 'planplus' in model_output:
|
||||
plan = plan + RECOVERY_POWER*model_output['planplus'][0]
|
||||
recovery_power = self.PLANPLUS_CONTROL * (0.75 if v_ego > 20.0 else 1.0)
|
||||
plan = plan + recovery_power * model_output['planplus'][0]
|
||||
desired_accel, should_stop = get_accel_from_plan(plan[:, Plan.VELOCITY][:, 0], plan[:, Plan.ACCELERATION][:, 0], self.constants.T_IDXS,
|
||||
action_t=long_action_t)
|
||||
desired_accel = smooth_value(desired_accel, prev_action.desiredAcceleration, self.LONG_SMOOTH_SECONDS)
|
||||
@@ -229,6 +231,7 @@ def main(demo=False):
|
||||
buf_main, buf_extra = None, None
|
||||
meta_main = FrameMeta()
|
||||
meta_extra = FrameMeta()
|
||||
camera_offset_helper = CameraOffsetHelper()
|
||||
|
||||
|
||||
if demo:
|
||||
@@ -283,13 +286,15 @@ def main(demo=False):
|
||||
v_ego = max(sm["carState"].vEgo, 0.)
|
||||
if sm.frame % 60 == 0:
|
||||
model.lat_delay = get_lat_delay(params, sm["liveDelay"].lateralDelay)
|
||||
model.PLANPLUS_CONTROL = params.get("PlanplusControl", return_default=True)
|
||||
camera_offset_helper.set_offset(params.get("CameraOffset", return_default=True))
|
||||
lat_delay = model.lat_delay + model.LAT_SMOOTH_SECONDS
|
||||
if sm.updated["liveCalibration"] and sm.seen['roadCameraState'] and sm.seen['deviceState']:
|
||||
device_from_calib_euler = np.array(sm["liveCalibration"].rpyCalib, dtype=np.float32)
|
||||
dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))]
|
||||
model_transform_main = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics if main_wide_camera else dc.fcam.intrinsics,
|
||||
False).astype(np.float32)
|
||||
model_transform_main = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics if main_wide_camera else dc.fcam.intrinsics, False).astype(np.float32)
|
||||
model_transform_extra = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics, True).astype(np.float32)
|
||||
model_transform_main, model_transform_extra = camera_offset_helper.update(model_transform_main, model_transform_extra, sm, main_wide_camera)
|
||||
live_calib_seen = True
|
||||
|
||||
traffic_convention = np.zeros(2)
|
||||
|
||||
84
sunnypilot/modeld_v2/tests/test_camera_offset_helper.py
Normal file
84
sunnypilot/modeld_v2/tests/test_camera_offset_helper.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.transformations.camera import DEVICE_CAMERAS
|
||||
from openpilot.common.transformations.model import get_warp_matrix
|
||||
from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper
|
||||
|
||||
|
||||
class MockStruct:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
def __getitem__(self, item):
|
||||
return getattr(self, item)
|
||||
|
||||
|
||||
class TestCameraOffset:
|
||||
def setup_method(self):
|
||||
self.camera_offset = CameraOffsetHelper()
|
||||
self.dc = DEVICE_CAMERAS[('mici', 'os04c10')]
|
||||
|
||||
def test_smoothing(self):
|
||||
self.camera_offset.set_offset(0.2)
|
||||
|
||||
sm = MockStruct(
|
||||
deviceState=MockStruct(deviceType='mici'),
|
||||
roadCameraState=MockStruct(sensor='os04c10'),
|
||||
liveCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22])
|
||||
)
|
||||
|
||||
intrinsics_main = self.dc.fcam.intrinsics
|
||||
intrinsics_extra = self.dc.ecam.intrinsics
|
||||
device_from_calib_euler = np.array([0.0, 0.0, 0.0], dtype=np.float32)
|
||||
main_transform = get_warp_matrix(device_from_calib_euler, intrinsics_main, False).astype(np.float32)
|
||||
extra_transform = get_warp_matrix(device_from_calib_euler, intrinsics_extra, True).astype(np.float32)
|
||||
|
||||
self.camera_offset.update(main_transform, extra_transform, sm, False)
|
||||
np.testing.assert_almost_equal(self.camera_offset.actual_camera_offset, 0.02)
|
||||
self.camera_offset.update(main_transform, extra_transform, sm, False)
|
||||
np.testing.assert_almost_equal(self.camera_offset.actual_camera_offset, 0.038)
|
||||
|
||||
def test_camera_offset_(self):
|
||||
intrinsics = self.dc.fcam.intrinsics
|
||||
transform = np.eye(3, dtype=np.float32)
|
||||
height = 1.22
|
||||
offset = 0.1
|
||||
|
||||
cy = intrinsics[1, 2]
|
||||
expected_shear = np.eye(3, dtype=np.float32)
|
||||
expected_shear[0, 1] = offset / height
|
||||
expected_shear[0, 2] = -offset / height * cy
|
||||
|
||||
result = CameraOffsetHelper.apply_camera_offset(transform, intrinsics, height, offset)
|
||||
np.testing.assert_array_almost_equal(result, expected_shear)
|
||||
|
||||
def test_update(self):
|
||||
sm = MockStruct(
|
||||
deviceState=MockStruct(deviceType='mici'),
|
||||
roadCameraState=MockStruct(sensor='os04c10'),
|
||||
liveCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22])
|
||||
)
|
||||
intrinsics_main = self.dc.fcam.intrinsics
|
||||
intrinsics_extra = self.dc.ecam.intrinsics
|
||||
device_from_calib_euler = np.array([0.0, 0.0, 0.0], dtype=np.float32)
|
||||
main_transform = get_warp_matrix(device_from_calib_euler, intrinsics_main, False).astype(np.float32)
|
||||
extra_transform = get_warp_matrix(device_from_calib_euler, intrinsics_extra, True).astype(np.float32)
|
||||
|
||||
self.camera_offset.set_offset(0.0) # test default offset doesn't change transformation
|
||||
main_out, extra_out = self.camera_offset.update(main_transform, extra_transform, sm, False)
|
||||
np.testing.assert_array_equal(main_out, main_transform)
|
||||
np.testing.assert_array_equal(extra_out, extra_transform)
|
||||
|
||||
self.camera_offset.set_offset(0.2) # test valid offset changes transformation
|
||||
main_out, extra_out = self.camera_offset.update(main_transform, extra_transform, sm, False)
|
||||
assert not np.array_equal(main_out, main_transform)
|
||||
assert not np.array_equal(extra_out, extra_transform)
|
||||
assert main_out[0, 1] != 0.0
|
||||
assert main_out[0, 2] != 0.0
|
||||
@@ -1,102 +0,0 @@
|
||||
import numpy as np
|
||||
import random
|
||||
|
||||
import cereal.messaging as messaging
|
||||
from msgq.visionipc import VisionIpcServer, VisionStreamType
|
||||
from opendbc.car.car_helpers import get_demo_car_params
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.transformations.camera import DEVICE_CAMERAS
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.system.manager.process_config import managed_processes
|
||||
from openpilot.selfdrive.test.process_replay.vision_meta import meta_from_camera_state
|
||||
|
||||
CAM = DEVICE_CAMERAS[("tici", "ar0231")].fcam
|
||||
IMG = np.zeros(int(CAM.width*CAM.height*(3/2)), dtype=np.uint8)
|
||||
IMG_BYTES = IMG.flatten().tobytes()
|
||||
|
||||
|
||||
class TestModeld:
|
||||
|
||||
def setup_method(self):
|
||||
self.vipc_server = VisionIpcServer("camerad")
|
||||
self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_ROAD, 40, CAM.width, CAM.height)
|
||||
self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_DRIVER, 40, CAM.width, CAM.height)
|
||||
self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_WIDE_ROAD, 40, CAM.width, CAM.height)
|
||||
self.vipc_server.start_listener()
|
||||
Params().put("CarParams", get_demo_car_params().to_bytes())
|
||||
|
||||
self.sm = messaging.SubMaster(['modelV2', 'cameraOdometry'])
|
||||
self.pm = messaging.PubMaster(['roadCameraState', 'wideRoadCameraState', 'liveCalibration'])
|
||||
|
||||
managed_processes['modeld'].start()
|
||||
self.pm.wait_for_readers_to_update("roadCameraState", 10)
|
||||
|
||||
def teardown_method(self):
|
||||
managed_processes['modeld'].stop()
|
||||
del self.vipc_server
|
||||
|
||||
def _send_frames(self, frame_id, cams=None):
|
||||
if cams is None:
|
||||
cams = ('roadCameraState', 'wideRoadCameraState')
|
||||
|
||||
cs = None
|
||||
for cam in cams:
|
||||
msg = messaging.new_message(cam)
|
||||
cs = getattr(msg, cam)
|
||||
cs.frameId = frame_id
|
||||
cs.timestampSof = int((frame_id * DT_MDL) * 1e9)
|
||||
cs.timestampEof = int(cs.timestampSof + (DT_MDL * 1e9))
|
||||
cam_meta = meta_from_camera_state(cam)
|
||||
|
||||
self.pm.send(msg.which(), msg)
|
||||
self.vipc_server.send(cam_meta.stream, IMG_BYTES, cs.frameId,
|
||||
cs.timestampSof, cs.timestampEof)
|
||||
return cs
|
||||
|
||||
def _wait(self):
|
||||
self.sm.update(5000)
|
||||
if self.sm['modelV2'].frameId != self.sm['cameraOdometry'].frameId:
|
||||
self.sm.update(1000)
|
||||
|
||||
def test_modeld(self):
|
||||
for n in range(1, 500):
|
||||
cs = self._send_frames(n)
|
||||
self._wait()
|
||||
|
||||
mdl = self.sm['modelV2']
|
||||
assert mdl.frameId == n
|
||||
assert mdl.frameIdExtra == n
|
||||
assert mdl.timestampEof == cs.timestampEof
|
||||
assert mdl.frameAge == 0
|
||||
assert mdl.frameDropPerc == 0
|
||||
|
||||
odo = self.sm['cameraOdometry']
|
||||
assert odo.frameId == n
|
||||
assert odo.timestampEof == cs.timestampEof
|
||||
|
||||
def test_dropped_frames(self):
|
||||
"""
|
||||
modeld should only run on consecutive road frames
|
||||
"""
|
||||
frame_id = -1
|
||||
road_frames = list()
|
||||
for n in range(1, 50):
|
||||
if (random.random() < 0.1) and n > 3:
|
||||
cams = random.choice([(), ('wideRoadCameraState', )])
|
||||
self._send_frames(n, cams)
|
||||
else:
|
||||
self._send_frames(n)
|
||||
road_frames.append(n)
|
||||
self._wait()
|
||||
|
||||
if len(road_frames) < 3 or road_frames[-1] - road_frames[-2] == 1:
|
||||
frame_id = road_frames[-1]
|
||||
|
||||
mdl = self.sm['modelV2']
|
||||
odo = self.sm['cameraOdometry']
|
||||
assert mdl.frameId == frame_id
|
||||
assert mdl.frameIdExtra == frame_id
|
||||
assert odo.frameId == frame_id
|
||||
if n != frame_id:
|
||||
assert not self.sm.updated['modelV2']
|
||||
assert not self.sm.updated['cameraOdometry']
|
||||
61
sunnypilot/modeld_v2/tests/test_recovery_power.py
Normal file
61
sunnypilot/modeld_v2/tests/test_recovery_power.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import numpy as np
|
||||
|
||||
from cereal import log
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.constants import Plan
|
||||
from openpilot.sunnypilot.modeld_v2.modeld import ModelState
|
||||
import openpilot.sunnypilot.modeld_v2.modeld as modeld
|
||||
|
||||
|
||||
class MockStruct:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
def test_recovery_power_scaling():
|
||||
state = MockStruct(
|
||||
PLANPLUS_CONTROL=1.0,
|
||||
LONG_SMOOTH_SECONDS=0.3,
|
||||
LAT_SMOOTH_SECONDS=0.1,
|
||||
MIN_LAT_CONTROL_SPEED=0.3,
|
||||
mlsim=True,
|
||||
generation=12,
|
||||
constants=MockStruct(T_IDXS=np.arange(100), DESIRE_LEN=8)
|
||||
)
|
||||
prev_action = log.ModelDataV2.Action()
|
||||
recorded_vel: list = []
|
||||
|
||||
def mock_accel(plan_vel, plan_accel, t_idxs, action_t=0.0):
|
||||
recorded_vel.append(plan_vel.copy())
|
||||
return 0.0, False
|
||||
|
||||
modeld.get_accel_from_plan = mock_accel
|
||||
modeld.get_curvature_from_output = lambda *args: 0.0
|
||||
plan = np.random.rand(1, 100, 15).astype(np.float32)
|
||||
planplus = np.random.rand(1, 100, 15).astype(np.float32)
|
||||
|
||||
model_output: dict = {
|
||||
'plan': plan.copy(),
|
||||
'planplus': planplus.copy()
|
||||
}
|
||||
|
||||
test_cases: list = [
|
||||
# (control, v_ego, expected_factor)
|
||||
(0.55, 20.0, 1.0),
|
||||
(1.0, 25.0, .75),
|
||||
(1.5, 25.1, 0.75),
|
||||
(2.0, 20.0, 1.0),
|
||||
(0.75, 19.0, 1.0),
|
||||
(0.8, 25.1, 0.75),
|
||||
]
|
||||
|
||||
for control, v_ego, factor in test_cases:
|
||||
state.PLANPLUS_CONTROL = control
|
||||
recorded_vel.clear()
|
||||
ModelState.get_action_from_model(state, model_output, prev_action, 0.0, 0.0, v_ego)
|
||||
|
||||
expected_recovery_power = control * factor
|
||||
expected_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + expected_recovery_power * planplus[0, :, Plan.VELOCITY][:, 0]
|
||||
|
||||
np.testing.assert_allclose(recorded_vel[0], expected_plan_vel, rtol=1e-5, atol=1e-6)
|
||||
@@ -2,25 +2,17 @@ from openpilot.sunnypilot.models.helpers import get_active_bundle
|
||||
from openpilot.sunnypilot.models.runners.model_runner import ModelRunner
|
||||
from openpilot.sunnypilot.models.runners.tinygrad.tinygrad_runner import TinygradRunner, TinygradSplitRunner
|
||||
from openpilot.sunnypilot.models.runners.constants import ModelType
|
||||
from openpilot.system.hardware import TICI
|
||||
|
||||
if not TICI:
|
||||
from openpilot.sunnypilot.models.runners.onnx.onnx_runner import ONNXRunner
|
||||
|
||||
def get_model_runner() -> ModelRunner:
|
||||
"""
|
||||
Factory function to create and return the appropriate ModelRunner instance.
|
||||
|
||||
Selects between ONNXRunner (for non-TICI platforms) and TinygradRunner
|
||||
(for TICI platforms), choosing TinygradSplitRunner if separate vision/policy
|
||||
Selects TinygradRunner, choosing TinygradSplitRunner if separate vision/policy
|
||||
models are detected in the active bundle.
|
||||
|
||||
:return: An instance of a ModelRunner subclass (ONNXRunner, TinygradRunner, or TinygradSplitRunner).
|
||||
"""
|
||||
if not TICI:
|
||||
return ONNXRunner()
|
||||
|
||||
# On TICI platforms, use Tinygrad runners
|
||||
bundle = get_active_bundle()
|
||||
if bundle and bundle.models:
|
||||
model_types = {m.type.raw for m in bundle.models}
|
||||
|
||||
BIN
sunnypilot/selfdrive/assets/logo.png
LFS
Normal file
BIN
sunnypilot/selfdrive/assets/logo.png
LFS
Normal file
Binary file not shown.
@@ -114,7 +114,7 @@ def initialize_params(params) -> list[dict[str, Any]]:
|
||||
|
||||
# hyundai
|
||||
keys.extend([
|
||||
"HyundaiLongitudinalTuning"
|
||||
"HyundaiLongitudinalTuning",
|
||||
])
|
||||
|
||||
# subaru
|
||||
@@ -128,4 +128,9 @@ def initialize_params(params) -> list[dict[str, Any]]:
|
||||
"TeslaCoopSteering",
|
||||
])
|
||||
|
||||
# toyota
|
||||
keys.extend([
|
||||
"ToyotaEnforceStockLongitudinal",
|
||||
])
|
||||
|
||||
return [{k: params.get(k, return_default=True)} for k in keys]
|
||||
|
||||
@@ -42,6 +42,16 @@ METADATA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__f
|
||||
|
||||
params = Params()
|
||||
|
||||
# Parameters that should never be remotely modified
|
||||
BLOCKED_PARAMS = {
|
||||
"CompletedSunnylinkConsentVersion",
|
||||
"CompletedTrainingVersion",
|
||||
"GithubUsername", # Could grant SSH access
|
||||
"GithubSshKeys", # Direct SSH key injection
|
||||
"HasAcceptedTerms",
|
||||
"HasAcceptedTermsSP",
|
||||
}
|
||||
|
||||
|
||||
def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None:
|
||||
cloudlog.info("sunnylinkd.handle_long_poll started")
|
||||
@@ -56,7 +66,7 @@ def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None:
|
||||
threading.Thread(target=ws_ping, args=(ws, end_event), name='ws_ping'),
|
||||
threading.Thread(target=ws_queue, args=(end_event,), name='ws_queue'),
|
||||
threading.Thread(target=upload_handler, args=(end_event,), name='upload_handler'),
|
||||
# threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'),
|
||||
threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'),
|
||||
threading.Thread(target=stat_handler, args=(end_event, Paths.stats_sp_root(), True), name='stat_handler'),
|
||||
] + [
|
||||
threading.Thread(target=jsonrpc_handler, args=(end_event, partial(startLocalProxy, end_event),), name=f'worker_{x}')
|
||||
@@ -248,6 +258,11 @@ def getParams(params_keys: list[str], compression: bool = False) -> str | dict[s
|
||||
@dispatcher.add_method
|
||||
def saveParams(params_to_update: dict[str, str], compression: bool = False) -> None:
|
||||
for key, value in params_to_update.items():
|
||||
# disallow modifications to blocked parameters
|
||||
if key in BLOCKED_PARAMS:
|
||||
cloudlog.warning(f"sunnylinkd.saveParams.blocked: Attempted to modify blocked parameter '{key}'")
|
||||
continue
|
||||
|
||||
try:
|
||||
save_param_from_base64_encoded_string(key, value, compression)
|
||||
except Exception as e:
|
||||
|
||||
59
sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py
Normal file
59
sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py
Normal 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.
|
||||
"""
|
||||
from openpilot.sunnypilot.sunnylink.athena import sunnylinkd
|
||||
|
||||
|
||||
class TestSunnylinkdMethods:
|
||||
def setup_method(self):
|
||||
self.saved_params = []
|
||||
|
||||
self.original_save = sunnylinkd.save_param_from_base64_encoded_string
|
||||
|
||||
def mock_save_param(key, value, compression=False):
|
||||
self.saved_params.append((key, value, compression))
|
||||
|
||||
sunnylinkd.save_param_from_base64_encoded_string = mock_save_param
|
||||
|
||||
def teardown_method(self):
|
||||
sunnylinkd.save_param_from_base64_encoded_string = self.original_save
|
||||
|
||||
def test_saveParams_blocked(self):
|
||||
blocked_params = {
|
||||
"GithubUsername": "attacker",
|
||||
"GithubSshKeys": "ssh-rsa attacker_key",
|
||||
}
|
||||
|
||||
sunnylinkd.saveParams(blocked_params)
|
||||
|
||||
assert len(self.saved_params) == 0
|
||||
|
||||
def test_saveParams_allowed(self):
|
||||
allowed_params = {
|
||||
"SpeedLimitOffset": "5",
|
||||
"MyCustomParam": "123"
|
||||
}
|
||||
|
||||
sunnylinkd.saveParams(allowed_params)
|
||||
|
||||
# verify content
|
||||
assert len(self.saved_params) == 2
|
||||
keys_saved = [p[0] for p in self.saved_params]
|
||||
assert "SpeedLimitOffset" in keys_saved
|
||||
assert "MyCustomParam" in keys_saved
|
||||
|
||||
def test_saveParams_mixed(self):
|
||||
mixed_params = {
|
||||
"GithubUsername": "attacker",
|
||||
"SpeedLimitOffset": "10"
|
||||
}
|
||||
|
||||
sunnylinkd.saveParams(mixed_params)
|
||||
|
||||
# should save allowed one
|
||||
assert len(self.saved_params) == 1
|
||||
assert self.saved_params[0][0] == "SpeedLimitOffset"
|
||||
assert self.saved_params[0][1] == "10"
|
||||
@@ -19,7 +19,7 @@ from openpilot.system.version import get_version
|
||||
|
||||
from cereal import messaging, custom
|
||||
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
|
||||
from openpilot.sunnypilot.sunnylink.backups.utils import decrypt_compressed_data, encrypt_compress_data, SnakeCaseEncoder
|
||||
from openpilot.sunnypilot.sunnylink.backups.utils import decrypt_compressed_data, encrypt_compressed_data, SnakeCaseEncoder
|
||||
from openpilot.sunnypilot.sunnylink.utils import get_param_as_byte, save_param_from_base64_encoded_string
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ class BackupManagerSP:
|
||||
|
||||
# Serialize and encrypt config data
|
||||
config_json = json.dumps(config_data)
|
||||
encrypted_config = encrypt_compress_data(config_json, use_aes_256=True)
|
||||
encrypted_config = encrypt_compressed_data(config_json, use_aes_256=True)
|
||||
self._update_progress(50.0, OperationType.BACKUP)
|
||||
|
||||
backup_info = custom.BackupManagerSP.BackupInfo()
|
||||
|
||||
@@ -4,9 +4,9 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import zlib
|
||||
import re
|
||||
import json
|
||||
@@ -14,8 +14,9 @@ from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, ec
|
||||
|
||||
from openpilot.common.api.base import KEYS
|
||||
from openpilot.sunnypilot.sunnylink.backups.AESCipher import AESCipher
|
||||
from openpilot.system.hardware.hw import Paths
|
||||
|
||||
@@ -27,37 +28,43 @@ class KeyDerivation:
|
||||
return f.read()
|
||||
|
||||
@staticmethod
|
||||
def derive_aes_key_iv_from_rsa(key_path: str, use_aes_256: bool) -> tuple[bytes, bytes]:
|
||||
rsa_key_pem: bytes = KeyDerivation._load_key(key_path)
|
||||
key_plain = rsa_key_pem.decode(errors="ignore")
|
||||
def derive_aes_key_iv(key_path: str, use_aes_256: bool) -> tuple[bytes, bytes]:
|
||||
key_pem: bytes = KeyDerivation._load_key(key_path)
|
||||
key_plain = key_pem.decode(errors="ignore")
|
||||
|
||||
if "private" in key_plain.lower():
|
||||
private_key = serialization.load_pem_private_key(rsa_key_pem, password=None, backend=default_backend())
|
||||
if not isinstance(private_key, rsa.RSAPrivateKey):
|
||||
raise ValueError("Invalid RSA key format: Unable to determine if key is public or private.")
|
||||
|
||||
der_data = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
private_key = serialization.load_pem_private_key(key_pem, password=None, backend=default_backend())
|
||||
if isinstance(private_key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey)):
|
||||
public_key = private_key.public_key()
|
||||
else:
|
||||
raise ValueError("Invalid key format: Unable to determine if key is public or private.")
|
||||
elif "public" in key_plain.lower():
|
||||
public_key = serialization.load_pem_public_key(rsa_key_pem, backend=default_backend())
|
||||
if not isinstance(public_key, rsa.RSAPublicKey):
|
||||
raise ValueError("Invalid RSA key format: Unable to determine if key is public or private.")
|
||||
|
||||
der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.PKCS1)
|
||||
public_key = serialization.load_pem_public_key(key_pem, backend=default_backend()) # type: ignore[assignment]
|
||||
if not isinstance(public_key, (rsa.RSAPublicKey, ec.EllipticCurvePublicKey)):
|
||||
raise ValueError("Invalid key format: Unable to determine if key is public or private.")
|
||||
else:
|
||||
raise ValueError("Unknown key format: Unable to determine if key is public or private.")
|
||||
raise ValueError("Invalid key format: Unable to determine if key is public or private.")
|
||||
|
||||
sha256_hash = hashlib.sha256(der_data).digest()
|
||||
aes_key = sha256_hash[:32] if use_aes_256 else sha256_hash[:16]
|
||||
aes_iv = sha256_hash[16:32]
|
||||
if isinstance(public_key, rsa.RSAPublicKey):
|
||||
der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.PKCS1)
|
||||
elif isinstance(public_key, ec.EllipticCurvePublicKey):
|
||||
der_data = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
else:
|
||||
raise ValueError("Unsupported key type.")
|
||||
|
||||
return aes_key, aes_iv
|
||||
if use_aes_256:
|
||||
# AES-256-CBC
|
||||
key = hashlib.sha256(der_data).digest()
|
||||
iv = hashlib.md5(der_data).digest()
|
||||
else:
|
||||
# AES-128-CBC
|
||||
key = hashlib.md5(der_data).digest()
|
||||
iv = hashlib.md5(der_data).digest() # Insecure IV reuse, kept for compatibility
|
||||
|
||||
return key, iv
|
||||
|
||||
|
||||
def qUncompress(data):
|
||||
def uncompress_dat(data):
|
||||
"""
|
||||
Decompress data using zlib.
|
||||
|
||||
@@ -71,7 +78,7 @@ def qUncompress(data):
|
||||
return zlib.decompress(data_stripped_4)
|
||||
|
||||
|
||||
def qCompress(data):
|
||||
def compress_dat(data):
|
||||
"""
|
||||
Compress data using zlib.
|
||||
|
||||
@@ -85,6 +92,19 @@ def qCompress(data):
|
||||
return b"ZLIB" + compressed_data
|
||||
|
||||
|
||||
def get_key_path(use_aes_256=False) -> str:
|
||||
key_path = ""
|
||||
for key in KEYS:
|
||||
if os.path.isfile(Paths.persist_root() + f'/comma/{key}') and os.path.isfile(Paths.persist_root() + f'/comma/{key}.pub'):
|
||||
key_path = str(Path(Paths.persist_root() + f'/comma/{key}') if use_aes_256 else Path(Paths.persist_root() + f'/comma/{key}.pub'))
|
||||
break
|
||||
|
||||
if not key_path:
|
||||
raise FileNotFoundError("No valid key pair found in persist storage.")
|
||||
|
||||
return key_path
|
||||
|
||||
|
||||
def decrypt_compressed_data(encrypted_base64, use_aes_256=False):
|
||||
"""
|
||||
Decrypt and decompress data from base64 string.
|
||||
@@ -96,18 +116,17 @@ def decrypt_compressed_data(encrypted_base64, use_aes_256=False):
|
||||
Returns:
|
||||
str: Decrypted and decompressed string
|
||||
"""
|
||||
key_path = Path(f"{Paths.persist_root()}/comma/id_rsa") if use_aes_256 else Path(f"{Paths.persist_root()}/comma/id_rsa.pub")
|
||||
try:
|
||||
# Decode base64
|
||||
encrypted_data = base64.b64decode(encrypted_base64)
|
||||
|
||||
# Decrypt
|
||||
key, iv = KeyDerivation.derive_aes_key_iv_from_rsa(str(key_path), use_aes_256)
|
||||
key, iv = KeyDerivation.derive_aes_key_iv(get_key_path(use_aes_256), use_aes_256)
|
||||
cipher = AESCipher(key, iv)
|
||||
decrypted_data = cipher.decrypt(encrypted_data)
|
||||
|
||||
# Decompress
|
||||
decompressed_data = qUncompress(decrypted_data)
|
||||
decompressed_data = uncompress_dat(decrypted_data)
|
||||
|
||||
# Decode UTF-8
|
||||
result = decompressed_data.decode('utf-8')
|
||||
@@ -117,7 +136,7 @@ def decrypt_compressed_data(encrypted_base64, use_aes_256=False):
|
||||
return ""
|
||||
|
||||
|
||||
def encrypt_compress_data(text, use_aes_256=True):
|
||||
def encrypt_compressed_data(text, use_aes_256=True):
|
||||
"""
|
||||
Compress and encrypt string data to base64.
|
||||
|
||||
@@ -128,16 +147,15 @@ def encrypt_compress_data(text, use_aes_256=True):
|
||||
Returns:
|
||||
str: Base64 encoded encrypted data
|
||||
"""
|
||||
key_path = Path(f"{Paths.persist_root()}/comma/id_rsa") if use_aes_256 else Path(f"{Paths.persist_root()}/comma/id_rsa.pub")
|
||||
try:
|
||||
# Encode to UTF-8
|
||||
text_bytes = text.encode('utf-8')
|
||||
|
||||
# Compress
|
||||
compressed_data = qCompress(text_bytes)
|
||||
compressed_data = compress_dat(text_bytes)
|
||||
|
||||
# Encrypt
|
||||
key, iv = KeyDerivation.derive_aes_key_iv_from_rsa(str(key_path), use_aes_256)
|
||||
key, iv = KeyDerivation.derive_aes_key_iv(get_key_path(use_aes_256), use_aes_256)
|
||||
cipher = AESCipher(key, iv)
|
||||
encrypted_data = cipher.encrypt(compressed_data)
|
||||
|
||||
|
||||
@@ -121,6 +121,13 @@
|
||||
"title": "Camera Debug Exp Time",
|
||||
"description": ""
|
||||
},
|
||||
"CameraOffset": {
|
||||
"title": "Adjust Camera Offset",
|
||||
"description": "Virtually shift camera's perspective to move model's center to Left(+ values) or Right (- values)",
|
||||
"min": -0.35,
|
||||
"max": 0.35,
|
||||
"step": 0.01
|
||||
},
|
||||
"CarBatteryCapacity": {
|
||||
"title": "Car Battery Capacity",
|
||||
"description": ""
|
||||
@@ -165,6 +172,10 @@
|
||||
"title": "Chevron Info",
|
||||
"description": ""
|
||||
},
|
||||
"CompletedSunnylinkConsentVersion": {
|
||||
"title": "Completed sunnylink Consent Version",
|
||||
"description": ""
|
||||
},
|
||||
"CompletedTrainingVersion": {
|
||||
"title": "Completed Training Version",
|
||||
"description": ""
|
||||
@@ -182,7 +193,7 @@
|
||||
"description": ""
|
||||
},
|
||||
"CustomAccIncrementsEnabled": {
|
||||
"title": "Custom ACC Increments Enabled",
|
||||
"title": "Custom ACC Increments",
|
||||
"description": ""
|
||||
},
|
||||
"CustomAccLongPressIncrement": {
|
||||
@@ -200,8 +211,8 @@
|
||||
"step": 1
|
||||
},
|
||||
"CustomTorqueParams": {
|
||||
"title": "Custom Torque Params",
|
||||
"description": ""
|
||||
"title": "Enable Custom Torque Tuning",
|
||||
"description": "Enables custom tuning for Torque lateral control"
|
||||
},
|
||||
"DevUIInfo": {
|
||||
"title": "Developer UI Info",
|
||||
@@ -275,7 +286,7 @@
|
||||
},
|
||||
"EnforceTorqueControl": {
|
||||
"title": "Enforce Torque Control",
|
||||
"description": ""
|
||||
"description": "Enable this to enforce sunnypilot to steer with Torque lateral control."
|
||||
},
|
||||
"ExperimentalMode": {
|
||||
"title": "Experimental Mode",
|
||||
@@ -349,6 +360,10 @@
|
||||
"title": "Has Accepted Terms",
|
||||
"description": ""
|
||||
},
|
||||
"HasAcceptedTermsSP": {
|
||||
"title": "Has Accepted sunnypilot Terms",
|
||||
"description": ""
|
||||
},
|
||||
"HideVEgoUI": {
|
||||
"title": "Hide vEgo UI",
|
||||
"description": ""
|
||||
@@ -436,12 +451,15 @@
|
||||
"description": ""
|
||||
},
|
||||
"LagdToggle": {
|
||||
"title": "LaGD Toggle",
|
||||
"description": ""
|
||||
"title": "Live Learning Steer Delay",
|
||||
"description": "Allow device to learn and adapt car's steering response time"
|
||||
},
|
||||
"LagdToggleDelay": {
|
||||
"title": "LaGD Toggle Delay",
|
||||
"description": ""
|
||||
"title": "Manual Software Delay",
|
||||
"description": "Software delay to use when Live Learning Steer Delay is toggled off",
|
||||
"min": 0.05,
|
||||
"max": 0.5,
|
||||
"step": 0.01
|
||||
},
|
||||
"LagdValueCache": {
|
||||
"title": "LaGD Value Cache",
|
||||
@@ -449,11 +467,11 @@
|
||||
},
|
||||
"LaneTurnDesire": {
|
||||
"title": "Lane Turn Desire",
|
||||
"description": ""
|
||||
"description": "Force model to plan an intent to turn based on blinker"
|
||||
},
|
||||
"LaneTurnValue": {
|
||||
"title": "Lane Turn Value",
|
||||
"description": "",
|
||||
"title": "Lane Turn Speed",
|
||||
"description": "Maximum speed for lane turn desire",
|
||||
"min": 0,
|
||||
"max": 20,
|
||||
"step": 1
|
||||
@@ -531,12 +549,12 @@
|
||||
"description": ""
|
||||
},
|
||||
"LiveTorqueParamsRelaxedToggle": {
|
||||
"title": "Live Torque Params Relaxed Toggle",
|
||||
"description": ""
|
||||
"title": "Less Restrict Settings for Self-Tune (Beta)",
|
||||
"description": "Less strict settings when using Self-Tune. This allows torqued to be more forgiving when learning values."
|
||||
},
|
||||
"LiveTorqueParamsToggle": {
|
||||
"title": "Live Torque Params Toggle",
|
||||
"description": ""
|
||||
"title": "Self-Tune",
|
||||
"description": "Enables self-tune for Torque lateral control"
|
||||
},
|
||||
"LocationFilterInitialState": {
|
||||
"title": "Location Filter Initial State",
|
||||
@@ -689,7 +707,7 @@
|
||||
"description": ""
|
||||
},
|
||||
"OffroadMode": {
|
||||
"title": "Offroad Mode",
|
||||
"title": "Force Offroad Mode",
|
||||
"description": ""
|
||||
},
|
||||
"Offroad_CarUnrecognized": {
|
||||
@@ -753,18 +771,18 @@
|
||||
"description": ""
|
||||
},
|
||||
"OnroadScreenOffBrightness": {
|
||||
"title": "Onroad Screen Off Brightness",
|
||||
"title": "Onroad Brightness",
|
||||
"description": "",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 5
|
||||
},
|
||||
"OnroadScreenOffControl": {
|
||||
"title": "Onroad Screen Off Control",
|
||||
"description": ""
|
||||
"title": "Onroad Screen: Reduced Brightness",
|
||||
"description": "Turn off device screen or reduce brightness after driving starts"
|
||||
},
|
||||
"OnroadScreenOffTimer": {
|
||||
"title": "Onroad Screen Off Timer",
|
||||
"title": "Onroad Brightness Delay",
|
||||
"description": "",
|
||||
"min": 0,
|
||||
"max": 60,
|
||||
@@ -826,6 +844,13 @@
|
||||
"title": "Panda Som Reset Triggered",
|
||||
"description": ""
|
||||
},
|
||||
"PlanplusControl": {
|
||||
"title": "Plan Plus Controls",
|
||||
"description": "Adjust planplus model recentering strength. The higher this number the more aggressively the model will recover to lanecenter, too high and it will ping-pong",
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.1
|
||||
},
|
||||
"PrimeType": {
|
||||
"title": "Prime Type",
|
||||
"description": ""
|
||||
@@ -863,7 +888,7 @@
|
||||
"description": ""
|
||||
},
|
||||
"RoadNameToggle": {
|
||||
"title": "Road Name Toggle",
|
||||
"title": "Display Road Name",
|
||||
"description": ""
|
||||
},
|
||||
"RouteCount": {
|
||||
@@ -876,7 +901,7 @@
|
||||
},
|
||||
"ShowAdvancedControls": {
|
||||
"title": "Show Advanced Controls",
|
||||
"description": ""
|
||||
"description": "Enable to show advanced controls on device"
|
||||
},
|
||||
"ShowDebugInfo": {
|
||||
"title": "UI Debug Mode",
|
||||
@@ -887,11 +912,11 @@
|
||||
"description": ""
|
||||
},
|
||||
"SmartCruiseControlMap": {
|
||||
"title": "Smart Cruise Control Map",
|
||||
"title": "Smart Cruise Control - Map",
|
||||
"description": ""
|
||||
},
|
||||
"SmartCruiseControlVision": {
|
||||
"title": "Smart Cruise Control Vision",
|
||||
"title": "Smart Cruise Control - Vision",
|
||||
"description": ""
|
||||
},
|
||||
"SnoozeUpdate": {
|
||||
@@ -899,7 +924,7 @@
|
||||
"description": ""
|
||||
},
|
||||
"SpeedLimitMode": {
|
||||
"title": "Speed Limit Mode",
|
||||
"title": "Speed Limit Assist Mode",
|
||||
"description": "",
|
||||
"options": [
|
||||
{
|
||||
@@ -939,7 +964,7 @@
|
||||
]
|
||||
},
|
||||
"SpeedLimitPolicy": {
|
||||
"title": "Speed Limit Policy",
|
||||
"title": "Speed Limit Source",
|
||||
"description": "",
|
||||
"options": [
|
||||
{
|
||||
@@ -965,7 +990,7 @@
|
||||
]
|
||||
},
|
||||
"SpeedLimitValueOffset": {
|
||||
"title": "Speed Limit Value Offset",
|
||||
"title": "Speed Limit Offset Value",
|
||||
"description": "",
|
||||
"min": -30,
|
||||
"max": 30,
|
||||
@@ -1020,23 +1045,27 @@
|
||||
"description": ""
|
||||
},
|
||||
"TorqueParamsOverrideEnabled": {
|
||||
"title": "Torque Params Override Enabled",
|
||||
"title": "Manual Real-Time Tuning",
|
||||
"description": ""
|
||||
},
|
||||
"TorqueParamsOverrideFriction": {
|
||||
"title": "Torque Params Override Friction",
|
||||
"title": "Manual Tune - Friction",
|
||||
"description": "",
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01
|
||||
},
|
||||
"TorqueParamsOverrideLatAccelFactor": {
|
||||
"title": "Torque Params Override Lat Accel Factor",
|
||||
"title": "Manual Tune - Lateral Acceleration Factor",
|
||||
"description": "",
|
||||
"min": 0.1,
|
||||
"max": 5.0,
|
||||
"step": 0.1
|
||||
},
|
||||
"ToyotaEnforceStockLongitudinal": {
|
||||
"title": "Toyota: Enforce Factory Longitudinal Control",
|
||||
"description": "When enabled, sunnypilot will not take over control of gas and brakes. Factory Toyota longitudinal control will be used."
|
||||
},
|
||||
"TrainingVersion": {
|
||||
"title": "Training Version",
|
||||
"description": ""
|
||||
|
||||
@@ -136,10 +136,11 @@ class SunnylinkState:
|
||||
token = self._api.get_token()
|
||||
response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/roles", method='GET', access_token=token)
|
||||
if response.status_code == 200:
|
||||
self._roles = _parse_roles(response.text)
|
||||
self._params.put("SunnylinkCache_Roles", response.text)
|
||||
sponsor_tier = self._get_highest_tier()
|
||||
roles = response.text
|
||||
self._params.put("SunnylinkCache_Roles", roles)
|
||||
with self._lock:
|
||||
self._roles = _parse_roles(roles)
|
||||
sponsor_tier = self._get_highest_tier()
|
||||
if sponsor_tier != self.sponsor_tier:
|
||||
self.sponsor_tier = sponsor_tier
|
||||
cloudlog.info(f"Sunnylink sponsor tier updated to {sponsor_tier.name}")
|
||||
@@ -157,7 +158,7 @@ class SunnylinkState:
|
||||
users = response.text
|
||||
self._params.put("SunnylinkCache_Users", users)
|
||||
with self._lock:
|
||||
_parse_users(users)
|
||||
self._users = _parse_users(users)
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Failed to fetch sunnylink users: {e} for dongle id {self.sunnylink_dongle_id}")
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from openpilot.system.statsd import statlog
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.system.hardware.power_monitoring import PowerMonitoring
|
||||
from openpilot.system.hardware.fan_controller import TiciFanController
|
||||
from openpilot.system.version import terms_version, training_version, get_build_metadata
|
||||
from openpilot.system.version import terms_version, training_version, get_build_metadata, terms_version_sp
|
||||
|
||||
ThermalStatus = log.DeviceState.ThermalStatus
|
||||
NetworkType = log.DeviceState.NetworkType
|
||||
@@ -310,6 +310,7 @@ def hardware_thread(end_event, hw_queue) -> None:
|
||||
startup_conditions["no_excessive_actuation"] = params.get("Offroad_ExcessiveActuation") is None
|
||||
startup_conditions["not_uninstalling"] = not params.get_bool("DoUninstall")
|
||||
startup_conditions["accepted_terms"] = params.get("HasAcceptedTerms") == terms_version
|
||||
startup_conditions["accepted_terms_sp"] = params.get("HasAcceptedTermsSP") == terms_version_sp
|
||||
|
||||
# with 2% left, we killall, otherwise the phone will take a long time to boot
|
||||
startup_conditions["free_space"] = msg.deviceState.freeSpacePercent > 2
|
||||
|
||||
@@ -95,3 +95,10 @@ 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"
|
||||
|
||||
@@ -11,12 +11,28 @@ from openpilot.common.params import Params
|
||||
from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.sunnypilot.widgets.toggle import ToggleSP
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
from openpilot.system.ui.widgets.label import gui_label
|
||||
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, ButtonAction, \
|
||||
_resolve_value, BUTTON_WIDTH, BUTTON_HEIGHT, TEXT_PADDING
|
||||
_resolve_value, BUTTON_WIDTH, BUTTON_HEIGHT, TEXT_PADDING, DualButtonAction
|
||||
from openpilot.system.ui.widgets.scroller_tici import LineSeparator, LINE_COLOR, LINE_PADDING
|
||||
from openpilot.system.ui.sunnypilot.lib.styles import style
|
||||
from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP, LABEL_WIDTH
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
|
||||
|
||||
class Spacer(Widget):
|
||||
def __init__(self, height: int = 1):
|
||||
super().__init__()
|
||||
self._rect = rl.Rectangle(0, 0, 0, height)
|
||||
|
||||
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
|
||||
super().set_parent_rect(parent_rect)
|
||||
self._rect.width = parent_rect.width
|
||||
|
||||
def _render(self, _):
|
||||
rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.x + self._rect.width), int(self._rect.y), rl.Color(0,0,0,0))
|
||||
|
||||
|
||||
class ToggleActionSP(ToggleAction):
|
||||
@@ -83,6 +99,33 @@ class ButtonActionSP(ButtonAction):
|
||||
return pressed
|
||||
|
||||
|
||||
class DualButtonActionSP(DualButtonAction):
|
||||
def __init__(self, left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable = None,
|
||||
right_callback: Callable = None, enabled: bool | Callable[[], bool] = True, border_radius: int = 15):
|
||||
DualButtonAction.__init__(self, left_text, right_text, left_callback, right_callback, enabled)
|
||||
self.left_button._border_radius = self.right_button._border_radius = border_radius
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
button_spacing = 20
|
||||
button_height = 150
|
||||
button_width = (rect.width - button_spacing) / 2
|
||||
button_y = rect.y + (rect.height - button_height) / 2
|
||||
|
||||
left_rect = rl.Rectangle(rect.x, button_y, button_width, button_height)
|
||||
right_rect = rl.Rectangle(rect.x + button_width + button_spacing, button_y, button_width, button_height)
|
||||
|
||||
# expand one to full width if other is not visible
|
||||
if not self.left_button.is_visible:
|
||||
right_rect.x = rect.x
|
||||
right_rect.width = rect.width
|
||||
elif not self.right_button.is_visible:
|
||||
left_rect.width = rect.width
|
||||
|
||||
# Render buttons
|
||||
self.left_button.render(left_rect)
|
||||
self.right_button.render(right_rect)
|
||||
|
||||
|
||||
class MultipleButtonActionSP(MultipleButtonAction):
|
||||
def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable = None,
|
||||
param: str | None = None):
|
||||
@@ -179,13 +222,8 @@ class ListItemSP(ListItem):
|
||||
return rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
if not self.inline:
|
||||
has_description = bool(self.description) and self.description_visible
|
||||
|
||||
if has_description:
|
||||
action_y = item_rect.y + self._text_size.y + style.ITEM_PADDING * 3
|
||||
else:
|
||||
action_y = item_rect.y + item_rect.height - style.BUTTON_HEIGHT - style.ITEM_PADDING * 1.5
|
||||
|
||||
text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
|
||||
action_y = item_rect.y + text_size.y + style.ITEM_PADDING * 3
|
||||
return rl.Rectangle(item_rect.x + style.ITEM_PADDING, action_y, item_rect.width - (style.ITEM_PADDING * 2), style.BUTTON_HEIGHT)
|
||||
|
||||
right_width = self.action_item.get_width_hint()
|
||||
@@ -255,13 +293,13 @@ class ListItemSP(ListItem):
|
||||
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2 if self.inline else self._rect.y + style.ITEM_PADDING * 1.5
|
||||
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color)
|
||||
|
||||
# Draw right item if present
|
||||
if self.action_item:
|
||||
right_rect = self.get_right_item_rect(self._rect)
|
||||
if self.action_item.render(right_rect) and self.action_item.enabled:
|
||||
# Right item was clicked/activated
|
||||
if self.callback:
|
||||
self.callback()
|
||||
# Draw right item if present
|
||||
if self.action_item:
|
||||
right_rect = self.get_right_item_rect(self._rect)
|
||||
if self.action_item.render(right_rect) and self.action_item.enabled:
|
||||
# Right item was clicked/activated
|
||||
if self.callback:
|
||||
self.callback()
|
||||
|
||||
# Draw description if visible
|
||||
if self.description_visible:
|
||||
@@ -284,6 +322,9 @@ def simple_button_item_sp(button_text: str | Callable[[], str], callback: Callab
|
||||
|
||||
def toggle_item_sp(title: str | Callable[[], str], description: str | Callable[[], str] | None = None, initial_state: bool = False,
|
||||
callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, param: str | None = None) -> ListItemSP:
|
||||
if param is None and hasattr(ui_state.params, 'last_accessed_param') and ui_state.params.last_accessed_param:
|
||||
param = ui_state.params.last_accessed_param
|
||||
ui_state.params.last_accessed_param = None
|
||||
action = ToggleActionSP(initial_state=initial_state, enabled=enabled, callback=callback, param=param)
|
||||
return ListItemSP(title=title, description=description, action_item=action, icon=icon, callback=callback)
|
||||
|
||||
@@ -291,6 +332,9 @@ def toggle_item_sp(title: str | Callable[[], str], description: str | Callable[[
|
||||
def multiple_button_item_sp(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]],
|
||||
selected_index: int = 0, button_width: int = style.BUTTON_ACTION_WIDTH, callback: Callable = None,
|
||||
icon: str = "", param: str | None = None, inline: bool = False) -> ListItemSP:
|
||||
if param is None and hasattr(ui_state.params, 'last_accessed_param') and ui_state.params.last_accessed_param:
|
||||
param = ui_state.params.last_accessed_param
|
||||
ui_state.params.last_accessed_param = None
|
||||
action = MultipleButtonActionSP(buttons, button_width, selected_index, callback=callback, param=param)
|
||||
return ListItemSP(title=title, description=description, icon=icon, action_item=action, inline=inline)
|
||||
|
||||
@@ -300,15 +344,34 @@ def option_item_sp(title: str | Callable[[], str], param: str,
|
||||
value_change_step: int = 1, on_value_changed: Callable[[int], None] | None = None,
|
||||
enabled: bool | Callable[[], bool] = True,
|
||||
icon: str = "", label_width: int = LABEL_WIDTH, value_map: dict[int, int] | None = None,
|
||||
use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None) -> ListItemSP:
|
||||
use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None, inline: bool = False) -> ListItemSP:
|
||||
action = OptionControlSP(
|
||||
param, min_value, max_value, value_change_step,
|
||||
enabled, on_value_changed, value_map, label_width, use_float_scaling, label_callback
|
||||
)
|
||||
return ListItemSP(title=title, description=description, action_item=action, icon=icon)
|
||||
return ListItemSP(title=title, description=description, action_item=action, icon=icon, inline=inline)
|
||||
|
||||
|
||||
def button_item_sp(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
|
||||
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItemSP:
|
||||
action = ButtonActionSP(text=button_text, enabled=enabled)
|
||||
return ListItemSP(title=title, description=description, action_item=action, callback=callback)
|
||||
|
||||
|
||||
def dual_button_item_sp(left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable = None,
|
||||
right_callback: Callable = None, description: str | Callable[[], str] | None = None,
|
||||
enabled: bool | Callable[[], bool] = True, border_radius: int = 15) -> ListItemSP:
|
||||
action = DualButtonActionSP(left_text, right_text, left_callback, right_callback, enabled, border_radius)
|
||||
return ListItemSP(title="", description=description, action_item=action)
|
||||
|
||||
|
||||
class LineSeparatorSP(LineSeparator):
|
||||
def __init__(self, height: int = 1):
|
||||
super().__init__()
|
||||
self._rect = rl.Rectangle(0, 0, 0, height)
|
||||
|
||||
def _render(self, _):
|
||||
line_y = int(self._rect.y + self._rect.height // 2)
|
||||
rl.draw_line(int(self._rect.x) + LINE_PADDING, line_y,
|
||||
int(self._rect.x + self._rect.width) - LINE_PADDING, line_y,
|
||||
LINE_COLOR)
|
||||
|
||||
@@ -30,6 +30,9 @@ BUILD_METADATA_FILENAME = "build.json"
|
||||
|
||||
training_version: str = "0.2.0"
|
||||
terms_version: str = "2"
|
||||
terms_version_sp: str = "1.0"
|
||||
sunnylink_consent_version: str = "1.0"
|
||||
sunnylink_consent_declined: str = "-1"
|
||||
|
||||
|
||||
def get_version(path: str = BASEDIR) -> str:
|
||||
|
||||
Reference in New Issue
Block a user