diff --git a/.gitattributes b/.gitattributes index cc1605a132..50ac49dd7c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,7 +10,8 @@ *.wav filter=lfs diff=lfs merge=lfs -text selfdrive/car/tests/test_models_segs.txt filter=lfs diff=lfs merge=lfs -text -system/hardware/tici/updater filter=lfs diff=lfs merge=lfs -text +system/hardware/tici/updater_weston filter=lfs diff=lfs merge=lfs -text +system/hardware/tici/updater_magic filter=lfs diff=lfs merge=lfs -text third_party/**/*.a filter=lfs diff=lfs merge=lfs -text third_party/**/*.so filter=lfs diff=lfs merge=lfs -text third_party/**/*.so.* filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/selfdrive_tests.yaml b/.github/workflows/selfdrive_tests.yaml index 57b1158be2..35ced1e38b 100644 --- a/.github/workflows/selfdrive_tests.yaml +++ b/.github/workflows/selfdrive_tests.yaml @@ -225,7 +225,7 @@ jobs: (github.event.pull_request.head.repo.full_name == 'commaai/openpilot')) && fromJSON('["namespace-profile-amd64-8x16", "namespace-experiments:docker.builds.local-cache=separate"]') || fromJSON('["ubuntu-24.04"]') }} - if: (github.repository == 'commaai/openpilot') && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.full_name == 'commaai/openpilot')) + if: false # FIXME: Started to timeout recently steps: - uses: actions/checkout@v4 with: diff --git a/Dockerfile.openpilot b/Dockerfile.openpilot index d85be77121..106a06e3a2 100644 --- a/Dockerfile.openpilot +++ b/Dockerfile.openpilot @@ -9,4 +9,6 @@ WORKDIR ${OPENPILOT_PATH} COPY . ${OPENPILOT_PATH}/ -RUN scons --cache-readonly -j$(nproc) +ENV UV_BIN="/home/batman/.local/bin/" +ENV PATH="$UV_BIN:$PATH" +RUN UV_PROJECT_ENVIRONMENT=$VIRTUAL_ENV uv run scons --cache-readonly -j$(nproc) diff --git a/Jenkinsfile b/Jenkinsfile index ad8e85136b..73fa74c1cd 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -167,7 +167,7 @@ node { env.GIT_COMMIT = checkout(scm).GIT_COMMIT def excludeBranches = ['__nightly', 'devel', 'devel-staging', 'release3', 'release3-staging', - 'release-tici', 'release-tizi', 'testing-closet*', 'hotfix-*'] + 'release-tici', 'release-tizi', 'release-tizi-staging', 'testing-closet*', 'hotfix-*'] def excludeRegex = excludeBranches.join('|').replaceAll('\\*', '.*') if (env.BRANCH_NAME != 'master' && !env.BRANCH_NAME.contains('__jenkins_loop_')) { @@ -178,8 +178,8 @@ node { try { if (env.BRANCH_NAME == 'devel-staging') { - deviceStage("build release3-staging", "tizi-needs-can", [], [ - step("build release3-staging", "RELEASE_BRANCH=release3-staging $SOURCE_DIR/release/build_release.sh"), + deviceStage("build release-tizi-staging", "tizi-needs-can", [], [ + step("build release-tizi-staging", "RELEASE_BRANCH=release-tizi-staging $SOURCE_DIR/release/build_release.sh"), ]) } diff --git a/RELEASES.md b/RELEASES.md index 966d5d3809..568be6c353 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,10 +1,12 @@ Version 0.10.1 (2025-09-08) ======================== -* New driving model #36087 +* New driving model #36114 * World Model: removed global localization inputs * World Model: 2x the number of parameters * World Model: trained on 4x the number of segments + * VAE Compression Model: new architecture and training objective * Driving Vision Model: trained on 4x the number of segments +* New Driver Monitoring model #36198 * Acura TLX 2021 support thanks to MVL! * Honda City 2023 support thanks to vanillagorillaa and drFritz! * Honda N-Box 2018 support thanks to miettal! diff --git a/cereal/log.capnp b/cereal/log.capnp index b98c1f5242..019fbbe10b 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -2146,13 +2146,10 @@ struct Joystick { struct DriverStateV2 { frameId @0 :UInt32; modelExecutionTime @1 :Float32; - dspExecutionTimeDEPRECATED @2 :Float32; gpuExecutionTime @8 :Float32; rawPredictions @3 :Data; - poorVisionProb @4 :Float32; wheelOnRightProb @5 :Float32; - leftDriverData @6 :DriverData; rightDriverData @7 :DriverData; @@ -2167,10 +2164,13 @@ struct DriverStateV2 { leftBlinkProb @7 :Float32; rightBlinkProb @8 :Float32; sunglassesProb @9 :Float32; - occludedProb @10 :Float32; - readyProb @11 :List(Float32); notReadyProb @12 :List(Float32); + occludedProbDEPRECATED @10 :Float32; + readyProbDEPRECATED @11 :List(Float32); } + + dspExecutionTimeDEPRECATED @2 :Float32; + poorVisionProbDEPRECATED @4 :Float32; } struct DriverStateDEPRECATED @0xb83c6cc593ed0a00 { diff --git a/common/filter_simple.py b/common/filter_simple.py index 8a7105063d..9ea6fe3070 100644 --- a/common/filter_simple.py +++ b/common/filter_simple.py @@ -1,21 +1,16 @@ class FirstOrderFilter: def __init__(self, x0, rc, dt, initialized=True): self.x = x0 - self._dt = dt + self.dt = dt self.update_alpha(rc) self.initialized = initialized - def update_dt(self, dt): - self._dt = dt - self.update_alpha(self._rc) - def update_alpha(self, rc): - self._rc = rc - self._alpha = self._dt / (self._rc + self._dt) + self.alpha = self.dt / (rc + self.dt) def update(self, x): if self.initialized: - self.x = (1. - self._alpha) * self.x + self._alpha * x + self.x = (1. - self.alpha) * self.x + self.alpha * x else: self.initialized = True self.x = x diff --git a/common/params_keys.h b/common/params_keys.h index 211b4d550b..f6e8d781c8 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -66,7 +66,7 @@ inline static std::unordered_map keys = { {"IsTakingSnapshot", {CLEAR_ON_MANAGER_START, BOOL}}, {"IsTestedBranch", {CLEAR_ON_MANAGER_START, BOOL}}, {"JoystickDebugMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, - {"LanguageSetting", {PERSISTENT, STRING, "main_en"}}, + {"LanguageSetting", {PERSISTENT, STRING, "en"}}, {"LastAthenaPingTime", {CLEAR_ON_MANAGER_START, INT}}, {"LastGPSPosition", {PERSISTENT, STRING}}, {"LastManagerExitReason", {CLEAR_ON_MANAGER_START, STRING}}, diff --git a/common/pid.py b/common/pid.py index 99142280ca..e3fa8afdf4 100644 --- a/common/pid.py +++ b/common/pid.py @@ -2,11 +2,10 @@ import numpy as np from numbers import Number class PIDController: - def __init__(self, k_p, k_i, k_f=0., k_d=0., pos_limit=1e308, neg_limit=-1e308, rate=100): + def __init__(self, k_p, k_i, k_d=0., pos_limit=1e308, neg_limit=-1e308, rate=100): self._k_p = k_p self._k_i = k_i self._k_d = k_d - self.k_f = k_f # feedforward gain if isinstance(self._k_p, Number): self._k_p = [[0], [self._k_p]] if isinstance(self._k_i, Number): @@ -16,7 +15,7 @@ class PIDController: self.set_limits(pos_limit, neg_limit) - self.i_rate = 1.0 / rate + self.i_dt = 1.0 / rate self.speed = 0.0 self.reset() @@ -46,12 +45,12 @@ class PIDController: def update(self, error, error_rate=0.0, speed=0.0, feedforward=0., freeze_integrator=False): self.speed = speed - self.p = float(error) * self.k_p - self.f = feedforward * self.k_f - self.d = error_rate * self.k_d + self.p = self.k_p * float(error) + self.d = self.k_d * error_rate + self.f = feedforward if not freeze_integrator: - i = self.i + error * self.k_i * self.i_rate + i = self.i + self.k_i * self.i_dt * error # Don't allow windup if already clipping test_control = self.p + i + self.d + self.f diff --git a/launch_env.sh b/launch_env.sh index 67dd5ee795..07ec162f0b 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -7,7 +7,7 @@ export OPENBLAS_NUM_THREADS=1 export VECLIB_MAXIMUM_THREADS=1 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="13.1" + export AGNOS_VERSION="14.2" fi export STAGING_ROOT="/data/safe_staging" diff --git a/opendbc_repo b/opendbc_repo index 2eec1af104..b59f8bdcca 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 2eec1af104972b7784644bf38c4c5afb52fc070a +Subproject commit b59f8bdcca8d375b4a5a652d2f2d2ec9cd3503d3 diff --git a/panda b/panda index 1289337ceb..615009cf0f 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 1289337ceb6205ad985a5469baa950b319329327 +Subproject commit 615009cf0f8fb8f3feadac160fbb0a07e4de171b diff --git a/pyproject.toml b/pyproject.toml index 9c8a6148a2..78bf8c22ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,9 @@ dependencies = [ "zstandard", # ui + "raylib < 5.5.0.3", # TODO: unpin when they fix https://github.com/electronstudio/raylib-python-cffi/issues/186 "qrcode", + "mapbox-earcut", ] [project.optional-dependencies] @@ -119,7 +121,6 @@ dev = [ "tabulate", "types-requests", "types-tabulate", - "raylib < 5.5.0.3", # TODO: unpin when they fix https://github.com/electronstudio/raylib-python-cffi/issues/186 ] tools = [ @@ -262,8 +263,13 @@ lint.flake8-implicit-str-concat.allow-multiline = false "tools".msg = "Use openpilot.tools" "pytest.main".msg = "pytest.main requires special handling that is easy to mess up!" "unittest".msg = "Use pytest" -"pyray.measure_text_ex".msg = "Use openpilot.system.ui.lib.text_measure" "time.time".msg = "Use time.monotonic" +# raylib banned APIs +"pyray.measure_text_ex".msg = "Use openpilot.system.ui.lib.text_measure" +"pyray.is_mouse_button_pressed".msg = "This can miss events. Use Widget._handle_mouse_press" +"pyray.is_mouse_button_released".msg = "This can miss events. Use Widget._handle_mouse_release" +"pyray.draw_text".msg = "Use a function (such as rl.draw_font_ex) that takes font as an argument" + [tool.ruff.format] quote-style = "preserve" diff --git a/selfdrive/SConscript b/selfdrive/SConscript index 0b49e69116..43bf7d0476 100644 --- a/selfdrive/SConscript +++ b/selfdrive/SConscript @@ -3,4 +3,5 @@ SConscript(['controls/lib/lateral_mpc_lib/SConscript']) SConscript(['controls/lib/longitudinal_mpc_lib/SConscript']) SConscript(['locationd/SConscript']) SConscript(['modeld/SConscript']) -SConscript(['ui/SConscript']) \ No newline at end of file +if GetOption('extras'): + SConscript(['ui/SConscript']) diff --git a/selfdrive/assets/fonts/NotoColorEmoji-Regular.ttf b/selfdrive/assets/fonts/NotoColorEmoji-Regular.ttf deleted file mode 100644 index 2579d30f65..0000000000 --- a/selfdrive/assets/fonts/NotoColorEmoji-Regular.ttf +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:69f216a4ec672bb910d652678301ffe3094c44e5d03276e794ef793d936a1f1d -size 25096376 diff --git a/selfdrive/assets/fonts/NotoColorEmoji.ttf b/selfdrive/assets/fonts/NotoColorEmoji.ttf new file mode 100644 index 0000000000..778e821ce3 --- /dev/null +++ b/selfdrive/assets/fonts/NotoColorEmoji.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93cdc4ee9aa40e2afceecc63da0ca05ec7aab4bec991ece51a6b52389f48a477 +size 10788068 diff --git a/selfdrive/car/tests/test_car_interfaces.py b/selfdrive/car/tests/test_car_interfaces.py index c40443d7e7..24d2faa0db 100644 --- a/selfdrive/car/tests/test_car_interfaces.py +++ b/selfdrive/car/tests/test_car_interfaces.py @@ -54,8 +54,8 @@ class TestCarInterfaces: # hypothesis also slows down significantly with just one more message draw LongControl(car_params) if car_params.steerControlType == CarParams.SteerControlType.angle: - LatControlAngle(car_params, car_interface) + LatControlAngle(car_params, car_interface, DT_CTRL) elif car_params.lateralTuning.which() == 'pid': - LatControlPID(car_params, car_interface) + LatControlPID(car_params, car_interface, DT_CTRL) elif car_params.lateralTuning.which() == 'torque': - LatControlTorque(car_params, car_interface) + LatControlTorque(car_params, car_interface, DT_CTRL) diff --git a/selfdrive/car/tests/test_models.py b/selfdrive/car/tests/test_models.py index 8996ad6460..94f5b33231 100644 --- a/selfdrive/car/tests/test_models.py +++ b/selfdrive/car/tests/test_models.py @@ -183,7 +183,7 @@ class TestCarModelBase(unittest.TestCase): if tuning == 'pid': self.assertTrue(len(self.CP.lateralTuning.pid.kpV)) elif tuning == 'torque': - self.assertTrue(self.CP.lateralTuning.torque.kf > 0) + self.assertTrue(self.CP.lateralTuning.torque.latAccelFactor > 0) else: raise Exception("unknown tuning") diff --git a/selfdrive/controls/controlsd.py b/selfdrive/controls/controlsd.py index 029d16e59e..9e31ac1526 100755 --- a/selfdrive/controls/controlsd.py +++ b/selfdrive/controls/controlsd.py @@ -6,7 +6,7 @@ from cereal import car, log import cereal.messaging as messaging from openpilot.common.constants import CV from openpilot.common.params import Params -from openpilot.common.realtime import config_realtime_process, Priority, Ratekeeper +from openpilot.common.realtime import config_realtime_process, DT_CTRL, Priority, Ratekeeper from openpilot.common.swaglog import cloudlog from opendbc.car.car_helpers import interfaces @@ -17,6 +17,7 @@ from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle, STEER_ANGLE_SATURATION_THRESHOLD from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.controls.lib.longcontrol import LongControl +from openpilot.selfdrive.modeld.modeld import LAT_SMOOTH_SECONDS from openpilot.selfdrive.locationd.helpers import PoseCalibrator, Pose State = log.SelfdriveState.OpenpilotState @@ -35,7 +36,7 @@ class Controls: self.CI = interfaces[self.CP.carFingerprint](self.CP) - self.sm = messaging.SubMaster(['liveParameters', 'liveTorqueParameters', 'modelV2', 'selfdriveState', + self.sm = messaging.SubMaster(['liveDelay', 'liveParameters', 'liveTorqueParameters', 'modelV2', 'selfdriveState', 'liveCalibration', 'livePose', 'longitudinalPlan', 'carState', 'carOutput', 'driverMonitoringState', 'onroadEvents', 'driverAssistance'], poll='selfdriveState') self.pm = messaging.PubMaster(['carControl', 'controlsState']) @@ -51,11 +52,11 @@ class Controls: self.VM = VehicleModel(self.CP) self.LaC: LatControl if self.CP.steerControlType == car.CarParams.SteerControlType.angle: - self.LaC = LatControlAngle(self.CP, self.CI) + self.LaC = LatControlAngle(self.CP, self.CI, DT_CTRL) elif self.CP.lateralTuning.which() == 'pid': - self.LaC = LatControlPID(self.CP, self.CI) + self.LaC = LatControlPID(self.CP, self.CI, DT_CTRL) elif self.CP.lateralTuning.which() == 'torque': - self.LaC = LatControlTorque(self.CP, self.CI) + self.LaC = LatControlTorque(self.CP, self.CI, DT_CTRL) def update(self): self.sm.update(15) @@ -117,11 +118,12 @@ class Controls: # Reset desired curvature to current to avoid violating the limits on engage new_desired_curvature = model_v2.action.desiredCurvature if CC.latActive else self.curvature self.desired_curvature, curvature_limited = clip_curvature(CS.vEgo, self.desired_curvature, new_desired_curvature, lp.roll) + lat_delay = self.sm["liveDelay"].lateralDelay + LAT_SMOOTH_SECONDS actuators.curvature = self.desired_curvature steer, steeringAngleDeg, lac_log = self.LaC.update(CC.latActive, CS, self.VM, lp, self.steer_limited_by_safety, self.desired_curvature, - curvature_limited) # TODO what if not available + curvature_limited, lat_delay) actuators.torque = float(steer) actuators.steeringAngleDeg = float(steeringAngleDeg) # Ensure no NaNs/Infs diff --git a/selfdrive/controls/lib/drive_helpers.py b/selfdrive/controls/lib/drive_helpers.py index e28fa3021c..bf6dd04f60 100644 --- a/selfdrive/controls/lib/drive_helpers.py +++ b/selfdrive/controls/lib/drive_helpers.py @@ -22,7 +22,7 @@ def smooth_value(val, prev_val, tau, dt=DT_MDL): alpha = 1 - np.exp(-dt/tau) if tau > 0 else 1 return alpha * val + (1 - alpha) * prev_val -def clip_curvature(v_ego, prev_curvature, new_curvature, roll): +def clip_curvature(v_ego, prev_curvature, new_curvature, roll) -> tuple[float, bool]: # This function respects ISO lateral jerk and acceleration limits + a max curvature v_ego = max(v_ego, MIN_SPEED) max_curvature_rate = MAX_LATERAL_JERK / (v_ego ** 2) # inexact calculation, check https://github.com/commaai/openpilot/pull/24755 diff --git a/selfdrive/controls/lib/latcontrol.py b/selfdrive/controls/lib/latcontrol.py index 2a8b873e2e..d69796738f 100644 --- a/selfdrive/controls/lib/latcontrol.py +++ b/selfdrive/controls/lib/latcontrol.py @@ -1,31 +1,29 @@ import numpy as np from abc import abstractmethod, ABC -from openpilot.common.realtime import DT_CTRL - class LatControl(ABC): - def __init__(self, CP, CI): - self.sat_count_rate = 1.0 * DT_CTRL + def __init__(self, CP, CI, dt): + self.dt = dt self.sat_limit = CP.steerLimitTimer - self.sat_count = 0. + self.sat_time = 0. self.sat_check_min_speed = 10. # we define the steer torque scale as [-1.0...1.0] self.steer_max = 1.0 @abstractmethod - def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, calibrated_pose, curvature_limited): + def update(self, active: bool, CS, VM, params, steer_limited_by_safety: bool, desired_curvature: float, curvature_limited: bool, lat_delay: float): pass def reset(self): - self.sat_count = 0. + self.sat_time = 0. def _check_saturation(self, saturated, CS, steer_limited_by_safety, curvature_limited): # Saturated only if control output is not being limited by car torque/angle rate limits if (saturated or curvature_limited) and CS.vEgo > self.sat_check_min_speed and not steer_limited_by_safety and not CS.steeringPressed: - self.sat_count += self.sat_count_rate + self.sat_time += self.dt else: - self.sat_count -= self.sat_count_rate - self.sat_count = np.clip(self.sat_count, 0.0, self.sat_limit) - return self.sat_count > (self.sat_limit - 1e-3) + self.sat_time -= self.dt + self.sat_time = np.clip(self.sat_time, 0.0, self.sat_limit) + return self.sat_time > (self.sat_limit - 1e-3) diff --git a/selfdrive/controls/lib/latcontrol_angle.py b/selfdrive/controls/lib/latcontrol_angle.py index ac35151487..808c9a659a 100644 --- a/selfdrive/controls/lib/latcontrol_angle.py +++ b/selfdrive/controls/lib/latcontrol_angle.py @@ -8,12 +8,12 @@ STEER_ANGLE_SATURATION_THRESHOLD = 2.5 # Degrees class LatControlAngle(LatControl): - def __init__(self, CP, CI): - super().__init__(CP, CI) + def __init__(self, CP, CI, dt): + super().__init__(CP, CI, dt) self.sat_check_min_speed = 5. self.use_steer_limited_by_safety = CP.brand == "tesla" - def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited): + def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited, lat_delay): angle_log = log.ControlsState.LateralAngleState.new_message() if not active: diff --git a/selfdrive/controls/lib/latcontrol_pid.py b/selfdrive/controls/lib/latcontrol_pid.py index 00a083509f..14ab9f21b5 100644 --- a/selfdrive/controls/lib/latcontrol_pid.py +++ b/selfdrive/controls/lib/latcontrol_pid.py @@ -6,14 +6,15 @@ from openpilot.common.pid import PIDController class LatControlPID(LatControl): - def __init__(self, CP, CI): - super().__init__(CP, CI) + def __init__(self, CP, CI, dt): + super().__init__(CP, CI, dt) self.pid = PIDController((CP.lateralTuning.pid.kpBP, CP.lateralTuning.pid.kpV), (CP.lateralTuning.pid.kiBP, CP.lateralTuning.pid.kiV), - k_f=CP.lateralTuning.pid.kf, pos_limit=self.steer_max, neg_limit=-self.steer_max) + pos_limit=self.steer_max, neg_limit=-self.steer_max) + self.ff_factor = CP.lateralTuning.pid.kf self.get_steer_feedforward = CI.get_steer_feedforward_function() - def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited): + def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited, lat_delay): pid_log = log.ControlsState.LateralPIDState.new_message() pid_log.steeringAngleDeg = float(CS.steeringAngleDeg) pid_log.steeringRateDeg = float(CS.steeringRateDeg) @@ -30,7 +31,7 @@ class LatControlPID(LatControl): else: # offset does not contribute to resistive torque - ff = self.get_steer_feedforward(angle_steers_des_no_offset, CS.vEgo) + ff = self.ff_factor * self.get_steer_feedforward(angle_steers_des_no_offset, CS.vEgo) freeze_integrator = steer_limited_by_safety or CS.steeringPressed or CS.vEgo < 5 output_torque = self.pid.update(error, diff --git a/selfdrive/controls/lib/latcontrol_torque.py b/selfdrive/controls/lib/latcontrol_torque.py index 5a2814e089..f2b1e0b669 100644 --- a/selfdrive/controls/lib/latcontrol_torque.py +++ b/selfdrive/controls/lib/latcontrol_torque.py @@ -1,9 +1,13 @@ import math import numpy as np +from collections import deque from cereal import log from opendbc.car.lateral import FRICTION_THRESHOLD, get_friction +from opendbc.car.tests.test_lateral_limits import MAX_LAT_JERK_UP from openpilot.common.constants import ACCELERATION_DUE_TO_GRAVITY +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.controls.lib.drive_helpers import MIN_SPEED from openpilot.selfdrive.controls.lib.latcontrol import LatControl from openpilot.common.pid import PIDController @@ -20,18 +24,24 @@ from openpilot.common.pid import PIDController LOW_SPEED_X = [0, 10, 20, 30] LOW_SPEED_Y = [15, 13, 10, 5] +KP = 1.0 +KI = 0.3 +KD = 0.0 class LatControlTorque(LatControl): - def __init__(self, CP, CI): - super().__init__(CP, CI) + def __init__(self, CP, CI, dt): + super().__init__(CP, CI, dt) self.torque_params = CP.lateralTuning.torque.as_builder() self.torque_from_lateral_accel = CI.torque_from_lateral_accel() self.lateral_accel_from_torque = CI.lateral_accel_from_torque() - self.pid = PIDController(self.torque_params.kp, self.torque_params.ki, - k_f=self.torque_params.kf) + self.pid = PIDController(KP, KI, k_d=KD, rate=1/self.dt) self.update_limits() self.steering_angle_deadzone_deg = self.torque_params.steeringAngleDeadzoneDeg + self.LATACCEL_REQUEST_BUFFER_NUM_FRAMES = int(1 / self.dt) + self.requested_lateral_accel_buffer = deque([0.] * self.LATACCEL_REQUEST_BUFFER_NUM_FRAMES , maxlen=self.LATACCEL_REQUEST_BUFFER_NUM_FRAMES) + self.previous_measurement = 0.0 + self.measurement_rate_filter = FirstOrderFilter(0.0, 1 / (2 * np.pi * (MAX_LAT_JERK_UP - 0.5)), self.dt) def update_live_torque_params(self, latAccelFactor, latAccelOffset, friction): self.torque_params.latAccelFactor = latAccelFactor @@ -43,37 +53,48 @@ class LatControlTorque(LatControl): self.pid.set_limits(self.lateral_accel_from_torque(self.steer_max, self.torque_params), self.lateral_accel_from_torque(-self.steer_max, self.torque_params)) - def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited): + def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited, lat_delay): pid_log = log.ControlsState.LateralTorqueState.new_message() if not active: output_torque = 0.0 pid_log.active = False else: - actual_curvature = -VM.calc_curvature(math.radians(CS.steeringAngleDeg - params.angleOffsetDeg), CS.vEgo, params.roll) + measured_curvature = -VM.calc_curvature(math.radians(CS.steeringAngleDeg - params.angleOffsetDeg), CS.vEgo, params.roll) roll_compensation = params.roll * ACCELERATION_DUE_TO_GRAVITY curvature_deadzone = abs(VM.calc_curvature(math.radians(self.steering_angle_deadzone_deg), CS.vEgo, 0.0)) - - desired_lateral_accel = desired_curvature * CS.vEgo ** 2 - actual_lateral_accel = actual_curvature * CS.vEgo ** 2 lateral_accel_deadzone = curvature_deadzone * CS.vEgo ** 2 - low_speed_factor = np.interp(CS.vEgo, LOW_SPEED_X, LOW_SPEED_Y)**2 - setpoint = desired_lateral_accel + low_speed_factor * desired_curvature - measurement = actual_lateral_accel + low_speed_factor * actual_curvature - gravity_adjusted_lateral_accel = desired_lateral_accel - roll_compensation + delay_frames = int(np.clip(lat_delay / self.dt, 1, self.LATACCEL_REQUEST_BUFFER_NUM_FRAMES)) + expected_lateral_accel = self.requested_lateral_accel_buffer[-delay_frames] + # TODO factor out lateral jerk from error to later replace it with delay independent alternative + future_desired_lateral_accel = desired_curvature * CS.vEgo ** 2 + self.requested_lateral_accel_buffer.append(future_desired_lateral_accel) + gravity_adjusted_future_lateral_accel = future_desired_lateral_accel - roll_compensation + desired_lateral_jerk = (future_desired_lateral_accel - expected_lateral_accel) / lat_delay + + measurement = measured_curvature * CS.vEgo ** 2 + measurement_rate = self.measurement_rate_filter.update((measurement - self.previous_measurement) / self.dt) + self.previous_measurement = measurement + + low_speed_factor = (np.interp(CS.vEgo, LOW_SPEED_X, LOW_SPEED_Y) / max(CS.vEgo, MIN_SPEED)) ** 2 + setpoint = lat_delay * desired_lateral_jerk + expected_lateral_accel + error = setpoint - measurement + error_lsf = error + low_speed_factor / KP * error # do error correction in lateral acceleration space, convert at end to handle non-linear torque responses correctly - pid_log.error = float(setpoint - measurement) - ff = gravity_adjusted_lateral_accel + pid_log.error = float(error_lsf) + ff = gravity_adjusted_future_lateral_accel # latAccelOffset corrects roll compensation bias from device roll misalignment relative to car roll ff -= self.torque_params.latAccelOffset - ff += get_friction(desired_lateral_accel - actual_lateral_accel, lateral_accel_deadzone, FRICTION_THRESHOLD, self.torque_params) + # TODO jerk is weighted by lat_delay for legacy reasons, but should be made independent of it + ff += get_friction(error, lateral_accel_deadzone, FRICTION_THRESHOLD, self.torque_params) freeze_integrator = steer_limited_by_safety or CS.steeringPressed or CS.vEgo < 5 output_lataccel = self.pid.update(pid_log.error, - feedforward=ff, - speed=CS.vEgo, - freeze_integrator=freeze_integrator) + -measurement_rate, + feedforward=ff, + speed=CS.vEgo, + freeze_integrator=freeze_integrator) output_torque = self.torque_from_lateral_accel(output_lataccel, self.torque_params) pid_log.active = True @@ -82,8 +103,8 @@ class LatControlTorque(LatControl): pid_log.d = float(self.pid.d) pid_log.f = float(self.pid.f) pid_log.output = float(-output_torque) # TODO: log lat accel? - pid_log.actualLateralAccel = float(actual_lateral_accel) - pid_log.desiredLateralAccel = float(desired_lateral_accel) + pid_log.actualLateralAccel = float(measurement) + pid_log.desiredLateralAccel = float(setpoint) pid_log.saturated = bool(self._check_saturation(self.steer_max - abs(output_torque) < 1e-3, CS, steer_limited_by_safety, curvature_limited)) # TODO left is positive in this convention diff --git a/selfdrive/controls/lib/longcontrol.py b/selfdrive/controls/lib/longcontrol.py index 6d4f922461..62dbc842c5 100644 --- a/selfdrive/controls/lib/longcontrol.py +++ b/selfdrive/controls/lib/longcontrol.py @@ -50,7 +50,7 @@ class LongControl: self.long_control_state = LongCtrlState.off self.pid = PIDController((CP.longitudinalTuning.kpBP, CP.longitudinalTuning.kpV), (CP.longitudinalTuning.kiBP, CP.longitudinalTuning.kiV), - k_f=CP.longitudinalTuning.kf, rate=1 / DT_CTRL) + rate=1 / DT_CTRL) self.last_output_accel = 0.0 def reset(self): diff --git a/selfdrive/controls/tests/test_latcontrol.py b/selfdrive/controls/tests/test_latcontrol.py index 0ce06dc996..354c7f00ad 100644 --- a/selfdrive/controls/tests/test_latcontrol.py +++ b/selfdrive/controls/tests/test_latcontrol.py @@ -7,6 +7,7 @@ from opendbc.car.toyota.values import CAR as TOYOTA from opendbc.car.nissan.values import CAR as NISSAN from opendbc.car.gm.values import CAR as GM from opendbc.car.vehicle_model import VehicleModel +from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle @@ -22,7 +23,7 @@ class TestLatControl: CI = CarInterface(CP) VM = VehicleModel(CP) - controller = controller(CP.as_reader(), CI) + controller = controller(CP.as_reader(), CI, DT_CTRL) CS = car.CarState.new_message() CS.vEgo = 30 @@ -32,13 +33,13 @@ class TestLatControl: # Saturate for curvature limited and controller limited for _ in range(1000): - _, _, lac_log = controller.update(True, CS, VM, params, False, 0, True) + _, _, lac_log = controller.update(True, CS, VM, params, False, 0, True, 0.2) assert lac_log.saturated for _ in range(1000): - _, _, lac_log = controller.update(True, CS, VM, params, False, 0, False) + _, _, lac_log = controller.update(True, CS, VM, params, False, 0, False, 0.2) assert not lac_log.saturated for _ in range(1000): - _, _, lac_log = controller.update(True, CS, VM, params, False, 1, False) + _, _, lac_log = controller.update(True, CS, VM, params, False, 1, False, 0.2) assert lac_log.saturated diff --git a/selfdrive/modeld/dmonitoringmodeld.py b/selfdrive/modeld/dmonitoringmodeld.py index 5aeb035bdc..2851a3e7da 100755 --- a/selfdrive/modeld/dmonitoringmodeld.py +++ b/selfdrive/modeld/dmonitoringmodeld.py @@ -25,13 +25,13 @@ from openpilot.selfdrive.modeld.runners.tinygrad_helpers import qcom_tensor_from MODEL_WIDTH, MODEL_HEIGHT = DM_INPUT_SIZE CALIB_LEN = 3 FEATURE_LEN = 512 -OUTPUT_SIZE = 84 + FEATURE_LEN +OUTPUT_SIZE = 83 + FEATURE_LEN PROCESS_NAME = "selfdrive.modeld.dmonitoringmodeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') MODEL_PKL_PATH = Path(__file__).parent / 'models/dmonitoring_model_tinygrad.pkl' - +# TODO: slice from meta class DriverStateResult(ctypes.Structure): _fields_ = [ ("face_orientation", ctypes.c_float*3), @@ -46,8 +46,8 @@ class DriverStateResult(ctypes.Structure): ("left_blink_prob", ctypes.c_float), ("right_blink_prob", ctypes.c_float), ("sunglasses_prob", ctypes.c_float), - ("occluded_prob", ctypes.c_float), - ("ready_prob", ctypes.c_float*4), + ("_unused_c", ctypes.c_float), + ("_unused_d", ctypes.c_float*4), ("not_ready_prob", ctypes.c_float*2)] @@ -55,7 +55,6 @@ class DMonitoringModelResult(ctypes.Structure): _fields_ = [ ("driver_state_lhd", DriverStateResult), ("driver_state_rhd", DriverStateResult), - ("poor_vision_prob", ctypes.c_float), ("wheel_on_right_prob", ctypes.c_float), ("features", ctypes.c_float*FEATURE_LEN)] @@ -107,8 +106,6 @@ def fill_driver_state(msg, ds_result: DriverStateResult): msg.leftBlinkProb = float(sigmoid(ds_result.left_blink_prob)) msg.rightBlinkProb = float(sigmoid(ds_result.right_blink_prob)) msg.sunglassesProb = float(sigmoid(ds_result.sunglasses_prob)) - msg.occludedProb = float(sigmoid(ds_result.occluded_prob)) - msg.readyProb = [float(sigmoid(x)) for x in ds_result.ready_prob] msg.notReadyProb = [float(sigmoid(x)) for x in ds_result.not_ready_prob] @@ -119,7 +116,6 @@ def get_driverstate_packet(model_output: np.ndarray, frame_id: int, location_ts: ds.frameId = frame_id ds.modelExecutionTime = execution_time ds.gpuExecutionTime = gpu_execution_time - ds.poorVisionProb = float(sigmoid(model_result.poor_vision_prob)) ds.wheelOnRightProb = float(sigmoid(model_result.wheel_on_right_prob)) ds.rawPredictions = model_output.tobytes() if SEND_RAW_PRED else b'' fill_driver_state(ds.leftDriverData, model_result.driver_state_lhd) diff --git a/selfdrive/modeld/models/README.md b/selfdrive/modeld/models/README.md index 255f28d80e..04b69c61c3 100644 --- a/selfdrive/modeld/models/README.md +++ b/selfdrive/modeld/models/README.md @@ -62,6 +62,5 @@ Refer to **slice_outputs** and **parse_vision_outputs/parse_policy_outputs** in * (deprecated) distracted probabilities: 2 * using phone probability: 1 * distracted probability: 1 - * common outputs 2 - * poor camera vision probability: 1 + * common outputs 1 * left hand drive probability: 1 diff --git a/selfdrive/modeld/models/dmonitoring_model.current b/selfdrive/modeld/models/dmonitoring_model.current deleted file mode 100644 index 121871ef2b..0000000000 --- a/selfdrive/modeld/models/dmonitoring_model.current +++ /dev/null @@ -1,2 +0,0 @@ -fa69be01-b430-4504-9d72-7dcb058eb6dd -d9fb22d1c4fa3ca3d201dbc8edf1d0f0918e53e6 diff --git a/selfdrive/modeld/models/dmonitoring_model.onnx b/selfdrive/modeld/models/dmonitoring_model.onnx index dcc727510e..5ae91f67a3 100644 --- a/selfdrive/modeld/models/dmonitoring_model.onnx +++ b/selfdrive/modeld/models/dmonitoring_model.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:50efe6451a3fb3fa04b6bb0e846544533329bd46ecefe9e657e91214dee2aaeb -size 7196502 +oid sha256:9b2117ee4907add59e3fbe6829cda74e0ad71c0835b0ebb9373ba9425de0d336 +size 7191776 diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index a9cb21a3f1..c2e5bc3fe4 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -38,8 +38,6 @@ class DRIVER_MONITOR_SETTINGS: self._EE_THRESH12 = 15.0 self._EE_MAX_OFFSET1 = 0.06 self._EE_MIN_OFFSET1 = 0.025 - self._EE_THRESH21 = 0.01 - self._EE_THRESH22 = 0.35 self._POSE_PITCH_THRESHOLD = 0.3133 self._POSE_PITCH_THRESHOLD_SLACK = 0.3237 @@ -137,11 +135,8 @@ class DriverMonitoring: self.pose = DriverPose(self.settings._POSE_OFFSET_MAX_COUNT) self.blink = DriverBlink() self.eev1 = 0. - self.eev2 = 1. self.ee1_offseter = RunningStatFilter(max_trackable=self.settings._POSE_OFFSET_MAX_COUNT) - self.ee2_offseter = RunningStatFilter(max_trackable=self.settings._POSE_OFFSET_MAX_COUNT) self.ee1_calibrated = False - self.ee2_calibrated = False self.always_on = always_on self.distracted_types = [] @@ -262,7 +257,7 @@ class DriverMonitoring: driver_data = driver_state.rightDriverData if self.wheel_on_right else driver_state.leftDriverData if not all(len(x) > 0 for x in (driver_data.faceOrientation, driver_data.facePosition, driver_data.faceOrientationStd, driver_data.facePositionStd, - driver_data.readyProb, driver_data.notReadyProb)): + driver_data.notReadyProb)): return self.face_detected = driver_data.faceProb > self.settings._FACE_THRESHOLD @@ -279,7 +274,6 @@ class DriverMonitoring: self.blink.right = driver_data.rightBlinkProb * (driver_data.rightEyeProb > self.settings._EYE_THRESHOLD) \ * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD) self.eev1 = driver_data.notReadyProb[0] - self.eev2 = driver_data.readyProb[0] self.distracted_types = self._get_distracted_types() self.driver_distracted = (DistractedType.DISTRACTED_E2E in self.distracted_types or DistractedType.DISTRACTED_POSE in self.distracted_types @@ -293,12 +287,10 @@ class DriverMonitoring: self.pose.pitch_offseter.push_and_update(self.pose.pitch) self.pose.yaw_offseter.push_and_update(self.pose.yaw) self.ee1_offseter.push_and_update(self.eev1) - self.ee2_offseter.push_and_update(self.eev2) self.pose.calibrated = self.pose.pitch_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT and \ self.pose.yaw_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT self.ee1_calibrated = self.ee1_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT - self.ee2_calibrated = self.ee2_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT self.is_model_uncertain = self.hi_stds > self.settings._HI_STD_FALLBACK_TIME self._set_timers(self.face_detected and not self.is_model_uncertain) diff --git a/selfdrive/monitoring/test_monitoring.py b/selfdrive/monitoring/test_monitoring.py index 2a20b20dc1..1cc7101880 100644 --- a/selfdrive/monitoring/test_monitoring.py +++ b/selfdrive/monitoring/test_monitoring.py @@ -25,7 +25,6 @@ def make_msg(face_detected, distracted=False, model_uncertain=False): ds.leftDriverData.faceOrientationStd = [1.*model_uncertain, 1.*model_uncertain, 1.*model_uncertain] ds.leftDriverData.facePositionStd = [1.*model_uncertain, 1.*model_uncertain] # TODO: test both separately when e2e is used - ds.leftDriverData.readyProb = [0., 0., 0., 0.] ds.leftDriverData.notReadyProb = [0., 0.] return ds diff --git a/selfdrive/test/process_replay/ref_commit b/selfdrive/test/process_replay/ref_commit index a833fadb94..dd73ed12f7 100644 --- a/selfdrive/test/process_replay/ref_commit +++ b/selfdrive/test/process_replay/ref_commit @@ -1 +1 @@ -afcab1abb62b9d5678342956cced4712f44e909e \ No newline at end of file +55e82ab6370865a1427ebc1d559921a5354d9cbf \ No newline at end of file diff --git a/selfdrive/test/setup_device_ci.sh b/selfdrive/test/setup_device_ci.sh index 98909bfb52..2a1442a20c 100755 --- a/selfdrive/test/setup_device_ci.sh +++ b/selfdrive/test/setup_device_ci.sh @@ -42,6 +42,7 @@ sudo systemctl restart NetworkManager sudo systemctl disable ssh-param-watcher.path sudo systemctl disable ssh-param-watcher.service sudo mount -o ro,remount / +sudo systemctl stop power_monitor while true; do if ! sudo systemctl is-active -q ssh; then @@ -54,7 +55,6 @@ while true; do # /data/ciui.py & #fi - awk '{print \$1}' /proc/uptime > /var/tmp/power_watchdog sleep 5s done diff --git a/selfdrive/test/test_onroad.py b/selfdrive/test/test_onroad.py index b4b9b9dbbe..0c3bdea4c8 100644 --- a/selfdrive/test/test_onroad.py +++ b/selfdrive/test/test_onroad.py @@ -32,7 +32,7 @@ CPU usage budget TEST_DURATION = 25 LOG_OFFSET = 8 -MAX_TOTAL_CPU = 300. # total for all 8 cores +MAX_TOTAL_CPU = 315. # total for all 8 cores PROCS = { # Baseline CPU usage by process "selfdrive.controls.controlsd": 16.0, @@ -42,7 +42,7 @@ PROCS = { "./encoderd": 13.0, "./camerad": 10.0, "selfdrive.controls.plannerd": 8.0, - "./ui": 18.0, + "selfdrive.ui.ui": 24.0, "system.sensord.sensord": 13.0, "selfdrive.controls.radard": 2.0, "selfdrive.modeld.modeld": 22.0, @@ -206,7 +206,8 @@ class TestOnroad: result += "-------------- UI Draw Timing ------------------\n" result += "------------------------------------------------\n" - ts = self.ts['uiDebug']['drawTimeMillis'] + # skip first few frames -- connecting to vipc + ts = self.ts['uiDebug']['drawTimeMillis'][15:] result += f"min {min(ts):.2f}ms\n" result += f"max {max(ts):.2f}ms\n" result += f"std {np.std(ts):.2f}ms\n" @@ -215,7 +216,7 @@ class TestOnroad: print(result) assert max(ts) < 250. - assert np.mean(ts) < 10. + assert np.mean(ts) < 20. # TODO: ~6-11ms, increase consistency #self.assertLess(np.std(ts), 5.) # some slow frames are expected since camerad/modeld can preempt ui @@ -285,7 +286,7 @@ class TestOnroad: # check for big leaks. note that memory usage is # expected to go up while the MSGQ buffers fill up - assert np.average(mems) <= 65, "Average memory usage above 65%" + assert np.average(mems) <= 85, "Average memory usage above 85%" assert np.max(np.diff(mems)) <= 4, "Max memory increase too high" assert np.average(np.diff(mems)) <= 1, "Average memory increase too high" diff --git a/selfdrive/ui/.gitignore b/selfdrive/ui/.gitignore index 7e9eaf932f..5e7b02a1a3 100644 --- a/selfdrive/ui/.gitignore +++ b/selfdrive/ui/.gitignore @@ -1,7 +1,7 @@ moc_* *.moc -translations/main_test_en.* +translations/test_en.* ui mui diff --git a/selfdrive/ui/SConscript b/selfdrive/ui/SConscript index ba9fa8b7a6..7ede8c394a 100644 --- a/selfdrive/ui/SConscript +++ b/selfdrive/ui/SConscript @@ -71,7 +71,7 @@ if GetOption('extras'): raylib_libs = common + ["raylib"] if arch == "larch64": - raylib_libs += ["GLESv2", "wayland-client", "wayland-egl", "EGL"] + raylib_libs += ["GLESv2", "EGL", "gbm", "drm"] else: raylib_libs += ["GL"] diff --git a/selfdrive/ui/installer/installer.cc b/selfdrive/ui/installer/installer.cc index a9b84b5c06..5cb0a38e0b 100644 --- a/selfdrive/ui/installer/installer.cc +++ b/selfdrive/ui/installer/installer.cc @@ -5,6 +5,7 @@ #include "common/swaglog.h" #include "common/util.h" +#include "system/hardware/hw.h" #include "third_party/raylib/include/raylib.h" int freshClone(); @@ -38,6 +39,27 @@ extern const uint8_t inter_ttf_end[] asm("_binary_selfdrive_ui_installer_inter_a Font font; +std::vector tici_prebuilt_branches = {"release3", "release-tizi", "release3-staging", "nightly", "nightly-dev"}; +std::string migrated_branch; + +void branchMigration() { + migrated_branch = BRANCH_STR; + cereal::InitData::DeviceType device_type = Hardware::get_device_type(); + if (device_type == cereal::InitData::DeviceType::TICI) { + if (std::find(tici_prebuilt_branches.begin(), tici_prebuilt_branches.end(), BRANCH_STR) != tici_prebuilt_branches.end()) { + migrated_branch = "release-tici"; + } else if (BRANCH_STR == "master") { + migrated_branch = "master-tici"; + } + } else if (device_type == cereal::InitData::DeviceType::TIZI) { + if (BRANCH_STR == "release3") { + migrated_branch = "release-tizi"; + } else if (BRANCH_STR == "release3-staging") { + migrated_branch = "release-tizi-staging"; + } + } +} + void run(const char* cmd) { int err = std::system(cmd); assert(err == 0); @@ -87,7 +109,7 @@ int doInstall() { int freshClone() { LOGD("Doing fresh clone"); std::string cmd = util::string_format("git clone --progress %s -b %s --depth=1 --recurse-submodules %s 2>&1", - GIT_URL.c_str(), BRANCH_STR.c_str(), TMP_INSTALL_PATH); + GIT_URL.c_str(), migrated_branch.c_str(), TMP_INSTALL_PATH); return executeGitCommand(cmd); } @@ -95,11 +117,11 @@ int cachedFetch(const std::string &cache) { LOGD("Fetching with cache: %s", cache.c_str()); run(util::string_format("cp -rp %s %s", cache.c_str(), TMP_INSTALL_PATH).c_str()); - run(util::string_format("cd %s && git remote set-branches --add origin %s", TMP_INSTALL_PATH, BRANCH_STR.c_str()).c_str()); + run(util::string_format("cd %s && git remote set-branches --add origin %s", TMP_INSTALL_PATH, migrated_branch.c_str()).c_str()); renderProgress(10); - return executeGitCommand(util::string_format("cd %s && git fetch --progress origin %s 2>&1", TMP_INSTALL_PATH, BRANCH_STR.c_str())); + return executeGitCommand(util::string_format("cd %s && git fetch --progress origin %s 2>&1", TMP_INSTALL_PATH, migrated_branch.c_str())); } int executeGitCommand(const std::string &cmd) { @@ -142,8 +164,8 @@ void cloneFinished(int exitCode) { // ensure correct branch is checked out int err = chdir(TMP_INSTALL_PATH); assert(err == 0); - run(("git checkout " + BRANCH_STR).c_str()); - run(("git reset --hard origin/" + BRANCH_STR).c_str()); + run(("git checkout " + migrated_branch).c_str()); + run(("git reset --hard origin/" + migrated_branch).c_str()); run("git submodule update --init"); // move into place @@ -193,6 +215,8 @@ int main(int argc, char *argv[]) { font = LoadFontFromMemory(".ttf", inter_ttf, inter_ttf_end - inter_ttf, FONT_SIZE, NULL, 0); SetTextureFilter(font.texture, TEXTURE_FILTER_BILINEAR); + branchMigration(); + if (util::file_exists(CONTINUE_PATH)) { finishInstall(); } else { diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py index 320e7477f1..34a7558cdc 100644 --- a/selfdrive/ui/layouts/home.py +++ b/selfdrive/ui/layouts/home.py @@ -8,7 +8,9 @@ from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButto from openpilot.selfdrive.ui.widgets.prime import PrimeWidget from openpilot.selfdrive.ui.widgets.setup import SetupWidget from openpilot.system.ui.lib.text_measure import measure_text_cached -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, DEFAULT_TEXT_COLOR +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.multilang import tr, trn +from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets import Widget HEADER_HEIGHT = 80 @@ -35,12 +37,16 @@ class HomeLayout(Widget): self.update_alert = UpdateAlert() self.offroad_alert = OffroadAlert() + self._layout_widgets = {HomeLayoutState.UPDATE: self.update_alert, HomeLayoutState.ALERTS: self.offroad_alert} + self.current_state = HomeLayoutState.HOME self.last_refresh = 0 self.settings_callback: callable | None = None self.update_available = False self.alert_count = 0 + self._prev_update_available = False + self._prev_alerts_present = False self.header_rect = rl.Rectangle(0, 0, 0, 0) self.content_rect = rl.Rectangle(0, 0, 0, 0) @@ -57,17 +63,29 @@ class HomeLayout(Widget): self._setup_callbacks() def show_event(self): + self._exp_mode_button.show_event() self.last_refresh = time.monotonic() self._refresh() def _setup_callbacks(self): self.update_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME)) self.offroad_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME)) + self._exp_mode_button.set_click_callback(lambda: self.settings_callback() if self.settings_callback else None) def set_settings_callback(self, callback: Callable): self.settings_callback = callback def _set_state(self, state: HomeLayoutState): + # propagate show/hide events + if state != self.current_state: + if state == HomeLayoutState.HOME: + self._exp_mode_button.show_event() + + if state in self._layout_widgets: + self._layout_widgets[state].show_event() + if self.current_state in self._layout_widgets: + self._layout_widgets[self.current_state].hide_event() + self.current_state = state def _render(self, rect: rl.Rectangle): @@ -86,7 +104,7 @@ class HomeLayout(Widget): elif self.current_state == HomeLayoutState.ALERTS: self._render_alerts_view() - def _update_layout_rects(self): + def _update_state(self): self.header_rect = rl.Rectangle( self._rect.x + CONTENT_MARGIN, self._rect.y + CONTENT_MARGIN, self._rect.width - 2 * CONTENT_MARGIN, HEADER_HEIGHT ) @@ -124,36 +142,43 @@ class HomeLayout(Widget): def _render_header(self): font = gui_app.font(FontWeight.MEDIUM) + version_text_width = self.header_rect.width + # Update notification button if self.update_available: + version_text_width -= self.update_notif_rect.width + # Highlight if currently viewing updates - highlight_color = rl.Color(255, 140, 40, 255) if self.current_state == HomeLayoutState.UPDATE else rl.Color(255, 102, 0, 255) + highlight_color = rl.Color(75, 95, 255, 255) if self.current_state == HomeLayoutState.UPDATE else rl.Color(54, 77, 239, 255) rl.draw_rectangle_rounded(self.update_notif_rect, 0.3, 10, highlight_color) - text = "UPDATE" - text_width = measure_text_cached(font, text, HEAD_BUTTON_FONT_SIZE).x - text_x = self.update_notif_rect.x + (self.update_notif_rect.width - text_width) // 2 - text_y = self.update_notif_rect.y + (self.update_notif_rect.height - HEAD_BUTTON_FONT_SIZE) // 2 + text = tr("UPDATE") + text_size = measure_text_cached(font, text, HEAD_BUTTON_FONT_SIZE) + text_x = self.update_notif_rect.x + (self.update_notif_rect.width - text_size.x) // 2 + text_y = self.update_notif_rect.y + (self.update_notif_rect.height - text_size.y) // 2 rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE) # Alert notification button if self.alert_count > 0: + version_text_width -= self.alert_notif_rect.width + # Highlight if currently viewing alerts highlight_color = rl.Color(255, 70, 70, 255) if self.current_state == HomeLayoutState.ALERTS else rl.Color(226, 44, 44, 255) rl.draw_rectangle_rounded(self.alert_notif_rect, 0.3, 10, highlight_color) - alert_text = f"{self.alert_count} ALERT{'S' if self.alert_count > 1 else ''}" - text_width = measure_text_cached(font, alert_text, HEAD_BUTTON_FONT_SIZE).x - text_x = self.alert_notif_rect.x + (self.alert_notif_rect.width - text_width) // 2 - text_y = self.alert_notif_rect.y + (self.alert_notif_rect.height - HEAD_BUTTON_FONT_SIZE) // 2 + alert_text = trn("{} ALERT", "{} ALERTS", self.alert_count).format(self.alert_count) + text_size = measure_text_cached(font, alert_text, HEAD_BUTTON_FONT_SIZE) + text_x = self.alert_notif_rect.x + (self.alert_notif_rect.width - text_size.x) // 2 + text_y = self.alert_notif_rect.y + (self.alert_notif_rect.height - text_size.y) // 2 rl.draw_text_ex(font, alert_text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE) # Version text (right aligned) - version_text = self._get_version_text() - text_width = measure_text_cached(gui_app.font(FontWeight.NORMAL), version_text, 48).x - version_x = self.header_rect.x + self.header_rect.width - text_width - version_y = self.header_rect.y + (self.header_rect.height - 48) // 2 - rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), version_text, rl.Vector2(int(version_x), int(version_y)), 48, 0, DEFAULT_TEXT_COLOR) + if self.update_available or self.alert_count > 0: + version_text_width -= SPACING * 1.5 + + version_rect = rl.Rectangle(self.header_rect.x + self.header_rect.width - version_text_width, self.header_rect.y, + version_text_width, self.header_rect.height) + gui_label(version_rect, self._get_version_text(), 48, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) def _render_home_content(self): self._render_left_column() @@ -185,19 +210,22 @@ class HomeLayout(Widget): def _refresh(self): # TODO: implement _update_state with a timer - self.update_available = self.update_alert.refresh() - self.alert_count = self.offroad_alert.refresh() - self._update_state_priority(self.update_available, self.alert_count > 0) - - def _update_state_priority(self, update_available: bool, alerts_present: bool): - current_state = self.current_state + update_available = self.update_alert.refresh() + alert_count = self.offroad_alert.refresh() + alerts_present = alert_count > 0 + # Show panels on transition from no alert/update to any alerts/update if not update_available and not alerts_present: - self.current_state = HomeLayoutState.HOME - elif update_available and (current_state == HomeLayoutState.HOME or (not alerts_present and current_state == HomeLayoutState.ALERTS)): - self.current_state = HomeLayoutState.UPDATE - elif alerts_present and (current_state == HomeLayoutState.HOME or (not update_available and current_state == HomeLayoutState.UPDATE)): - self.current_state = HomeLayoutState.ALERTS + self._set_state(HomeLayoutState.HOME) + elif update_available and ((not self._prev_update_available) or (not alerts_present and self.current_state == HomeLayoutState.ALERTS)): + self._set_state(HomeLayoutState.UPDATE) + elif alerts_present and ((not self._prev_alerts_present) or (not update_available and self.current_state == HomeLayoutState.UPDATE)): + self._set_state(HomeLayoutState.ALERTS) + + self.update_available = update_available + self.alert_count = alert_count + self._prev_update_available = update_available + self._prev_alerts_present = alerts_present def _get_version_text(self) -> str: brand = "openpilot" diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index ffb45f821d..702854f98a 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -8,10 +8,7 @@ from openpilot.selfdrive.ui.layouts.settings.settings import SettingsLayout, Pan from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.system.ui.widgets import Widget - - -ONROAD_FPS = 20 -OFFROAD_FPS = 60 +from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow class MainState(IntEnum): @@ -30,8 +27,6 @@ class MainLayout(Widget): self._current_mode = MainState.HOME self._prev_onroad = False - gui_app.set_target_fps(OFFROAD_FPS) - # Initialize layouts self._layouts = {MainState.HOME: HomeLayout(), MainState.SETTINGS: SettingsLayout(), MainState.ONROAD: AugmentedRoadView()} @@ -41,14 +36,21 @@ class MainLayout(Widget): # Set callbacks self._setup_callbacks() + # Start onboarding if terms or training not completed + self._onboarding_window = OnboardingWindow() + if not self._onboarding_window.completed: + gui_app.set_modal_overlay(self._onboarding_window) + def _render(self, _): self._handle_onroad_transition() self._render_main_content() def _setup_callbacks(self): self._sidebar.set_callbacks(on_settings=self._on_settings_clicked, - on_flag=self._on_bookmark_clicked) + on_flag=self._on_bookmark_clicked, + open_settings=lambda: self.open_settings(PanelType.TOGGLES)) self._layouts[MainState.HOME]._setup_widget.set_open_settings_callback(lambda: self.open_settings(PanelType.FIREHOSE)) + self._layouts[MainState.HOME].set_settings_callback(lambda: self.open_settings(PanelType.TOGGLES)) self._layouts[MainState.SETTINGS].set_callbacks(on_close=self._set_mode_for_state) self._layouts[MainState.ONROAD].set_click_callback(self._on_onroad_clicked) device.add_interactive_timeout_callback(self._set_mode_for_state) @@ -81,9 +83,6 @@ class MainLayout(Widget): self._current_mode = layout self._layouts[self._current_mode].show_event() - # No need to draw onroad faster than source (model at 20Hz) and prevents screen tearing - gui_app.set_target_fps(ONROAD_FPS if self._current_mode == MainState.ONROAD else OFFROAD_FPS) - def open_settings(self, panel_type: PanelType): self._layouts[MainState.SETTINGS].set_current_panel(panel_type) self._set_current_layout(MainState.SETTINGS) diff --git a/selfdrive/ui/layouts/onboarding.py b/selfdrive/ui/layouts/onboarding.py new file mode 100644 index 0000000000..df259a8fb5 --- /dev/null +++ b/selfdrive/ui/layouts/onboarding.py @@ -0,0 +1,214 @@ +import os +import re +import threading +from enum import IntEnum + +import pyray as rl +from openpilot.common.basedir import BASEDIR +from openpilot.system.ui.lib.application import FontWeight, gui_app +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.selfdrive.ui.ui_state import ui_state + +DEBUG = False + +STEP_RECTS = [rl.Rectangle(104, 800, 633, 175), rl.Rectangle(1835, 0, 2159, 1080), rl.Rectangle(1835, 0, 2156, 1080), + rl.Rectangle(1526, 473, 427, 472), rl.Rectangle(1643, 441, 217, 223), rl.Rectangle(1835, 0, 2155, 1080), + rl.Rectangle(1786, 591, 267, 236), rl.Rectangle(1353, 0, 804, 1080), rl.Rectangle(1458, 485, 633, 211), + rl.Rectangle(95, 794, 1158, 187), rl.Rectangle(1560, 170, 392, 397), rl.Rectangle(1835, 0, 2159, 1080), + rl.Rectangle(1351, 0, 807, 1080), rl.Rectangle(1835, 0, 2158, 1080), rl.Rectangle(1531, 82, 441, 920), + rl.Rectangle(1336, 438, 490, 393), rl.Rectangle(1835, 0, 2159, 1080), rl.Rectangle(1835, 0, 2159, 1080), + rl.Rectangle(87, 795, 1187, 186)] + +DM_RECORD_STEP = 9 +DM_RECORD_YES_RECT = rl.Rectangle(695, 794, 558, 187) + +RESTART_TRAINING_RECT = rl.Rectangle(87, 795, 472, 186) + + +class OnboardingState(IntEnum): + TERMS = 0 + ONBOARDING = 1 + DECLINE = 2 + + +class TrainingGuide(Widget): + def __init__(self, completed_callback=None): + super().__init__() + self._completed_callback = completed_callback + + self._step = 0 + self._load_image_paths() + + # Load first image now so we show something immediately + self._textures = [gui_app.texture(self._image_paths[0])] + self._image_objs = [] + + threading.Thread(target=self._preload_thread, daemon=True).start() + + def _load_image_paths(self): + paths = [fn for fn in os.listdir(os.path.join(BASEDIR, "selfdrive/assets/training")) if re.match(r'^step\d*\.png$', fn)] + paths = sorted(paths, key=lambda x: int(re.search(r'\d+', x).group())) + self._image_paths = [os.path.join(BASEDIR, "selfdrive/assets/training", fn) for fn in paths] + + def _preload_thread(self): + # PNG loading is slow in raylib, so we preload in a thread and upload to GPU in main thread + # We've already loaded the first image on init + for path in self._image_paths[1:]: + self._image_objs.append(gui_app._load_image_from_path(path)) + + def _handle_mouse_release(self, mouse_pos): + if rl.check_collision_point_rec(mouse_pos, STEP_RECTS[self._step]): + # Record DM camera? + if self._step == DM_RECORD_STEP: + yes = rl.check_collision_point_rec(mouse_pos, DM_RECORD_YES_RECT) + print(f"putting RecordFront to {yes}") + ui_state.params.put_bool("RecordFront", yes) + + # Restart training? + elif self._step == len(self._image_paths) - 1: + if rl.check_collision_point_rec(mouse_pos, RESTART_TRAINING_RECT): + self._step = -1 + + self._step += 1 + + # Finished? + if self._step >= len(self._image_paths): + self._step = 0 + if self._completed_callback: + self._completed_callback() + + def _update_state(self): + if len(self._image_objs): + self._textures.append(gui_app._load_texture_from_image(self._image_objs.pop(0))) + + def _render(self, _): + # Safeguard against fast tapping + step = min(self._step, len(self._textures) - 1) + rl.draw_texture(self._textures[step], 0, 0, rl.WHITE) + + # progress bar + if 0 < step < len(STEP_RECTS) - 1: + h = 20 + w = int((step / (len(STEP_RECTS) - 1)) * self._rect.width) + rl.draw_rectangle(int(self._rect.x), int(self._rect.y + self._rect.height - h), + w, h, rl.Color(70, 91, 234, 255)) + + if DEBUG: + rl.draw_rectangle_lines_ex(STEP_RECTS[step], 3, rl.RED) + + return -1 + + +class TermsPage(Widget): + def __init__(self, on_accept=None, on_decline=None): + super().__init__() + self._on_accept = on_accept + self._on_decline = on_decline + + self._title = Label(tr("Welcome to openpilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + self._desc = Label(tr("You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing."), + font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + + self._decline_btn = Button(tr("Decline"), click_callback=on_decline) + self._accept_btn = Button(tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept) + + def _render(self, _): + welcome_x = self._rect.x + 165 + 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 + # TODO: Label doesn't top align when wrapping + desc_y = welcome_y - 100 + desc_rect = rl.Rectangle(desc_x, desc_y, self._rect.width - desc_x, self._rect.height - desc_y - 250) + self._desc.render(desc_rect) + + btn_y = self._rect.y + self._rect.height - 160 - 45 + btn_width = (self._rect.width - 45 * 3) / 2 + self._decline_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160)) + self._accept_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160)) + + if DEBUG: + rl.draw_rectangle_lines_ex(welcome_rect, 3, rl.RED) + rl.draw_rectangle_lines_ex(desc_rect, 3, rl.RED) + + return -1 + + +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 openpilot."), + font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + self._back_btn = Button(tr("Back"), click_callback=back_callback) + self._uninstall_btn = Button(tr("Decline, uninstall openpilot"), button_style=ButtonStyle.DANGER, + click_callback=self._on_uninstall_clicked) + + def _on_uninstall_clicked(self): + ui_state.params.put_bool("DoUninstall", True) + gui_app.request_close() + + def _render(self, _): + btn_y = self._rect.y + self._rect.height - 160 - 45 + btn_width = (self._rect.width - 45 * 3) / 2 + self._back_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160)) + self._uninstall_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160)) + + # text rect in middle of top and button + text_height = btn_y - (200 + 45) + text_rect = rl.Rectangle(self._rect.x + 165, self._rect.y + (btn_y - text_height) / 2 + 10, self._rect.width - (165 * 2), text_height) + if DEBUG: + rl.draw_rectangle_lines_ex(text_rect, 3, rl.RED) + self._text.render(text_rect) + + +class OnboardingWindow(Widget): + def __init__(self): + super().__init__() + self._current_terms_version = ui_state.params.get("TermsVersion") + self._current_training_version = ui_state.params.get("TrainingVersion") + self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == self._current_terms_version + self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == self._current_training_version + + self._state = OnboardingState.TERMS if not self._accepted_terms else OnboardingState.ONBOARDING + + # Windows + self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined) + self._training_guide: TrainingGuide | None = None + self._decline_page = DeclinePage(back_callback=self._on_decline_back) + + @property + def completed(self) -> bool: + return self._accepted_terms and self._training_done + + def _on_terms_declined(self): + self._state = OnboardingState.DECLINE + + def _on_decline_back(self): + self._state = OnboardingState.TERMS + + def _on_terms_accepted(self): + ui_state.params.put("HasAcceptedTerms", self._current_terms_version) + self._state = OnboardingState.ONBOARDING + if self._training_done: + gui_app.set_modal_overlay(None) + + def _on_completed_training(self): + ui_state.params.put("CompletedTrainingVersion", self._current_training_version) + gui_app.set_modal_overlay(None) + + def _render(self, _): + if self._training_guide is None: + self._training_guide = TrainingGuide(completed_callback=self._on_completed_training) + + 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.DECLINE: + self._decline_page.render(self._rect) + return -1 diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py index cfef0f84d1..91268960cb 100644 --- a/selfdrive/ui/layouts/settings/developer.py +++ b/selfdrive/ui/layouts/settings/developer.py @@ -1,20 +1,29 @@ 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 from openpilot.system.ui.widgets.list_view import toggle_item from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import DialogResult # Description constants DESCRIPTIONS = { - 'enable_adb': ( + 'enable_adb': tr( "ADB (Android Debug Bridge) allows connecting to your device over USB or over the network. " + "See https://docs.comma.ai/how-to/connect-to-comma for more info." ), - 'joystick_debug_mode': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)", - 'ssh_key': ( + 'ssh_key': tr( "Warning: This grants SSH access to all public keys in your GitHub settings. Never enter a GitHub username " + "other than your own. A comma employee will NEVER ask you to add their GitHub username." ), + 'alpha_longitudinal': tr( + "WARNING: openpilot longitudinal control is in alpha for this car and will disable Automatic Emergency Braking (AEB).

" + + "On this car, openpilot defaults to the car's built-in ACC instead of openpilot's longitudinal control. " + + "Enable this to switch to openpilot longitudinal control. Enabling Experimental mode is recommended when enabling openpilot longitudinal control alpha." + ), } @@ -22,40 +31,143 @@ class DeveloperLayout(Widget): def __init__(self): super().__init__() self._params = Params() + self._is_release = self._params.get_bool("IsReleaseBranch") + + # Build items and keep references for callbacks/state updates + self._adb_toggle = toggle_item( + tr("Enable ADB"), + description=DESCRIPTIONS["enable_adb"], + initial_state=self._params.get_bool("AdbEnabled"), + callback=self._on_enable_adb, + enabled=ui_state.is_offroad, + ) + + # SSH enable toggle + SSH key management + self._ssh_toggle = toggle_item( + tr("Enable SSH"), + description="", + initial_state=self._params.get_bool("SshEnabled"), + callback=self._on_enable_ssh, + ) + self._ssh_keys = ssh_key_item("SSH Keys", description=DESCRIPTIONS["ssh_key"]) + + self._joystick_toggle = toggle_item( + tr("Joystick Debug Mode"), + description="", + initial_state=self._params.get_bool("JoystickDebugMode"), + callback=self._on_joystick_debug_mode, + enabled=ui_state.is_offroad, + ) + + self._long_maneuver_toggle = toggle_item( + tr("Longitudinal Maneuver Mode"), + description="", + initial_state=self._params.get_bool("LongitudinalManeuverMode"), + callback=self._on_long_maneuver_mode, + ) + + self._alpha_long_toggle = toggle_item( + tr("openpilot Longitudinal Control (Alpha)"), + description=DESCRIPTIONS["alpha_longitudinal"], + initial_state=self._params.get_bool("AlphaLongitudinalEnabled"), + callback=self._on_alpha_long_enabled, + enabled=lambda: not ui_state.engaged, + ) + + self._alpha_long_toggle.set_description(self._alpha_long_toggle.description + " Changing this setting will restart openpilot if the car is powered on.") + items = [ - toggle_item( - "Enable ADB", - description=DESCRIPTIONS["enable_adb"], - initial_state=self._params.get_bool("AdbEnabled"), - callback=self._on_enable_adb, - ), - ssh_key_item("SSH Key", description=DESCRIPTIONS["ssh_key"]), - toggle_item( - "Joystick Debug Mode", - description=DESCRIPTIONS["joystick_debug_mode"], - initial_state=self._params.get_bool("JoystickDebugMode"), - callback=self._on_joystick_debug_mode, - ), - toggle_item( - "Longitudinal Maneuver Mode", - description="", - initial_state=self._params.get_bool("LongitudinalManeuverMode"), - callback=self._on_long_maneuver_mode, - ), - toggle_item( - "openpilot Longitudinal Control (Alpha)", - description="", - initial_state=self._params.get_bool("AlphaLongitudinalEnabled"), - callback=self._on_alpha_long_enabled, - ), + self._adb_toggle, + self._ssh_toggle, + self._ssh_keys, + self._joystick_toggle, + self._long_maneuver_toggle, + self._alpha_long_toggle, ] self._scroller = Scroller(items, line_separator=True, spacing=0) + # Toggles should be not available to change in onroad state + ui_state.add_offroad_transition_callback(self._update_toggles) + def _render(self, rect): self._scroller.render(rect) - def _on_enable_adb(self): pass - def _on_joystick_debug_mode(self): pass - def _on_long_maneuver_mode(self): pass - def _on_alpha_long_enabled(self): pass + def show_event(self): + self._scroller.show_event() + self._update_toggles() + + def _update_toggles(self): + ui_state.update_params() + + # Hide non-release toggles on release builds + # TODO: we can do an onroad cycle, but alpha long toggle requires a deinit function to re-enable radar and not fault + for item in (self._adb_toggle, self._joystick_toggle, self._long_maneuver_toggle, self._alpha_long_toggle): + item.set_visible(not self._is_release) + + # CP gating + if ui_state.CP is not None: + alpha_avail = ui_state.CP.alphaLongitudinalAvailable + if not alpha_avail or self._is_release: + self._alpha_long_toggle.set_visible(False) + self._params.remove("AlphaLongitudinalEnabled") + else: + self._alpha_long_toggle.set_visible(True) + + long_man_enabled = ui_state.has_longitudinal_control and ui_state.is_offroad() + self._long_maneuver_toggle.action_item.set_enabled(long_man_enabled) + if not long_man_enabled: + self._long_maneuver_toggle.action_item.set_state(False) + self._params.put_bool("LongitudinalManeuverMode", False) + else: + self._long_maneuver_toggle.action_item.set_enabled(False) + self._alpha_long_toggle.set_visible(False) + + # 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 key, item in ( + ("AdbEnabled", self._adb_toggle), + ("SshEnabled", self._ssh_toggle), + ("JoystickDebugMode", self._joystick_toggle), + ("LongitudinalManeuverMode", self._long_maneuver_toggle), + ("AlphaLongitudinalEnabled", self._alpha_long_toggle), + ): + item.action_item.set_state(self._params.get_bool(key)) + + def _on_enable_adb(self, state: bool): + self._params.put_bool("AdbEnabled", state) + + def _on_enable_ssh(self, state: bool): + self._params.put_bool("SshEnabled", state) + + def _on_joystick_debug_mode(self, state: bool): + self._params.put_bool("JoystickDebugMode", state) + self._params.put_bool("LongitudinalManeuverMode", False) + self._long_maneuver_toggle.action_item.set_state(False) + + def _on_long_maneuver_mode(self, state: bool): + self._params.put_bool("LongitudinalManeuverMode", state) + self._params.put_bool("JoystickDebugMode", False) + self._joystick_toggle.action_item.set_state(False) + + def _on_alpha_long_enabled(self, state: bool): + if state: + def confirm_callback(result: int): + if result == DialogResult.CONFIRM: + self._params.put_bool("AlphaLongitudinalEnabled", True) + self._params.put_bool("OnroadCycleRequested", True) + self._update_toggles() + else: + self._alpha_long_toggle.action_item.set_state(False) + + # show confirmation dialog + content = (f"

{self._alpha_long_toggle.title}


" + + f"

{self._alpha_long_toggle.description}

") + + dlg = ConfirmDialog(content, tr("Enable"), rich=True) + gui_app.set_modal_overlay(dlg, callback=confirm_callback) + + else: + self._params.put_bool("AlphaLongitudinalEnabled", False) + self._params.put_bool("OnroadCycleRequested", True) + self._update_toggles() diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index 14847df102..e9bdf59e26 100644 --- a/selfdrive/ui/layouts/settings/device.py +++ b/selfdrive/ui/layouts/settings/device.py @@ -1,29 +1,31 @@ import os import json +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 +from openpilot.selfdrive.ui.layouts.onboarding import TrainingGuide from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog from openpilot.system.hardware import TICI from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget, DialogResult -from openpilot.system.ui.widgets.confirm_dialog import confirm_dialog, alert_dialog -from openpilot.system.ui.widgets.html_render import HtmlRenderer +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog +from openpilot.system.ui.widgets.html_render import HtmlModal from openpilot.system.ui.widgets.list_view import text_item, button_item, dual_button_item from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.system.ui.widgets.scroller import Scroller # Description constants DESCRIPTIONS = { - 'pair_device': "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer.", - 'driver_camera': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)", - 'reset_calibration': ( - "openpilot requires the device to be mounted within 4° left or right and within 5° " + - "up or 9° down. openpilot is continuously calibrating, resetting is rarely required." - ), - 'review_guide': "Review the rules, features, and limitations of openpilot", + 'pair_device': tr("Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer."), + 'driver_camera': tr("Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)"), + 'reset_calibration': tr("openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down."), + 'review_guide': tr("Review the rules, features, and limitations of openpilot"), } @@ -35,32 +37,47 @@ class DeviceLayout(Widget): self._select_language_dialog: MultiOptionDialog | None = None self._driver_camera: DriverCameraDialog | None = None self._pair_device_dialog: PairingDialog | None = None - self._fcc_dialog: HtmlRenderer | None = None + self._fcc_dialog: HtmlModal | None = None + self._training_guide: TrainingGuide | None = None items = self._initialize_items() self._scroller = Scroller(items, line_separator=True, spacing=0) + ui_state.add_offroad_transition_callback(self._offroad_transition) + def _initialize_items(self): - dongle_id = self._params.get("DongleId") or "N/A" - serial = self._params.get("HardwareSerial") or "N/A" + dongle_id = self._params.get("DongleId") or tr("N/A") + serial = self._params.get("HardwareSerial") or tr("N/A") - self._pair_device_btn = button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], callback=self._pair_device) + self._pair_device_btn = button_item(tr("Pair Device"), tr("PAIR"), DESCRIPTIONS['pair_device'], callback=self._pair_device) self._pair_device_btn.set_visible(lambda: not ui_state.prime_state.is_paired()) + self._reset_calib_btn = button_item(tr("Reset Calibration"), tr("RESET"), DESCRIPTIONS['reset_calibration'], callback=self._reset_calibration_prompt) + self._reset_calib_btn.set_description_opened_callback(self._update_calib_description) + + self._power_off_btn = dual_button_item(tr("Reboot"), tr("Power Off"), left_callback=self._reboot_prompt, right_callback=self._power_off_prompt) + items = [ - text_item("Dongle ID", dongle_id), - text_item("Serial", serial), + text_item(tr("Dongle ID"), dongle_id), + text_item(tr("Serial"), serial), self._pair_device_btn, - button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], callback=self._show_driver_camera, enabled=ui_state.is_offroad), - button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], callback=self._reset_calibration_prompt), - regulatory_btn := button_item("Regulatory", "VIEW", callback=self._on_regulatory), - button_item("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide), - button_item("Change Language", "CHANGE", callback=self._show_language_selection, enabled=ui_state.is_offroad), - dual_button_item("Reboot", "Power Off", left_callback=self._reboot_prompt, right_callback=self._power_off_prompt), + button_item(tr("Driver Camera"), tr("PREVIEW"), DESCRIPTIONS['driver_camera'], callback=self._show_driver_camera, enabled=ui_state.is_offroad), + self._reset_calib_btn, + button_item(tr("Review Training Guide"), tr("REVIEW"), DESCRIPTIONS['review_guide'], self._on_review_training_guide, enabled=ui_state.is_offroad), + regulatory_btn := button_item(tr("Regulatory"), tr("VIEW"), callback=self._on_regulatory, enabled=ui_state.is_offroad), + # TODO: implement multilang + # button_item(tr("Change Language"), tr("CHANGE"), callback=self._show_language_selection, enabled=ui_state.is_offroad), + self._power_off_btn, ] regulatory_btn.set_visible(TICI) return items + def _offroad_transition(self): + self._power_off_btn.action_item.right_button.set_visible(ui_state.is_offroad()) + + def show_event(self): + self._scroller.show_event() + def _render(self, rect): self._scroller.render(rect) @@ -90,34 +107,80 @@ class DeviceLayout(Widget): def _reset_calibration_prompt(self): if ui_state.engaged: - gui_app.set_modal_overlay(lambda: alert_dialog("Disengage to Reset Calibration")) - return - - gui_app.set_modal_overlay( - lambda: confirm_dialog("Are you sure you want to reset calibration?", "Reset"), - callback=self._reset_calibration, - ) - - def _reset_calibration(self, result: int): - if ui_state.engaged or result != DialogResult.CONFIRM: + gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Reset Calibration"))) return - self._params.remove("CalibrationParams") - self._params.remove("LiveTorqueParameters") - self._params.remove("LiveParameters") - self._params.remove("LiveParametersV2") - self._params.remove("LiveDelay") - self._params.put_bool("OnroadCycleRequested", True) + def reset_calibration(result: int): + # Check engaged again in case it changed while the dialog was open + if ui_state.engaged or result != DialogResult.CONFIRM: + return + + self._params.remove("CalibrationParams") + self._params.remove("LiveTorqueParameters") + self._params.remove("LiveParameters") + self._params.remove("LiveParametersV2") + self._params.remove("LiveDelay") + self._params.put_bool("OnroadCycleRequested", True) + self._update_calib_description() + + dialog = ConfirmDialog(tr("Are you sure you want to reset calibration?"), tr("Reset")) + gui_app.set_modal_overlay(dialog, callback=reset_calibration) + + def _update_calib_description(self): + desc = DESCRIPTIONS['reset_calibration'] + + calib_bytes = self._params.get("CalibrationParams") + if calib_bytes: + try: + calib = messaging.log_from_bytes(calib_bytes, log.Event).liveCalibration + + if calib.calStatus != log.LiveCalibrationData.Status.uncalibrated: + pitch = math.degrees(calib.rpyCalib[1]) + yaw = math.degrees(calib.rpyCalib[2]) + desc += tr(" Your device is pointed {:.1f}° {} and {:.1f}° {}.").format(abs(pitch), tr("down") if pitch > 0 else tr("up"), + abs(yaw), tr("left") if yaw > 0 else tr("right")) + except Exception: + cloudlog.exception("invalid CalibrationParams") + + lag_perc = 0 + lag_bytes = self._params.get("LiveDelay") + if lag_bytes: + try: + lag_perc = messaging.log_from_bytes(lag_bytes, log.Event).liveDelay.calPerc + except Exception: + cloudlog.exception("invalid LiveDelay") + if lag_perc < 100: + desc += tr("

Steering lag calibration is {}% complete.").format(lag_perc) + else: + desc += tr("

Steering lag calibration is complete.") + + torque_bytes = self._params.get("LiveTorqueParameters") + if torque_bytes: + try: + torque = messaging.log_from_bytes(torque_bytes, log.Event).liveTorqueParameters + # don't add for non-torque cars + if torque.useParams: + torque_perc = torque.calPerc + if torque_perc < 100: + desc += tr(" Steering torque response calibration is {}% complete.").format(torque_perc) + else: + desc += tr(" Steering torque response calibration is complete.") + except Exception: + cloudlog.exception("invalid LiveTorqueParameters") + + desc += "

" + desc += tr("openpilot is continuously calibrating, resetting is rarely required. " + + "Resetting calibration will restart openpilot if the car is powered on.") + + self._reset_calib_btn.set_description(desc) def _reboot_prompt(self): if ui_state.engaged: - gui_app.set_modal_overlay(lambda: alert_dialog("Disengage to Reboot")) + gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Reboot"))) return - gui_app.set_modal_overlay( - lambda: confirm_dialog("Are you sure you want to reboot?", "Reboot"), - callback=self._perform_reboot, - ) + dialog = ConfirmDialog(tr("Are you sure you want to reboot?"), tr("Reboot")) + gui_app.set_modal_overlay(dialog, callback=self._perform_reboot) def _perform_reboot(self, result: int): if not ui_state.engaged and result == DialogResult.CONFIRM: @@ -125,13 +188,11 @@ class DeviceLayout(Widget): def _power_off_prompt(self): if ui_state.engaged: - gui_app.set_modal_overlay(lambda: alert_dialog("Disengage to Power Off")) + gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Power Off"))) return - gui_app.set_modal_overlay( - lambda: confirm_dialog("Are you sure you want to power off?", "Power Off"), - callback=self._perform_power_off, - ) + dialog = ConfirmDialog(tr("Are you sure you want to power off?"), tr("Power Off")) + gui_app.set_modal_overlay(dialog, callback=self._perform_power_off) def _perform_power_off(self, result: int): if not ui_state.engaged and result == DialogResult.CONFIRM: @@ -144,10 +205,13 @@ class DeviceLayout(Widget): def _on_regulatory(self): if not self._fcc_dialog: - self._fcc_dialog = HtmlRenderer(os.path.join(BASEDIR, "selfdrive/assets/offroad/fcc.html")) + self._fcc_dialog = HtmlModal(os.path.join(BASEDIR, "selfdrive/assets/offroad/fcc.html")) + gui_app.set_modal_overlay(self._fcc_dialog) - gui_app.set_modal_overlay(self._fcc_dialog, - callback=lambda result: setattr(self, '_fcc_dialog', None), - ) + def _on_review_training_guide(self): + if not self._training_guide: + def completed_callback(): + gui_app.set_modal_overlay(None) - def _on_review_training_guide(self): pass + self._training_guide = TrainingGuide(completed_callback=completed_callback) + gui_app.set_modal_overlay(self._training_guide) diff --git a/selfdrive/ui/layouts/settings/firehose.py b/selfdrive/ui/layouts/settings/firehose.py index b3db1fa5f0..f4d70eaa47 100644 --- a/selfdrive/ui/layouts/settings/firehose.py +++ b/selfdrive/ui/layouts/settings/firehose.py @@ -7,21 +7,23 @@ from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.multilang import tr, trn +from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.lib.api_helpers import get_token -TITLE = "Firehose Mode" -DESCRIPTION = ( +TITLE = tr("Firehose Mode") +DESCRIPTION = tr( "openpilot learns to drive by watching humans, like you, drive.\n\n" + "Firehose Mode allows you to maximize your training data uploads to improve " + "openpilot's driving models. More data means bigger models, which means better Experimental Mode." ) -INSTRUCTIONS = ( +INSTRUCTIONS = tr( "For maximum effectiveness, bring your device inside and connect to a good USB-C adapter and Wi-Fi weekly.\n\n" - + "Firehose Mode can also work while you're driving if connected to a hotspot or unlimited SIM card.\n\n" + + "Firehose Mode can also work while you're driving if connected to a hotspot or unlimited SIM card.\n\n\n" + "Frequently Asked Questions\n\n" + "Does it matter how or where I drive? Nope, just drive as you normally would.\n\n" + "Do all of my segments get pulled in Firehose Mode? No, we selectively pull a subset of your segments.\n\n" @@ -43,12 +45,16 @@ class FirehoseLayout(Widget): self.params = Params() self.segment_count = self._get_segment_count() self.scroll_panel = GuiScrollPanel() + self._content_height = 0 self.running = True self.update_thread = threading.Thread(target=self._update_loop, daemon=True) self.update_thread.start() self.last_update_time = 0 + def show_event(self): + self.scroll_panel.set_offset(0) + def _get_segment_count(self) -> int: stats = self.params.get(self.PARAM_KEY) if not stats: @@ -66,97 +72,71 @@ class FirehoseLayout(Widget): def _render(self, rect: rl.Rectangle): # Calculate content dimensions - content_width = rect.width - 80 - content_height = self._calculate_content_height(int(content_width)) - content_rect = rl.Rectangle(rect.x, rect.y, rect.width, content_height) + content_rect = rl.Rectangle(rect.x, rect.y, rect.width, self._content_height) # Handle scrolling and render with clipping - scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect) + scroll_offset = self.scroll_panel.update(rect, content_rect) rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) - self._render_content(rect, scroll_offset) + self._content_height = self._render_content(rect, scroll_offset) rl.end_scissor_mode() - def _calculate_content_height(self, content_width: int) -> int: - height = 80 # Top margin - - # Title - height += 100 + 40 - - # Description - desc_font = gui_app.font(FontWeight.NORMAL) - desc_lines = wrap_text(desc_font, DESCRIPTION, 45, content_width) - height += len(desc_lines) * 45 + 40 - - # Status section - height += 32 # Separator - status_text, _ = self._get_status() - status_lines = wrap_text(gui_app.font(FontWeight.BOLD), status_text, 60, content_width) - height += len(status_lines) * 60 + 20 - - # Contribution count (if available) - if self.segment_count > 0: - contrib_text = f"{self.segment_count} segment(s) of your driving is in the training dataset so far." - contrib_lines = wrap_text(gui_app.font(FontWeight.BOLD), contrib_text, 52, content_width) - height += len(contrib_lines) * 52 + 20 - - # Instructions section - height += 32 # Separator - inst_lines = wrap_text(gui_app.font(FontWeight.NORMAL), INSTRUCTIONS, 40, content_width) - height += len(inst_lines) * 40 + 40 # Bottom margin - - return height - - def _render_content(self, rect: rl.Rectangle, scroll_offset: rl.Vector2): + def _render_content(self, rect: rl.Rectangle, scroll_offset: float) -> int: x = int(rect.x + 40) - y = int(rect.y + 40 + scroll_offset.y) + y = int(rect.y + 40 + scroll_offset) w = int(rect.width - 80) - # Title + # Title (centered) title_font = gui_app.font(FontWeight.MEDIUM) - rl.draw_text_ex(title_font, TITLE, rl.Vector2(x, y), 100, 0, rl.WHITE) - y += 140 + text_width = measure_text_cached(title_font, TITLE, 100).x + title_x = rect.x + (rect.width - text_width) / 2 + rl.draw_text_ex(title_font, TITLE, rl.Vector2(title_x, y), 100, 0, rl.WHITE) + y += 200 # Description y = self._draw_wrapped_text(x, y, w, DESCRIPTION, gui_app.font(FontWeight.NORMAL), 45, rl.WHITE) - y += 40 + y += 40 + 20 # Separator rl.draw_rectangle(x, y, w, 2, self.GRAY) - y += 30 + y += 30 + 20 # Status status_text, status_color = self._get_status() y = self._draw_wrapped_text(x, y, w, status_text, gui_app.font(FontWeight.BOLD), 60, status_color) - y += 20 + y += 20 + 20 # Contribution count (if available) if self.segment_count > 0: - contrib_text = f"{self.segment_count} segment(s) of your driving is in the training dataset so far." + contrib_text = trn("{} segment of your driving is in the training dataset so far.", + "{} segments of your driving is in the training dataset so far.", self.segment_count).format(self.segment_count) y = self._draw_wrapped_text(x, y, w, contrib_text, gui_app.font(FontWeight.BOLD), 52, rl.WHITE) - y += 20 + y += 20 + 20 # Separator rl.draw_rectangle(x, y, w, 2, self.GRAY) - y += 30 + y += 30 + 20 # Instructions - self._draw_wrapped_text(x, y, w, INSTRUCTIONS, gui_app.font(FontWeight.NORMAL), 40, self.LIGHT_GRAY) + y = self._draw_wrapped_text(x, y, w, INSTRUCTIONS, gui_app.font(FontWeight.NORMAL), 40, self.LIGHT_GRAY) + + # bottom margin + remove effect of scroll offset + return int(round(y - self.scroll_panel.offset + 40)) - def _draw_wrapped_text(self, x, y, width, text, font, size, color): - wrapped = wrap_text(font, text, size, width) + def _draw_wrapped_text(self, x, y, width, text, font, font_size, color): + wrapped = wrap_text(font, text, font_size, width) for line in wrapped: - rl.draw_text_ex(font, line, rl.Vector2(x, y), size, 0, color) - y += size - return y + rl.draw_text_ex(font, line, rl.Vector2(x, y), font_size, 0, color) + y += font_size * FONT_SCALE + return round(y) def _get_status(self) -> tuple[str, rl.Color]: network_type = ui_state.sm["deviceState"].networkType network_metered = ui_state.sm["deviceState"].networkMetered if not network_metered and network_type != 0: # Not metered and connected - return "ACTIVE", self.GREEN + return tr("ACTIVE"), self.GREEN else: - return "INACTIVE: connect to an unmetered network", self.RED + return tr("INACTIVE: connect to an unmetered network"), self.RED def _fetch_firehose_stats(self): try: diff --git a/selfdrive/ui/layouts/settings/settings.py b/selfdrive/ui/layouts/settings/settings.py index d43382f199..7f431d3a77 100644 --- a/selfdrive/ui/layouts/settings/settings.py +++ b/selfdrive/ui/layouts/settings/settings.py @@ -8,6 +8,7 @@ from openpilot.selfdrive.ui.layouts.settings.firehose import FirehoseLayout from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.multilang import tr 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 @@ -58,12 +59,12 @@ class SettingsLayout(Widget): wifi_manager.set_active(False) self._panels = { - PanelType.DEVICE: PanelInfo("Device", DeviceLayout()), - PanelType.NETWORK: PanelInfo("Network", NetworkUI(wifi_manager)), - PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayout()), - PanelType.SOFTWARE: PanelInfo("Software", SoftwareLayout()), - PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayout()), - PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayout()), + PanelType.DEVICE: PanelInfo(tr("Device"), DeviceLayout()), + PanelType.NETWORK: PanelInfo(tr("Network"), NetworkUI(wifi_manager)), + PanelType.TOGGLES: PanelInfo(tr("Toggles"), TogglesLayout()), + PanelType.SOFTWARE: PanelInfo(tr("Software"), SoftwareLayout()), + PanelType.FIREHOSE: PanelInfo(tr("Firehose"), FirehoseLayout()), + PanelType.DEVELOPER: PanelInfo(tr("Developer"), DeveloperLayout()), } self._font_medium = gui_app.font(FontWeight.MEDIUM) diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index 4361725a1b..8e0cfdbc3c 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -1,42 +1,166 @@ -from openpilot.common.params import Params +import os +import time +import datetime +from openpilot.common.time_helpers import system_time_valid +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, trn from openpilot.system.ui.widgets import Widget, DialogResult -from openpilot.system.ui.widgets.confirm_dialog import confirm_dialog -from openpilot.system.ui.widgets.list_view import button_item, text_item +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog +from openpilot.system.ui.widgets.list_view import button_item, text_item, ListItem from openpilot.system.ui.widgets.scroller import Scroller +# TODO: remove this. updater fails to respond on startup if time is not correct +UPDATED_TIMEOUT = 10 # seconds to wait for updated to respond + + +def time_ago(date: datetime.datetime | None) -> str: + if not date: + return tr("never") + + if not system_time_valid(): + return date.strftime("%a %b %d %Y") + + now = datetime.datetime.now(datetime.UTC) + if date.tzinfo is None: + date = date.replace(tzinfo=datetime.UTC) + + diff_seconds = int((now - date).total_seconds()) + if diff_seconds < 60: + return tr("now") + if diff_seconds < 3600: + m = diff_seconds // 60 + return trn("{} minute ago", "{} minutes ago", m).format(m) + if diff_seconds < 86400: + h = diff_seconds // 3600 + return trn("{} hour ago", "{} hours ago", h).format(h) + if diff_seconds < 604800: + d = diff_seconds // 86400 + return trn("{} day ago", "{} days ago", d).format(d) + return date.strftime("%a %b %d %Y") + class SoftwareLayout(Widget): def __init__(self): super().__init__() - self._params = Params() + self._onroad_label = ListItem(title=tr("Updates are only downloaded while the car is off.")) + self._version_item = text_item(tr("Current Version"), ui_state.params.get("UpdaterCurrentDescription") or "") + self._download_btn = button_item(tr("Download"), tr("CHECK"), callback=self._on_download_update) + + # Install button is initially hidden + self._install_btn = button_item(tr("Install Update"), tr("INSTALL"), callback=self._on_install_update) + self._install_btn.set_visible(False) + + # Track waiting-for-updater transition to avoid brief re-enable while still idle + self._waiting_for_updater = False + self._waiting_start_ts: float = 0.0 + items = self._init_items() self._scroller = Scroller(items, line_separator=True, spacing=0) def _init_items(self): items = [ - text_item("Current Version", ""), - button_item("Download", "CHECK", callback=self._on_download_update), - button_item("Install Update", "INSTALL", callback=self._on_install_update), - button_item("Target Branch", "SELECT", callback=self._on_select_branch), - button_item("Uninstall", "UNINSTALL", callback=self._on_uninstall), + self._onroad_label, + self._version_item, + self._download_btn, + self._install_btn, + # TODO: implement branch switching + # button_item("Target Branch", "SELECT", callback=self._on_select_branch), + button_item("Uninstall", tr("UNINSTALL"), callback=self._on_uninstall), ] return items + def show_event(self): + self._scroller.show_event() + def _render(self, rect): self._scroller.render(rect) - def _on_download_update(self): pass - def _on_install_update(self): pass - def _on_select_branch(self): pass + def _update_state(self): + # Show/hide onroad warning + self._onroad_label.set_visible(ui_state.is_onroad()) + + # Update current version and release notes + current_desc = ui_state.params.get("UpdaterCurrentDescription") or "" + current_release_notes = (ui_state.params.get("UpdaterCurrentReleaseNotes") or b"").decode("utf-8", "replace") + self._version_item.action_item.set_text(current_desc) + self._version_item.set_description(current_release_notes) + + # Update download button visibility and state + self._download_btn.set_visible(ui_state.is_offroad()) + + updater_state = ui_state.params.get("UpdaterState") or "idle" + failed_count = ui_state.params.get("UpdateFailedCount") or 0 + fetch_available = ui_state.params.get_bool("UpdaterFetchAvailable") + update_available = ui_state.params.get_bool("UpdateAvailable") + + if updater_state != "idle": + # Updater responded + self._waiting_for_updater = False + self._download_btn.action_item.set_enabled(False) + self._download_btn.action_item.set_value(updater_state) + else: + if failed_count > 0: + self._download_btn.action_item.set_value(tr("failed to check for update")) + self._download_btn.action_item.set_text(tr("CHECK")) + elif fetch_available: + self._download_btn.action_item.set_value(tr("update available")) + self._download_btn.action_item.set_text(tr("DOWNLOAD")) + else: + last_update = ui_state.params.get("LastUpdateTime") + if last_update: + formatted = time_ago(last_update) + self._download_btn.action_item.set_value(tr("up to date, last checked {}").format(formatted)) + else: + self._download_btn.action_item.set_value(tr("up to date, last checked never")) + self._download_btn.action_item.set_text(tr("CHECK")) + + # If we've been waiting too long without a state change, reset state + if self._waiting_for_updater and (time.monotonic() - self._waiting_start_ts > UPDATED_TIMEOUT): + self._waiting_for_updater = False + + # Only enable if we're not waiting for updater to flip out of idle + self._download_btn.action_item.set_enabled(not self._waiting_for_updater) + + # Update install button + self._install_btn.set_visible(ui_state.is_offroad() and update_available) + if update_available: + new_desc = ui_state.params.get("UpdaterNewDescription") or "" + new_release_notes = (ui_state.params.get("UpdaterNewReleaseNotes") or b"").decode("utf-8", "replace") + self._install_btn.action_item.set_text(tr("INSTALL")) + self._install_btn.action_item.set_value(new_desc) + self._install_btn.set_description(new_release_notes) + # Enable install button for testing (like Qt showEvent) + self._install_btn.action_item.set_enabled(True) + else: + self._install_btn.set_visible(False) + + def _on_download_update(self): + # Check if we should start checking or start downloading + self._download_btn.action_item.set_enabled(False) + if self._download_btn.action_item.text == tr("CHECK"): + # Start checking for updates + self._waiting_for_updater = True + self._waiting_start_ts = time.monotonic() + os.system("pkill -SIGUSR1 -f system.updated.updated") + else: + # Start downloading + self._waiting_for_updater = True + self._waiting_start_ts = time.monotonic() + os.system("pkill -SIGHUP -f system.updated.updated") def _on_uninstall(self): def handle_uninstall_confirmation(result): if result == DialogResult.CONFIRM: - self._params.put_bool("DoUninstall", True) + ui_state.params.put_bool("DoUninstall", True) + + dialog = ConfirmDialog(tr("Are you sure you want to uninstall?"), tr("Uninstall")) + gui_app.set_modal_overlay(dialog, callback=handle_uninstall_confirmation) - gui_app.set_modal_overlay( - lambda: confirm_dialog("Are you sure you want to uninstall?", "Uninstall"), - callback=handle_uninstall_confirmation, - ) + def _on_install_update(self): + # Trigger reboot to install update + self._install_btn.action_item.set_enabled(False) + ui_state.params.put_bool("DoReboot", True) + + def _on_select_branch(self): pass diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 58afcec5ef..1e4d5c6c85 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -1,28 +1,36 @@ -from openpilot.common.params import Params +from cereal import log +from openpilot.common.params import Params, UnknownKeyName from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import DialogResult +from openpilot.selfdrive.ui.ui_state import ui_state + +PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants # Description constants DESCRIPTIONS = { - "OpenpilotEnabledToggle": ( + "OpenpilotEnabledToggle": tr( "Use the openpilot system for adaptive cruise control and lane keep driver assistance. " + "Your attention is required at all times to use this feature." ), - "DisengageOnAccelerator": "When enabled, pressing the accelerator pedal will disengage openpilot.", - "LongitudinalPersonality": ( + "DisengageOnAccelerator": tr("When enabled, pressing the accelerator pedal will disengage openpilot."), + "LongitudinalPersonality": tr( "Standard is recommended. In aggressive mode, openpilot will follow lead cars closer and be more aggressive with the gas and brake. " + "In relaxed mode openpilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " + "your steering wheel distance button." ), - "IsLdwEnabled": ( + "IsLdwEnabled": tr( "Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " + "without a turn signal activated while driving over 31 mph (50 km/h)." ), - "AlwaysOnDM": "Enable driver monitoring even when openpilot is not engaged.", - 'RecordFront': "Upload data from the driver facing camera and help improve the driver monitoring algorithm.", - "IsMetric": "Display speed in km/h instead of mph.", - "RecordAudio": "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect.", + "AlwaysOnDM": tr("Enable driver monitoring even when openpilot is not engaged."), + 'RecordFront': tr("Upload data from the driver facing camera and help improve the driver monitoring algorithm."), + "IsMetric": tr("Display speed in km/h instead of mph."), + "RecordAudio": tr("Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect."), } @@ -30,66 +38,204 @@ class TogglesLayout(Widget): def __init__(self): super().__init__() self._params = Params() - items = [ - toggle_item( - "Enable openpilot", + self._is_release = self._params.get_bool("IsReleaseBranch") + + # param, title, desc, icon, needs_restart + self._toggle_defs = { + "OpenpilotEnabledToggle": ( + tr("Enable openpilot"), DESCRIPTIONS["OpenpilotEnabledToggle"], - self._params.get_bool("OpenpilotEnabledToggle"), - icon="chffr_wheel.png", + "chffr_wheel.png", + True, ), - toggle_item( - "Experimental Mode", - initial_state=self._params.get_bool("ExperimentalMode"), - icon="experimental_white.png", + "ExperimentalMode": ( + tr("Experimental Mode"), + "", + "experimental_white.png", + False, ), - toggle_item( - "Disengage on Accelerator Pedal", + "DisengageOnAccelerator": ( + tr("Disengage on Accelerator Pedal"), DESCRIPTIONS["DisengageOnAccelerator"], - self._params.get_bool("DisengageOnAccelerator"), - icon="disengage_on_accelerator.png", + "disengage_on_accelerator.png", + False, ), - multiple_button_item( - "Driving Personality", - DESCRIPTIONS["LongitudinalPersonality"], - buttons=["Aggressive", "Standard", "Relaxed"], - button_width=255, - callback=self._set_longitudinal_personality, - selected_index=self._params.get("LongitudinalPersonality", return_default=True), - icon="speed_limit.png" - ), - toggle_item( - "Enable Lane Departure Warnings", + "IsLdwEnabled": ( + tr("Enable Lane Departure Warnings"), DESCRIPTIONS["IsLdwEnabled"], - self._params.get_bool("IsLdwEnabled"), - icon="warning.png", + "warning.png", + False, ), - toggle_item( - "Always-On Driver Monitoring", + "AlwaysOnDM": ( + tr("Always-On Driver Monitoring"), DESCRIPTIONS["AlwaysOnDM"], - self._params.get_bool("AlwaysOnDM"), - icon="monitoring.png", + "monitoring.png", + False, ), - toggle_item( - "Record and Upload Driver Camera", + "RecordFront": ( + tr("Record and Upload Driver Camera"), DESCRIPTIONS["RecordFront"], - self._params.get_bool("RecordFront"), - icon="monitoring.png", + "monitoring.png", + True, ), - toggle_item( - "Record Microphone Audio", + "RecordAudio": ( + tr("Record and Upload Microphone Audio"), DESCRIPTIONS["RecordAudio"], - self._params.get_bool("RecordAudio"), - icon="microphone.png", + "microphone.png", + True, ), - toggle_item( - "Use Metric System", DESCRIPTIONS["IsMetric"], self._params.get_bool("IsMetric"), icon="metric.png" + "IsMetric": ( + tr("Use Metric System"), + DESCRIPTIONS["IsMetric"], + "metric.png", + False, ), - ] + } + + self._long_personality_setting = multiple_button_item( + tr("Driving Personality"), + DESCRIPTIONS["LongitudinalPersonality"], + buttons=[tr("Aggressive"), tr("Standard"), tr("Relaxed")], + button_width=255, + callback=self._set_longitudinal_personality, + selected_index=self._params.get("LongitudinalPersonality", return_default=True), + icon="speed_limit.png" + ) + + self._toggles = {} + self._locked_toggles = set() + for param, (title, desc, icon, needs_restart) in self._toggle_defs.items(): + toggle = toggle_item( + title, + desc, + self._params.get_bool(param), + callback=lambda state, p=param: self._toggle_callback(state, p), + icon=icon, + ) + + try: + locked = self._params.get_bool(param + "Lock") + except UnknownKeyName: + locked = False + toggle.action_item.set_enabled(not locked) + + if needs_restart and not locked: + toggle.set_description(toggle.description + tr(" Changing this setting will restart openpilot if the car is powered on.")) + + # track for engaged state updates + if locked: + self._locked_toggles.add(param) + + self._toggles[param] = toggle + + # insert longitudinal personality after NDOG toggle + if param == "DisengageOnAccelerator": + self._toggles["LongitudinalPersonality"] = self._long_personality_setting + + self._update_experimental_mode_icon() + self._scroller = Scroller(list(self._toggles.values()), line_separator=True, spacing=0) + + ui_state.add_engaged_transition_callback(self._update_toggles) + + def _update_state(self): + if ui_state.sm.updated["selfdriveState"]: + personality = PERSONALITY_TO_INT[ui_state.sm["selfdriveState"].personality] + if personality != ui_state.personality and ui_state.started: + self._long_personality_setting.action_item.set_selected_button(personality) + ui_state.personality = personality + + def show_event(self): + self._scroller.show_event() + self._update_toggles() - self._scroller = Scroller(items, line_separator=True, spacing=0) + def _update_toggles(self): + ui_state.update_params() + + e2e_description = tr( + "openpilot defaults to driving in chill mode. Experimental mode enables alpha-level features that aren't ready for chill mode. " + + "Experimental features are listed below:
" + + "

End-to-End Longitudinal Control


" + + "Let the driving model control the gas and brakes. openpilot will drive as it thinks a human would, including stopping for red lights and stop signs. " + + "Since the driving model decides the speed to drive, the set speed will only act as an upper bound. This is an alpha quality feature; " + + "mistakes should be expected.
" + + "

New Driving Visualization


" + + "The driving visualization will transition to the road-facing wide-angle camera at low speeds to better show some turns. " + + "The Experimental mode logo will also be shown in the top right corner." + ) + + if ui_state.CP is not None: + if ui_state.has_longitudinal_control: + self._toggles["ExperimentalMode"].action_item.set_enabled(True) + self._toggles["ExperimentalMode"].set_description(e2e_description) + self._long_personality_setting.action_item.set_enabled(True) + else: + # no long for now + self._toggles["ExperimentalMode"].action_item.set_enabled(False) + self._toggles["ExperimentalMode"].action_item.set_state(False) + self._long_personality_setting.action_item.set_enabled(False) + self._params.remove("ExperimentalMode") + + unavailable = tr("Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control.") + + long_desc = unavailable + " " + tr("openpilot longitudinal control may come in a future update.") + if ui_state.CP.alphaLongitudinalAvailable: + if self._is_release: + long_desc = unavailable + " " + tr("An alpha version of openpilot longitudinal control can be tested, along with " + + "Experimental mode, on non-release branches.") + else: + long_desc = tr("Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode.") + + self._toggles["ExperimentalMode"].set_description("" + long_desc + "

" + e2e_description) + else: + self._toggles["ExperimentalMode"].set_description(e2e_description) + + 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: + self._toggles[toggle_def].action_item.set_enabled(not ui_state.engaged) def _render(self, rect): self._scroller.render(rect) + def _update_experimental_mode_icon(self): + icon = "experimental.png" if self._toggles["ExperimentalMode"].action_item.get_state() else "experimental_white.png" + self._toggles["ExperimentalMode"].set_icon(icon) + + def _handle_experimental_mode_toggle(self, state: bool): + confirmed = self._params.get_bool("ExperimentalModeConfirmed") + if state and not confirmed: + def confirm_callback(result: int): + if result == DialogResult.CONFIRM: + self._params.put_bool("ExperimentalMode", True) + self._params.put_bool("ExperimentalModeConfirmed", True) + else: + self._toggles["ExperimentalMode"].action_item.set_state(False) + self._update_experimental_mode_icon() + + # show confirmation dialog + content = (f"

{self._toggles['ExperimentalMode'].title}


" + + f"

{self._toggles['ExperimentalMode'].description}

") + dlg = ConfirmDialog(content, tr("Enable"), rich=True) + gui_app.set_modal_overlay(dlg, callback=confirm_callback) + else: + self._update_experimental_mode_icon() + self._params.put_bool("ExperimentalMode", state) + + def _toggle_callback(self, state: bool, param: str): + if param == "ExperimentalMode": + self._handle_experimental_mode_toggle(state) + return + + self._params.put_bool(param, state) + if self._toggle_defs[param][3]: + self._params.put_bool("OnroadCycleRequested", True) + def _set_longitudinal_personality(self, button_index: int): self._params.put("LongitudinalPersonality", button_index) diff --git a/selfdrive/ui/layouts/sidebar.py b/selfdrive/ui/layouts/sidebar.py index 4d47a9878c..48b577ea59 100644 --- a/selfdrive/ui/layouts/sidebar.py +++ b/selfdrive/ui/layouts/sidebar.py @@ -4,7 +4,8 @@ from dataclasses import dataclass from collections.abc import Callable from cereal import log from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, FONT_SCALE +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget @@ -23,7 +24,6 @@ NetworkType = log.DeviceState.NetworkType # Color scheme class Colors: - SIDEBAR_BG = rl.Color(57, 57, 57, 255) WHITE = rl.WHITE WHITE_DIM = rl.Color(255, 255, 255, 85) GRAY = rl.Color(84, 84, 84, 255) @@ -40,13 +40,13 @@ class Colors: NETWORK_TYPES = { - NetworkType.none: "Offline", - NetworkType.wifi: "WiFi", - NetworkType.cell2G: "2G", - NetworkType.cell3G: "3G", - NetworkType.cell4G: "LTE", - NetworkType.cell5G: "5G", - NetworkType.ethernet: "Ethernet", + NetworkType.none: tr("--"), + NetworkType.wifi: tr("Wi-Fi"), + NetworkType.ethernet: tr("ETH"), + NetworkType.cell2G: tr("2G"), + NetworkType.cell3G: tr("3G"), + NetworkType.cell4G: tr("LTE"), + NetworkType.cell5G: tr("5G"), } @@ -68,27 +68,33 @@ class Sidebar(Widget): self._net_type = NETWORK_TYPES.get(NetworkType.none) self._net_strength = 0 - self._temp_status = MetricData("TEMP", "GOOD", Colors.GOOD) - self._panda_status = MetricData("VEHICLE", "ONLINE", Colors.GOOD) - self._connect_status = MetricData("CONNECT", "OFFLINE", Colors.WARNING) + self._temp_status = MetricData(tr("TEMP"), tr("GOOD"), Colors.GOOD) + self._panda_status = MetricData(tr("VEHICLE"), tr("ONLINE"), Colors.GOOD) + self._connect_status = MetricData(tr("CONNECT"), tr("OFFLINE"), Colors.WARNING) + self._recording_audio = False self._home_img = gui_app.texture("images/button_home.png", HOME_BTN.width, HOME_BTN.height) self._flag_img = gui_app.texture("images/button_flag.png", HOME_BTN.width, HOME_BTN.height) self._settings_img = gui_app.texture("images/button_settings.png", SETTINGS_BTN.width, SETTINGS_BTN.height) + self._mic_img = gui_app.texture("icons/microphone.png", 30, 30) + self._mic_indicator_rect = rl.Rectangle(0, 0, 0, 0) self._font_regular = gui_app.font(FontWeight.NORMAL) self._font_bold = gui_app.font(FontWeight.SEMI_BOLD) # Callbacks self._on_settings_click: Callable | None = None self._on_flag_click: Callable | None = None + self._open_settings_callback: Callable | None = None - def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | None = None): + def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | None = None, + open_settings: Callable | None = None): self._on_settings_click = on_settings self._on_flag_click = on_flag + self._open_settings_callback = open_settings def _render(self, rect: rl.Rectangle): # Background - rl.draw_rectangle_rec(rect, Colors.SIDEBAR_BG) + rl.draw_rectangle_rec(rect, rl.BLACK) self._draw_buttons(rect) self._draw_network_indicator(rect) @@ -101,13 +107,14 @@ class Sidebar(Widget): device_state = sm['deviceState'] + self._recording_audio = ui_state.recording_audio self._update_network_status(device_state) self._update_temperature_status(device_state) self._update_connection_status(device_state) self._update_panda_status() def _update_network_status(self, device_state): - self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, "Unknown") + self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr("Unknown")) strength = device_state.networkStrength self._net_strength = max(0, min(5, strength.raw + 1)) if strength > 0 else 0 @@ -115,26 +122,26 @@ class Sidebar(Widget): thermal_status = device_state.thermalStatus if thermal_status == ThermalStatus.green: - self._temp_status.update("TEMP", "GOOD", Colors.GOOD) + self._temp_status.update(tr("TEMP"), tr("GOOD"), Colors.GOOD) elif thermal_status == ThermalStatus.yellow: - self._temp_status.update("TEMP", "OK", Colors.WARNING) + self._temp_status.update(tr("TEMP"), tr("OK"), Colors.WARNING) else: - self._temp_status.update("TEMP", "HIGH", Colors.DANGER) + self._temp_status.update(tr("TEMP"), tr("HIGH"), Colors.DANGER) def _update_connection_status(self, device_state): last_ping = device_state.lastAthenaPingTime if last_ping == 0: - self._connect_status.update("CONNECT", "OFFLINE", Colors.WARNING) + self._connect_status.update(tr("CONNECT"), tr("OFFLINE"), Colors.WARNING) elif time.monotonic_ns() - last_ping < 80_000_000_000: # 80 seconds in nanoseconds - self._connect_status.update("CONNECT", "ONLINE", Colors.GOOD) + self._connect_status.update(tr("CONNECT"), tr("ONLINE"), Colors.GOOD) else: - self._connect_status.update("CONNECT", "ERROR", Colors.DANGER) + self._connect_status.update(tr("CONNECT"), tr("ERROR"), Colors.DANGER) def _update_panda_status(self): if ui_state.panda_type == log.PandaState.PandaType.unknown: - self._panda_status.update("NO", "PANDA", Colors.DANGER) + self._panda_status.update(tr("NO"), tr("PANDA"), Colors.DANGER) else: - self._panda_status.update("VEHICLE", "ONLINE", Colors.GOOD) + self._panda_status.update(tr("VEHICLE"), tr("ONLINE"), Colors.GOOD) def _handle_mouse_release(self, mouse_pos: MousePos): if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN): @@ -143,6 +150,9 @@ class Sidebar(Widget): elif rl.check_collision_point_rec(mouse_pos, HOME_BTN) and ui_state.started: if self._on_flag_click: self._on_flag_click() + elif self._recording_audio and rl.check_collision_point_rec(mouse_pos, self._mic_indicator_rect): + if self._open_settings_callback: + self._open_settings_callback() def _draw_buttons(self, rect: rl.Rectangle): mouse_pos = rl.get_mouse_position() @@ -160,6 +170,17 @@ class Sidebar(Widget): tint = Colors.BUTTON_PRESSED if (ui_state.started and flag_pressed) else Colors.BUTTON_NORMAL rl.draw_texture(button_img, int(HOME_BTN.x), int(HOME_BTN.y), tint) + # Microphone button + if self._recording_audio: + self._mic_indicator_rect = rl.Rectangle(rect.x + rect.width - 130, rect.y + 245, 75, 40) + + mic_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, self._mic_indicator_rect) + bg_color = rl.Color(Colors.DANGER.r, Colors.DANGER.g, Colors.DANGER.b, int(255 * 0.65)) if mic_pressed else Colors.DANGER + + rl.draw_rectangle_rounded(self._mic_indicator_rect, 1, 10, bg_color) + rl.draw_texture(self._mic_img, int(self._mic_indicator_rect.x + (self._mic_indicator_rect.width - self._mic_img.width) / 2), + int(self._mic_indicator_rect.y + (self._mic_indicator_rect.height - self._mic_img.height) / 2), Colors.WHITE) + def _draw_network_indicator(self, rect: rl.Rectangle): # Signal strength dots x_start = rect.x + 58 @@ -197,7 +218,7 @@ class Sidebar(Widget): # Draw label and value labels = [metric.label, metric.value] - text_y = metric_rect.y + (metric_rect.height / 2 - len(labels) * FONT_SIZE) + text_y = metric_rect.y + (metric_rect.height / 2 - len(labels) * FONT_SIZE * FONT_SCALE) for text in labels: text_size = measure_text_cached(self._font_bold, text, FONT_SIZE) text_y += text_size.y diff --git a/selfdrive/ui/onroad/alert_renderer.py b/selfdrive/ui/onroad/alert_renderer.py index c529694df4..a81fbfc440 100644 --- a/selfdrive/ui/onroad/alert_renderer.py +++ b/selfdrive/ui/onroad/alert_renderer.py @@ -5,9 +5,10 @@ from cereal import messaging, log from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.hardware import TICI from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.label import gui_text_box +from openpilot.system.ui.widgets.label import Label AlertSize = log.SelfdriveState.AlertSize AlertStatus = log.SelfdriveState.AlertStatus @@ -21,14 +22,19 @@ ALERT_FONT_SMALL = 66 ALERT_FONT_MEDIUM = 74 ALERT_FONT_BIG = 88 +ALERT_HEIGHTS = { + AlertSize.small: 271, + AlertSize.mid: 420, +} + SELFDRIVE_STATE_TIMEOUT = 5 # Seconds SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds # Constants ALERT_COLORS = { - AlertStatus.normal: rl.Color(0, 0, 0, 235), # Black - AlertStatus.userPrompt: rl.Color(0xFE, 0x8C, 0x34, 235), # Orange - AlertStatus.critical: rl.Color(0xC9, 0x22, 0x31, 235), # Red + AlertStatus.normal: rl.Color(0x15, 0x15, 0x15, 0xF1), # #151515 with alpha 0xF1 + AlertStatus.userPrompt: rl.Color(0xDA, 0x6F, 0x25, 0xF1), # #DA6F25 with alpha 0xF1 + AlertStatus.critical: rl.Color(0xC9, 0x22, 0x31, 0xF1), # #C92231 with alpha 0xF1 } @@ -42,24 +48,24 @@ class Alert: # Pre-defined alert instances ALERT_STARTUP_PENDING = Alert( - text1="openpilot Unavailable", - text2="Waiting to start", + text1=tr("openpilot Unavailable"), + text2=tr("Waiting to start"), size=AlertSize.mid, status=AlertStatus.normal, ) ALERT_CRITICAL_TIMEOUT = Alert( - text1="TAKE CONTROL IMMEDIATELY", - text2="System Unresponsive", + text1=tr("TAKE CONTROL IMMEDIATELY"), + text2=tr("System Unresponsive"), size=AlertSize.full, status=AlertStatus.critical, ) ALERT_CRITICAL_REBOOT = Alert( - text1="System Unresponsive", - text2="Reboot Device", - size=AlertSize.full, - status=AlertStatus.critical, + text1=tr("System Unresponsive"), + text2=tr("Reboot Device"), + size=AlertSize.mid, + status=AlertStatus.normal, ) @@ -69,13 +75,19 @@ class AlertRenderer(Widget): self.font_regular: rl.Font = gui_app.font(FontWeight.NORMAL) self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD) + # font size is set dynamically + self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) + self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) + def get_alert(self, sm: messaging.SubMaster) -> Alert | None: """Generate the current alert based on selfdrive state.""" ss = sm['selfdriveState'] # Check if selfdriveState messages have stopped arriving + recv_frame = sm.recv_frame['selfdriveState'] if not sm.updated['selfdriveState']: - recv_frame = sm.recv_frame['selfdriveState'] time_since_onroad = time.monotonic() - ui_state.started_time # 1. Never received selfdriveState since going onroad @@ -95,13 +107,17 @@ class AlertRenderer(Widget): if ss.alertSize == 0: return None + # Don't get old alert + if recv_frame < ui_state.started_frame: + return None + # Return current alert return Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw) - def _render(self, rect: rl.Rectangle) -> bool: + def _render(self, rect: rl.Rectangle): alert = self.get_alert(ui_state.sm) if not alert: - return False + return alert_rect = self._get_alert_rect(rect, alert.size) self._draw_background(alert_rect, alert) @@ -113,21 +129,14 @@ class AlertRenderer(Widget): alert_rect.height - 2 * ALERT_PADDING ) self._draw_text(text_rect, alert) - return True def _get_alert_rect(self, rect: rl.Rectangle, size: int) -> rl.Rectangle: if size == AlertSize.full: return rect - height = (ALERT_FONT_MEDIUM + 2 * ALERT_PADDING if size == AlertSize.small else - ALERT_FONT_BIG + ALERT_LINE_SPACING + ALERT_FONT_SMALL + 2 * ALERT_PADDING) - - return rl.Rectangle( - rect.x + ALERT_MARGIN, - rect.y + rect.height - ALERT_MARGIN - height, - rect.width - 2 * ALERT_MARGIN, - height - ) + h = ALERT_HEIGHTS.get(size, rect.height) + return rl.Rectangle(rect.x + ALERT_MARGIN, rect.y + rect.height - h + ALERT_MARGIN, + rect.width - ALERT_MARGIN * 2, h - ALERT_MARGIN * 2) def _draw_background(self, rect: rl.Rectangle, alert: Alert) -> None: color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal]) @@ -150,13 +159,17 @@ class AlertRenderer(Widget): else: is_long = len(alert.text1) > 15 font_size1 = 132 if is_long else 177 - align_ment = rl.GuiTextAlignment.TEXT_ALIGN_CENTER - vertical_align = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE - text_rect = rl.Rectangle(rect.x, rect.y, rect.width, rect.height // 2) - gui_text_box(text_rect, alert.text1, font_size1, alignment=align_ment, alignment_vertical=vertical_align, font_weight=FontWeight.BOLD) - text_rect.y = rect.y + rect.height // 2 - gui_text_box(text_rect, alert.text2, ALERT_FONT_BIG, alignment=align_ment) + top_offset = 200 if is_long or '\n' in alert.text1 else 270 + title_rect = rl.Rectangle(rect.x, rect.y + top_offset, rect.width, 600) + self._full_text1_label.set_font_size(font_size1) + self._full_text1_label.set_text(alert.text1) + self._full_text1_label.render(title_rect) + + bottom_offset = 361 if is_long else 420 + subtitle_rect = rl.Rectangle(rect.x, rect.y + rect.height - bottom_offset, rect.width, 300) + self._full_text2_label.set_text(alert.text2) + self._full_text2_label.render(subtitle_rect) def _draw_centered(self, text, rect, font, font_size, center_y=True, color=rl.WHITE) -> None: text_size = measure_text_cached(font, text, font_size) diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index 0a4c45163b..661496a032 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -1,6 +1,7 @@ +import time import numpy as np import pyray as rl -from cereal import log +from cereal import log, messaging from msgq.visionipc import VisionStreamType from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus, UI_BORDER_SIZE from openpilot.selfdrive.ui.onroad.alert_renderer import AlertRenderer @@ -19,9 +20,9 @@ WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"] BORDER_COLORS = { - UIStatus.DISENGAGED: rl.Color(0x17, 0x33, 0x49, 0xC8), # Blue for disengaged state - UIStatus.OVERRIDE: rl.Color(0x91, 0x9B, 0x95, 0xF1), # Gray for override state - UIStatus.ENGAGED: rl.Color(0x17, 0x86, 0x44, 0xF1), # Green for engaged state + 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 } WIDE_CAM_MAX_SPEED = 10.0 # m/s (22 mph) @@ -48,8 +49,12 @@ class AugmentedRoadView(CameraView): self.alert_renderer = AlertRenderer() self.driver_state_renderer = DriverStateRenderer() + # debug + self._pm = messaging.PubMaster(['uiDebug']) + def _render(self, rect): # Only render when system is started to avoid invalid data access + start_draw = time.monotonic() if not ui_state.started: return @@ -66,9 +71,6 @@ class AugmentedRoadView(CameraView): rect.height - 2 * UI_BORDER_SIZE, ) - # Draw colored border based on driving state - self._draw_border(rect) - # Enable scissor mode to clip all rendering within content rectangle boundaries # This creates a rendering viewport that prevents graphics from drawing outside the border rl.begin_scissor_mode( @@ -84,8 +86,8 @@ class AugmentedRoadView(CameraView): # Draw all UI overlays self.model_renderer.render(self._content_rect) self._hud_renderer.render(self._content_rect) - if not self.alert_renderer.render(self._content_rect): - self.driver_state_renderer.render(self._content_rect) + self.alert_renderer.render(self._content_rect) + self.driver_state_renderer.render(self._content_rect) # Custom UI extension point - add custom overlays here # Use self._content_rect for positioning within camera bounds @@ -93,6 +95,14 @@ class AugmentedRoadView(CameraView): # End clipping region rl.end_scissor_mode() + # Draw colored border based on driving state + self._draw_border(rect) + + # publish uiDebug + msg = messaging.new_message('uiDebug') + msg.uiDebug.drawTimeMillis = (time.monotonic() - start_draw) * 1000 + self._pm.send('uiDebug', msg) + def _handle_mouse_press(self, _): if not self._hud_renderer.user_interacting() and self._click_callback is not None: self._click_callback() @@ -102,8 +112,25 @@ class AugmentedRoadView(CameraView): pass def _draw_border(self, rect: rl.Rectangle): + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + border_roundness = 0.12 border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED]) - rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, border_color) + border_rect = rl.Rectangle(rect.x + UI_BORDER_SIZE, rect.y + UI_BORDER_SIZE, + rect.width - 2 * UI_BORDER_SIZE, rect.height - 2 * UI_BORDER_SIZE) + rl.draw_rectangle_rounded_lines_ex(border_rect, border_roundness, 10, UI_BORDER_SIZE, border_color) + + # black bg around colored border + black_bg_thickness = UI_BORDER_SIZE + black_bg_rect = rl.Rectangle( + border_rect.x - UI_BORDER_SIZE, + border_rect.y - UI_BORDER_SIZE, + border_rect.width + 2 * UI_BORDER_SIZE, + border_rect.height + 2 * UI_BORDER_SIZE, + ) + edge_offset = (black_bg_rect.height - border_rect.height) / 2 # distance between rect edges + roundness_out = (border_roundness * border_rect.height + 2 * edge_offset) / max(1.0, black_bg_rect.height) + rl.draw_rectangle_rounded_lines_ex(black_bg_rect, roundness_out, 10, black_bg_thickness, rl.BLACK) + rl.end_scissor_mode() def _switch_stream_if_needed(self, sm): if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams: diff --git a/selfdrive/ui/onroad/cameraview.py b/selfdrive/ui/onroad/cameraview.py index ca031e2f17..5098b6a06c 100644 --- a/selfdrive/ui/onroad/cameraview.py +++ b/selfdrive/ui/onroad/cameraview.py @@ -8,6 +8,7 @@ from openpilot.system.hardware import TICI from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage from openpilot.system.ui.widgets import Widget +from openpilot.selfdrive.ui.ui_state import ui_state CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts @@ -67,6 +68,7 @@ else: class CameraView(Widget): def __init__(self, name: str, stream_type: VisionStreamType): super().__init__() + # TODO: implement a receiver and connect thread self._name = name # Primary stream self.client = VisionIpcClient(name, stream_type, conflate=True) @@ -103,6 +105,19 @@ class CameraView(Widget): self.egl_texture = rl.load_texture_from_image(temp_image) rl.unload_image(temp_image) + ui_state.add_offroad_transition_callback(self._offroad_transition) + + def _offroad_transition(self): + # Reconnect if not first time going onroad + if ui_state.is_onroad() and self.frame is not None: + # Prevent old frames from showing when going onroad. Qt has a separate thread + # which drains the VisionIpcClient SubSocket for us. Re-connecting is not enough + # and only clears internal buffers, not the message queue. + self.frame = None + if self.client: + del self.client + self.client = VisionIpcClient(self._name, self._stream_type, conflate=True) + def _set_placeholder_color(self, color: rl.Color): """Set a placeholder color to be drawn when no frame is available.""" self._placeholder_color = color diff --git a/selfdrive/ui/onroad/driver_camera_dialog.py b/selfdrive/ui/onroad/driver_camera_dialog.py index 6c5508ce7d..543ea35e81 100644 --- a/selfdrive/ui/onroad/driver_camera_dialog.py +++ b/selfdrive/ui/onroad/driver_camera_dialog.py @@ -3,8 +3,9 @@ import pyray as rl from msgq.visionipc import VisionStreamType from openpilot.selfdrive.ui.onroad.cameraview import CameraView from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer -from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.ui_state import ui_state, device from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets.label import gui_label @@ -12,10 +13,17 @@ class DriverCameraDialog(CameraView): def __init__(self): super().__init__("camerad", VisionStreamType.VISION_STREAM_DRIVER) self.driver_state_renderer = DriverStateRenderer() + # TODO: this can grow unbounded, should be given some thought + device.add_interactive_timeout_callback(self.stop_dmonitoringmodeld) + ui_state.params.put_bool("IsDriverViewEnabled", True) + + def stop_dmonitoringmodeld(self): + ui_state.params.put_bool("IsDriverViewEnabled", False) + gui_app.set_modal_overlay(None) def _handle_mouse_release(self, _): super()._handle_mouse_release(_) - gui_app.set_modal_overlay(None) + self.stop_dmonitoringmodeld() def _render(self, rect): super()._render(rect) @@ -23,7 +31,7 @@ class DriverCameraDialog(CameraView): if not self.frame: gui_label( rect, - "camera starting", + tr("camera starting"), font_size=100, font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, diff --git a/selfdrive/ui/onroad/driver_state.py b/selfdrive/ui/onroad/driver_state.py index a25d9bd316..8aaef1bcfb 100644 --- a/selfdrive/ui/onroad/driver_state.py +++ b/selfdrive/ui/onroad/driver_state.py @@ -1,10 +1,13 @@ import numpy as np import pyray as rl +from cereal import log from dataclasses import dataclass from openpilot.selfdrive.ui.ui_state import ui_state, UI_BORDER_SIZE from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import Widget +AlertSize = log.SelfdriveState.AlertSize + # Default 3D coordinates for face keypoints as a NumPy array DEFAULT_FACE_KPTS_3D = np.array([ [-5.98, -51.20, 8.00], [-17.64, -49.14, 8.00], [-23.81, -46.40, 8.00], [-29.98, -40.91, 8.00], @@ -50,7 +53,6 @@ class DriverStateRenderer(Widget): self.is_active = False self.is_rhd = False self.dm_fade_state = 0.0 - self.last_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0) self.driver_pose_vals = np.zeros(3, dtype=np.float32) self.driver_pose_diff = np.zeros(3, dtype=np.float32) self.driver_pose_sins = np.zeros(3, dtype=np.float32) @@ -75,8 +77,8 @@ class DriverStateRenderer(Widget): self.engaged_color = rl.Color(26, 242, 66, 255) self.disengaged_color = rl.Color(139, 139, 139, 255) - self.set_visible(lambda: (ui_state.sm.recv_frame['driverStateV2'] > ui_state.started_frame and - ui_state.sm.seen['driverMonitoringState'])) + self.set_visible(lambda: (ui_state.sm["selfdriveState"].alertSize == AlertSize.none and + ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame)) def _render(self, rect): # Set opacity based on active state @@ -106,11 +108,7 @@ class DriverStateRenderer(Widget): def _update_state(self): """Update the driver monitoring state based on model data""" sm = ui_state.sm - if not sm.updated["driverMonitoringState"]: - if (self._rect.x != self.last_rect.x or self._rect.y != self.last_rect.y or - self._rect.width != self.last_rect.width or self._rect.height != self.last_rect.height): - self._pre_calculate_drawing_elements() - self.last_rect = self._rect + if not self.is_visible: return # Get monitoring state @@ -222,7 +220,7 @@ class DriverStateRenderer(Widget): radius_y = arc_data.height / 2 x_coords = center_x + np.cos(angles) * radius_x - y_coords = center_y + np.sin(angles) * radius_y + y_coords = center_y - np.sin(angles) * radius_y arc_lines = self.h_arc_lines if is_horizontal else self.v_arc_lines for i, (x_coord, y_coord) in enumerate(zip(x_coords, y_coords, strict=True)): diff --git a/selfdrive/ui/onroad/exp_button.py b/selfdrive/ui/onroad/exp_button.py index 175233c5ba..e5d8171413 100644 --- a/selfdrive/ui/onroad/exp_button.py +++ b/selfdrive/ui/onroad/exp_button.py @@ -66,8 +66,5 @@ class ExpButton(Widget): if not self._params.get_bool("ExperimentalModeConfirmed"): return False - car_params = ui_state.sm["carParams"] - if car_params.alphaLongitudinalAvailable: - return self._params.get_bool("AlphaLongitudinalEnabled") - else: - return car_params.openpilotLongitudinalControl + # Mirror exp mode toggle using persistent car params + return ui_state.has_longitudinal_control diff --git a/selfdrive/ui/onroad/hud_renderer.py b/selfdrive/ui/onroad/hud_renderer.py index c813d852bc..a2459c27e2 100644 --- a/selfdrive/ui/onroad/hud_renderer.py +++ b/selfdrive/ui/onroad/hud_renderer.py @@ -4,6 +4,7 @@ from openpilot.common.constants import CV from openpilot.selfdrive.ui.onroad.exp_button import ExpButton from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget @@ -60,7 +61,7 @@ class HudRenderer(Widget): super().__init__() """Initialize the HUD renderer.""" self.is_cruise_set: bool = False - self.is_cruise_available: bool = False + self.is_cruise_available: bool = True self.set_speed: float = SET_SPEED_NA self.speed: float = 0.0 self.v_ego_cluster_seen: bool = False @@ -130,8 +131,8 @@ class HudRenderer(Widget): y = rect.y + 45 set_speed_rect = rl.Rectangle(x, y, set_speed_width, UI_CONFIG.set_speed_height) - rl.draw_rectangle_rounded(set_speed_rect, 0.2, 30, COLORS.black_translucent) - rl.draw_rectangle_rounded_lines_ex(set_speed_rect, 0.2, 30, 6, COLORS.border_translucent) + rl.draw_rectangle_rounded(set_speed_rect, 0.35, 10, COLORS.black_translucent) + rl.draw_rectangle_rounded_lines_ex(set_speed_rect, 0.35, 10, 6, COLORS.border_translucent) max_color = COLORS.grey set_speed_color = COLORS.dark_grey @@ -144,7 +145,7 @@ class HudRenderer(Widget): elif ui_state.status == UIStatus.OVERRIDE: max_color = COLORS.override - max_text = "MAX" + max_text = tr("MAX") max_text_width = measure_text_cached(self._font_semi_bold, max_text, FONT_SIZES.max_speed).x rl.draw_text_ex( self._font_semi_bold, @@ -173,7 +174,7 @@ class HudRenderer(Widget): speed_pos = rl.Vector2(rect.x + rect.width / 2 - speed_text_size.x / 2, 180 - speed_text_size.y / 2) rl.draw_text_ex(self._font_bold, speed_text, speed_pos, FONT_SIZES.current_speed, 0, COLORS.white) - unit_text = "km/h" if ui_state.is_metric else "mph" + unit_text = tr("km/h") if ui_state.is_metric else tr("mph") unit_text_size = measure_text_cached(self._font_medium, unit_text, FONT_SIZES.speed_unit) unit_pos = rl.Vector2(rect.x + rect.width / 2 - unit_text_size.x / 2, 290 - unit_text_size.y / 2) rl.draw_text_ex(self._font_medium, unit_text, unit_pos, FONT_SIZES.speed_unit, 0, COLORS.white_translucent) diff --git a/selfdrive/ui/onroad/model_renderer.py b/selfdrive/ui/onroad/model_renderer.py index 4c036873d0..1e4fbbb783 100644 --- a/selfdrive/ui/onroad/model_renderer.py +++ b/selfdrive/ui/onroad/model_renderer.py @@ -8,7 +8,7 @@ from openpilot.common.params import Params from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.lib.shader_polygon import draw_polygon +from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient from openpilot.system.ui.widgets import Widget CLIP_MARGIN = 500 @@ -66,12 +66,12 @@ class ModelRenderer(Widget): self._transform_dirty = True self._clip_region = None - self._exp_gradient = { - 'start': (0.0, 1.0), # Bottom of path - 'end': (0.0, 0.0), # Top of path - 'colors': [], - 'stops': [], - } + self._exp_gradient = Gradient( + start=(0.0, 1.0), # Bottom of path + end=(0.0, 0.0), # Top of path + colors=[], + stops=[], + ) # Get longitudinal control setting from car parameters if car_params := Params().get("CarParams"): @@ -169,12 +169,12 @@ class ModelRenderer(Widget): # Update lane lines using raw points for i, lane_line in enumerate(self._lane_lines): lane_line.projected_points = self._map_line_to_polygon( - lane_line.raw_points, 0.025 * self._lane_line_probs[i], 0.0, max_idx + lane_line.raw_points, 0.025 * self._lane_line_probs[i], 0.0, max_idx, max_distance ) # Update road edges using raw points for road_edge in self._road_edges: - road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, 0.025, 0.0, max_idx) + road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, 0.025, 0.0, max_idx, max_distance) # Update path using raw points if lead and lead.status: @@ -183,7 +183,7 @@ class ModelRenderer(Widget): max_idx = self._get_path_length_idx(path_x_array, max_distance) self._path.projected_points = self._map_line_to_polygon( - self._path.raw_points, 0.9, self._path_offset_z, max_idx, allow_invert=False + self._path.raw_points, 0.9, self._path_offset_z, max_idx, max_distance, allow_invert=False ) self._update_experimental_gradient() @@ -226,8 +226,12 @@ class ModelRenderer(Widget): i += 1 + (1 if (i + 2) < max_len else 0) # Store the gradient in the path object - self._exp_gradient['colors'] = segment_colors - self._exp_gradient['stops'] = gradient_stops + self._exp_gradient = Gradient( + start=(0.0, 1.0), # Bottom of path + end=(0.0, 0.0), # Top of path + colors=segment_colors, + stops=gradient_stops, + ) def _update_lead_vehicle(self, d_rel, v_rel, point, rect): speed_buff, lead_buff = 10.0, 40.0 @@ -277,12 +281,11 @@ class ModelRenderer(Widget): return allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control - self._blend_filter.update_dt(1 / gui_app.target_fps) self._blend_filter.update(int(allow_throttle)) if self._experimental_mode: # Draw with acceleration coloring - if len(self._exp_gradient['colors']) > 1: + if len(self._exp_gradient.colors) > 1: draw_polygon(self._rect, self._path.projected_points, gradient=self._exp_gradient) else: draw_polygon(self._rect, self._path.projected_points, rl.Color(255, 255, 255, 30)) @@ -290,12 +293,12 @@ class ModelRenderer(Widget): # Blend throttle/no throttle colors based on transition blend_factor = round(self._blend_filter.x * 100) / 100 blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor) - gradient = { - 'start': (0.0, 1.0), # Bottom of path - 'end': (0.0, 0.0), # Top of path - 'colors': blended_colors, - 'stops': [0.0, 0.5, 1.0], - } + gradient = Gradient( + start=(0.0, 1.0), # Bottom of path + end=(0.0, 0.0), # Top of path + colors=blended_colors, + stops=[0.0, 0.5, 1.0], + ) draw_polygon(self._rect, self._path.projected_points, gradient=gradient) def _draw_lead_indicator(self): @@ -308,11 +311,11 @@ class ModelRenderer(Widget): rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(201, 34, 49, lead.fill_alpha)) @staticmethod - def _get_path_length_idx(pos_x_array: np.ndarray, path_height: float) -> int: - """Get the index corresponding to the given path height""" + def _get_path_length_idx(pos_x_array: np.ndarray, path_distance: float) -> int: + """Get the index corresponding to the given path distance""" if len(pos_x_array) == 0: return 0 - indices = np.where(pos_x_array <= path_height)[0] + indices = np.where(pos_x_array <= path_distance)[0] return indices[-1] if indices.size > 0 else 0 def _map_to_screen(self, in_x, in_y, in_z): @@ -331,13 +334,24 @@ class ModelRenderer(Widget): return (x, y) - def _map_line_to_polygon(self, line: np.ndarray, y_off: float, z_off: float, max_idx: int, allow_invert: bool = True) -> np.ndarray: + def _map_line_to_polygon(self, line: np.ndarray, y_off: float, z_off: float, max_idx: int, max_distance: float, allow_invert: bool = True) -> np.ndarray: """Convert 3D line to 2D polygon for rendering.""" if line.shape[0] == 0: return np.empty((0, 2), dtype=np.float32) # Slice points and filter non-negative x-coordinates points = line[:max_idx + 1] + + # Interpolate around max_idx so path end is smooth (max_distance is always >= p0.x) + if 0 < max_idx < line.shape[0] - 1: + p0 = line[max_idx] + p1 = line[max_idx + 1] + x0, x1 = p0[0], p1[0] + interp_y = np.interp(max_distance, [x0, x1], [p0[1], p1[1]]) + interp_z = np.interp(max_distance, [x0, x1], [p0[2], p1[2]]) + interp_point = np.array([max_distance, interp_y, interp_z], dtype=points.dtype) + points = np.concatenate((points, interp_point[None, :]), axis=0) + points = points[points[:, 0] >= 0] if points.shape[0] == 0: return np.empty((0, 2), dtype=np.float32) diff --git a/selfdrive/ui/tests/create_test_translations.sh b/selfdrive/ui/tests/create_test_translations.sh index ed0890d946..1587a88205 100755 --- a/selfdrive/ui/tests/create_test_translations.sh +++ b/selfdrive/ui/tests/create_test_translations.sh @@ -4,8 +4,8 @@ set -e UI_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"/.. TEST_TEXT="(WRAPPED_SOURCE_TEXT)" -TEST_TS_FILE=$UI_DIR/translations/main_test_en.ts -TEST_QM_FILE=$UI_DIR/translations/main_test_en.qm +TEST_TS_FILE=$UI_DIR/translations/test_en.ts +TEST_QM_FILE=$UI_DIR/translations/test_en.qm # translation strings UNFINISHED="<\/translation>" diff --git a/selfdrive/ui/tests/cycle_offroad_alerts.py b/selfdrive/ui/tests/cycle_offroad_alerts.py index e468d88e0e..b577b74b00 100755 --- a/selfdrive/ui/tests/cycle_offroad_alerts.py +++ b/selfdrive/ui/tests/cycle_offroad_alerts.py @@ -7,6 +7,7 @@ import json from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert +from openpilot.system.updated.updated import parse_release_notes if __name__ == "__main__": params = Params() @@ -18,9 +19,7 @@ if __name__ == "__main__": while True: print("setting alert update") params.put_bool("UpdateAvailable", True) - r = open(os.path.join(BASEDIR, "RELEASES.md")).read() - r = r[:r.find('\n\n')] # Slice latest release notes - params.put("UpdaterNewReleaseNotes", r + "\n") + params.put("UpdaterNewReleaseNotes", parse_release_notes(BASEDIR)) time.sleep(t) params.put_bool("UpdateAvailable", False) diff --git a/selfdrive/ui/tests/test_raylib_ui.py b/selfdrive/ui/tests/test_raylib_ui.py index 3f23301972..69ba946dcd 100644 --- a/selfdrive/ui/tests/test_raylib_ui.py +++ b/selfdrive/ui/tests/test_raylib_ui.py @@ -2,7 +2,7 @@ import time from openpilot.selfdrive.test.helpers import with_processes -@with_processes(["raylib_ui"]) +@with_processes(["ui"]) def test_raylib_ui(): """Test initialization of the UI widgets is successful.""" time.sleep(1) diff --git a/selfdrive/ui/tests/test_runner.cc b/selfdrive/ui/tests/test_runner.cc index c8cc0d3e05..4bde921696 100644 --- a/selfdrive/ui/tests/test_runner.cc +++ b/selfdrive/ui/tests/test_runner.cc @@ -10,7 +10,7 @@ int main(int argc, char **argv) { // unit tests for Qt QApplication app(argc, argv); - QString language_file = "main_test_en"; + QString language_file = "test_en"; // FIXME: pytest-cpp considers this print as a test case qDebug() << "Loading language:" << language_file; diff --git a/selfdrive/ui/tests/test_translations.py b/selfdrive/ui/tests/test_translations.py index 2ae3356bb8..edd9a30412 100644 --- a/selfdrive/ui/tests/test_translations.py +++ b/selfdrive/ui/tests/test_translations.py @@ -93,7 +93,7 @@ class TestTranslations: def test_bad_language(self): IGNORED_WORDS = {'pédale'} - match = re.search(r'_([a-zA-Z]{2,3})', self.file) + match = re.search(r'([a-zA-Z]{2,3})', self.file) assert match, f"{self.name} - could not parse language" try: diff --git a/selfdrive/ui/tests/test_ui/print_mouse_coords.py b/selfdrive/ui/tests/test_ui/print_mouse_coords.py new file mode 100755 index 0000000000..1e88ce57d3 --- /dev/null +++ b/selfdrive/ui/tests/test_ui/print_mouse_coords.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +""" +Simple script to print mouse coordinates on Ubuntu. +Run with: python print_mouse_coords.py +Press Ctrl+C to exit. +""" + +from pynput import mouse + +print("Mouse coordinate printer - Press Ctrl+C to exit") +print("Click to set the top left origin") + +origin: tuple[int, int] | None = None +clicks: list[tuple[int, int]] = [] + + +def on_click(x, y, button, pressed): + global origin, clicks + if pressed: # Only on mouse down, not up + if origin is None: + origin = (x, y) + print(f"Origin set to: {x},{y}") + else: + rel_x = x - origin[0] + rel_y = y - origin[1] + clicks.append((rel_x, rel_y)) + print(f"Clicks: {clicks}") + + +if __name__ == "__main__": + try: + # Start mouse listener + with mouse.Listener(on_click=on_click) as listener: + listener.join() + except KeyboardInterrupt: + print("\nExiting...") diff --git a/selfdrive/ui/tests/test_ui/raylib_screenshots.py b/selfdrive/ui/tests/test_ui/raylib_screenshots.py index 7fb22e4484..2e96b3bd43 100755 --- a/selfdrive/ui/tests/test_ui/raylib_screenshots.py +++ b/selfdrive/ui/tests/test_ui/raylib_screenshots.py @@ -9,59 +9,103 @@ from collections import namedtuple import pyautogui import pywinctl -from cereal import log +from cereal import car, log from cereal import messaging from cereal.messaging import PubMaster +from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params 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 + +AlertSize = log.SelfdriveState.AlertSize +AlertStatus = log.SelfdriveState.AlertStatus TEST_DIR = pathlib.Path(__file__).parent TEST_OUTPUT_DIR = TEST_DIR / "raylib_report" SCREENSHOTS_DIR = TEST_OUTPUT_DIR / "screenshots" -UI_DELAY = 0.1 +UI_DELAY = 0.2 # Offroad alerts to test OFFROAD_ALERTS = ['Offroad_IsTakingSnapshot'] +def put_update_params(params: Params): + params.put("UpdaterCurrentReleaseNotes", parse_release_notes(BASEDIR)) + params.put("UpdaterNewReleaseNotes", parse_release_notes(BASEDIR)) + + def setup_homescreen(click, pm: PubMaster): pass -def setup_settings_device(click, pm: PubMaster): +def setup_homescreen_update_available(click, pm: PubMaster): + params = Params() + params.put_bool("UpdateAvailable", True) + put_update_params(params) + setup_offroad_alert(click, pm) + + +def setup_settings(click, pm: PubMaster): click(100, 100) +def close_settings(click, pm: PubMaster): + click(240, 216) + + def setup_settings_network(click, pm: PubMaster): - setup_settings_device(click, pm) + setup_settings(click, pm) click(278, 450) +def setup_settings_network_advanced(click, pm: PubMaster): + setup_settings_network(click, pm) + click(1880, 100) + + def setup_settings_toggles(click, pm: PubMaster): - setup_settings_device(click, pm) + setup_settings(click, pm) click(278, 600) def setup_settings_software(click, pm: PubMaster): - setup_settings_device(click, pm) + put_update_params(Params()) + setup_settings(click, pm) click(278, 720) +def setup_settings_software_download(click, pm: PubMaster): + params = Params() + # setup_settings_software but with "DOWNLOAD" button to test long text + params.put("UpdaterState", "idle") + params.put_bool("UpdaterFetchAvailable", True) + setup_settings_software(click, pm) + + +def setup_settings_software_release_notes(click, pm: PubMaster): + setup_settings_software(click, pm) + click(588, 110) # expand description for current version + + def setup_settings_firehose(click, pm: PubMaster): - setup_settings_device(click, pm) + setup_settings(click, pm) click(278, 845) def setup_settings_developer(click, pm: PubMaster): - setup_settings_device(click, pm) + CP = car.CarParams() + CP.alphaLongitudinalAvailable = True # show alpha long control toggle + Params().put("CarParamsPersistent", CP.to_bytes()) + + setup_settings(click, pm) click(278, 950) def setup_keyboard(click, pm: PubMaster): setup_settings_developer(click, pm) - click(1930, 270) + click(1930, 470) def setup_pair_device(click, pm: PubMaster): @@ -69,25 +113,148 @@ def setup_pair_device(click, pm: PubMaster): def setup_offroad_alert(click, pm: PubMaster): - set_offroad_alert("Offroad_TemperatureTooHigh", True, extra_text='99') + put_update_params(Params()) + set_offroad_alert("Offroad_TemperatureTooHigh", True, extra_text='99C') + set_offroad_alert("Offroad_ExcessiveActuation", True, extra_text='longitudinal') for alert in OFFROAD_ALERTS: set_offroad_alert(alert, True) - setup_settings_device(click, pm) - click(240, 216) + setup_settings(click, pm) + close_settings(click, pm) + + +def setup_confirmation_dialog(click, pm: PubMaster): + setup_settings(click, pm) + click(1985, 791) # reset calibration + + +def setup_experimental_mode_description(click, pm: PubMaster): + setup_settings_toggles(click, pm) + click(1200, 280) # expand description for experimental mode + + +def setup_openpilot_long_confirmation_dialog(click, pm: PubMaster): + setup_settings_developer(click, pm) + click(2000, 960) # toggle openpilot longitudinal control + + +def setup_onroad(click, pm: PubMaster): + ds = messaging.new_message('deviceState') + ds.deviceState.started = True + + ps = messaging.new_message('pandaStates', 1) + ps.pandaStates[0].pandaType = log.PandaState.PandaType.dos + ps.pandaStates[0].ignitionLine = True + + driverState = messaging.new_message('driverStateV2') + driverState.driverStateV2.leftDriverData.faceOrientation = [0, 0, 0] + + for _ in range(5): + pm.send('deviceState', ds) + pm.send('pandaStates', ps) + pm.send('driverStateV2', driverState) + ds.clear_write_flag() + ps.clear_write_flag() + driverState.clear_write_flag() + time.sleep(0.05) + + +def setup_onroad_sidebar(click, pm: PubMaster): + setup_onroad(click, pm) + click(100, 100) # open sidebar + + +def setup_onroad_small_alert(click, pm: PubMaster): + setup_onroad(click, pm) + alert = messaging.new_message('selfdriveState') + alert.selfdriveState.alertSize = AlertSize.small + alert.selfdriveState.alertText1 = "Small Alert" + alert.selfdriveState.alertText2 = "This is a small alert" + alert.selfdriveState.alertStatus = AlertStatus.normal + for _ in range(5): + pm.send('selfdriveState', alert) + alert.clear_write_flag() + time.sleep(0.05) + + +def setup_onroad_medium_alert(click, pm: PubMaster): + setup_onroad(click, pm) + alert = messaging.new_message('selfdriveState') + alert.selfdriveState.alertSize = AlertSize.mid + alert.selfdriveState.alertText1 = "Medium Alert" + alert.selfdriveState.alertText2 = "This is a medium alert" + alert.selfdriveState.alertStatus = AlertStatus.userPrompt + for _ in range(5): + pm.send('selfdriveState', alert) + alert.clear_write_flag() + time.sleep(0.05) + + +def setup_onroad_full_alert(click, pm: PubMaster): + setup_onroad(click, pm) + alert = messaging.new_message('selfdriveState') + alert.selfdriveState.alertSize = AlertSize.full + alert.selfdriveState.alertText1 = "DISENGAGE IMMEDIATELY" + alert.selfdriveState.alertText2 = "Driver Distracted" + alert.selfdriveState.alertStatus = AlertStatus.critical + for _ in range(5): + pm.send('selfdriveState', alert) + alert.clear_write_flag() + time.sleep(0.05) + + +def setup_onroad_full_alert_multiline(click, pm: PubMaster): + setup_onroad(click, pm) + alert = messaging.new_message('selfdriveState') + alert.selfdriveState.alertSize = AlertSize.full + alert.selfdriveState.alertText1 = "Reverse\nGear" + alert.selfdriveState.alertStatus = AlertStatus.normal + for _ in range(5): + pm.send('selfdriveState', alert) + alert.clear_write_flag() + time.sleep(0.05) + + +def setup_onroad_full_alert_long_text(click, pm: PubMaster): + setup_onroad(click, pm) + alert = messaging.new_message('selfdriveState') + alert.selfdriveState.alertSize = AlertSize.full + alert.selfdriveState.alertText1 = "TAKE CONTROL IMMEDIATELY" + alert.selfdriveState.alertText2 = "Calibration Invalid: Remount Device & Recalibrate" + alert.selfdriveState.alertStatus = AlertStatus.userPrompt + for _ in range(5): + pm.send('selfdriveState', alert) + alert.clear_write_flag() + time.sleep(0.05) CASES = { "homescreen": setup_homescreen, - "settings_device": setup_settings_device, + "homescreen_paired": setup_homescreen, + "homescreen_prime": setup_homescreen, + "homescreen_update_available": setup_homescreen_update_available, + "settings_device": setup_settings, "settings_network": setup_settings_network, + "settings_network_advanced": setup_settings_network_advanced, "settings_toggles": setup_settings_toggles, "settings_software": setup_settings_software, + "settings_software_download": setup_settings_software_download, + "settings_software_release_notes": setup_settings_software_release_notes, "settings_firehose": setup_settings_firehose, "settings_developer": setup_settings_developer, "keyboard": setup_keyboard, "pair_device": setup_pair_device, "offroad_alert": setup_offroad_alert, + "confirmation_dialog": setup_confirmation_dialog, + "experimental_mode_description": setup_experimental_mode_description, + "openpilot_long_confirmation_dialog": setup_openpilot_long_confirmation_dialog, + "onroad": setup_onroad, + "onroad_sidebar": setup_onroad_sidebar, + "onroad_small_alert": setup_onroad_small_alert, + "onroad_medium_alert": setup_onroad_medium_alert, + "onroad_full_alert": setup_onroad_full_alert, + "onroad_full_alert_multiline": setup_onroad_full_alert_multiline, + "onroad_full_alert_long_text": setup_onroad_full_alert_long_text, } @@ -98,7 +265,7 @@ class TestUI: def setup(self): # Seed minimal offroad state - self.pm = PubMaster(["deviceState"]) + self.pm = PubMaster(["deviceState", "pandaStates", "driverStateV2", "selfdriveState"]) ds = messaging.new_message('deviceState') ds.deviceState.networkType = log.DeviceState.NetworkType.wifi for _ in range(5): @@ -122,9 +289,10 @@ class TestUI: time.sleep(0.01) pyautogui.mouseUp(self.ui.left + x, self.ui.top + y, *args, **kwargs) - @with_processes(["raylib_ui"]) + @with_processes(["ui"]) def test_ui(self, name, setup_case): self.setup() + time.sleep(UI_DELAY) # wait for UI to start setup_case(self.click, self.pm) self.screenshot(name) @@ -135,10 +303,21 @@ def create_screenshots(): SCREENSHOTS_DIR.mkdir(parents=True) t = TestUI() - with OpenpilotPrefix(): - params = Params() - params.put("DongleId", "123456789012345") - for name, setup in CASES.items(): + for name, setup in CASES.items(): + with OpenpilotPrefix(): + params = Params() + params.put("DongleId", "123456789012345") + + # Set branch name + description = "0.10.1 / this-is-a-really-super-mega-long-branch-name / 7864838 / Oct 03" + params.put("UpdaterCurrentDescription", description) + params.put("UpdaterNewDescription", description) + + if name == "homescreen_paired": + params.put("PrimeType", 0) # NONE + elif name == "homescreen_prime": + params.put("PrimeType", 2) # LITE + t.test_ui(name, setup) diff --git a/selfdrive/ui/translations/main_ar.ts b/selfdrive/ui/translations/ar.ts similarity index 100% rename from selfdrive/ui/translations/main_ar.ts rename to selfdrive/ui/translations/ar.ts diff --git a/selfdrive/ui/translations/auto_translate.py b/selfdrive/ui/translations/auto_translate.py index c2e4bbc552..6251e03397 100755 --- a/selfdrive/ui/translations/auto_translate.py +++ b/selfdrive/ui/translations/auto_translate.py @@ -26,7 +26,7 @@ def get_language_files(languages: list[str] = None) -> dict[str, pathlib.Path]: for filename in language_dict.values(): path = TRANSLATIONS_DIR / f"{filename}.ts" - language = path.stem.split("main_")[1] + language = path.stem if languages is None or language in languages: files[language] = path diff --git a/selfdrive/ui/translations/main_de.ts b/selfdrive/ui/translations/de.ts similarity index 100% rename from selfdrive/ui/translations/main_de.ts rename to selfdrive/ui/translations/de.ts diff --git a/selfdrive/ui/translations/main_en.ts b/selfdrive/ui/translations/en.ts similarity index 100% rename from selfdrive/ui/translations/main_en.ts rename to selfdrive/ui/translations/en.ts diff --git a/selfdrive/ui/translations/main_es.ts b/selfdrive/ui/translations/es.ts similarity index 100% rename from selfdrive/ui/translations/main_es.ts rename to selfdrive/ui/translations/es.ts diff --git a/selfdrive/ui/translations/main_fr.ts b/selfdrive/ui/translations/fr.ts similarity index 100% rename from selfdrive/ui/translations/main_fr.ts rename to selfdrive/ui/translations/fr.ts diff --git a/selfdrive/ui/translations/main_ja.ts b/selfdrive/ui/translations/ja.ts similarity index 100% rename from selfdrive/ui/translations/main_ja.ts rename to selfdrive/ui/translations/ja.ts diff --git a/selfdrive/ui/translations/main_ko.ts b/selfdrive/ui/translations/ko.ts similarity index 100% rename from selfdrive/ui/translations/main_ko.ts rename to selfdrive/ui/translations/ko.ts diff --git a/selfdrive/ui/translations/languages.json b/selfdrive/ui/translations/languages.json index 132b5088d7..b0674dee82 100644 --- a/selfdrive/ui/translations/languages.json +++ b/selfdrive/ui/translations/languages.json @@ -1,14 +1,14 @@ { - "English": "main_en", - "Deutsch": "main_de", - "Français": "main_fr", - "Português": "main_pt-BR", - "Español": "main_es", - "Türkçe": "main_tr", - "العربية": "main_ar", - "ไทย": "main_th", - "中文(繁體)": "main_zh-CHT", - "中文(简体)": "main_zh-CHS", - "한국어": "main_ko", - "日本語": "main_ja" + "English": "en", + "Deutsch": "de", + "Français": "fr", + "Português": "pt-BR", + "Español": "es", + "Türkçe": "tr", + "العربية": "ar", + "ไทย": "th", + "中文(繁體)": "zh-CHT", + "中文(简体)": "zh-CHS", + "한국어": "ko", + "日本語": "ja" } diff --git a/selfdrive/ui/translations/main_nl.ts b/selfdrive/ui/translations/nl.ts similarity index 100% rename from selfdrive/ui/translations/main_nl.ts rename to selfdrive/ui/translations/nl.ts diff --git a/selfdrive/ui/translations/main_pl.ts b/selfdrive/ui/translations/pl.ts similarity index 100% rename from selfdrive/ui/translations/main_pl.ts rename to selfdrive/ui/translations/pl.ts diff --git a/selfdrive/ui/translations/main_pt-BR.ts b/selfdrive/ui/translations/pt-BR.ts similarity index 100% rename from selfdrive/ui/translations/main_pt-BR.ts rename to selfdrive/ui/translations/pt-BR.ts diff --git a/selfdrive/ui/translations/main_th.ts b/selfdrive/ui/translations/th.ts similarity index 100% rename from selfdrive/ui/translations/main_th.ts rename to selfdrive/ui/translations/th.ts diff --git a/selfdrive/ui/translations/main_tr.ts b/selfdrive/ui/translations/tr.ts similarity index 100% rename from selfdrive/ui/translations/main_tr.ts rename to selfdrive/ui/translations/tr.ts diff --git a/selfdrive/ui/translations/main_zh-CHS.ts b/selfdrive/ui/translations/zh-CHS.ts similarity index 100% rename from selfdrive/ui/translations/main_zh-CHS.ts rename to selfdrive/ui/translations/zh-CHS.ts diff --git a/selfdrive/ui/translations/main_zh-CHT.ts b/selfdrive/ui/translations/zh-CHT.ts similarity index 100% rename from selfdrive/ui/translations/main_zh-CHT.ts rename to selfdrive/ui/translations/zh-CHT.ts diff --git a/selfdrive/ui/ui.py b/selfdrive/ui/ui.py index 0230e16a4f..3eb72ec104 100755 --- a/selfdrive/ui/ui.py +++ b/selfdrive/ui/ui.py @@ -1,20 +1,20 @@ #!/usr/bin/env python3 import pyray as rl -from openpilot.common.watchdog import kick_watchdog from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.main import MainLayout from openpilot.selfdrive.ui.ui_state import ui_state def main(): + # TODO: https://github.com/commaai/agnos-builder/pull/490 + # os.nice(-20) + gui_app.init_window("UI") main_layout = MainLayout() main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) for showing_dialog in gui_app.render(): ui_state.update() - kick_watchdog() - if not showing_dialog: main_layout.render() diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index 39a65a9191..dab01f9245 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -4,13 +4,13 @@ import time import threading from collections.abc import Callable from enum import Enum -from cereal import messaging, log +from cereal import messaging, car, log from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.common.params import Params, UnknownKeyName +from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.lib.prime_state import PrimeState -from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app +from openpilot.system.hardware import HARDWARE UI_BORDER_SIZE = 30 BACKLIGHT_OFFROAD = 50 @@ -50,6 +50,7 @@ class UIState: "managerState", "selfdriveState", "longitudinalPlan", + "rawAudioData", ] ) @@ -66,11 +67,25 @@ class UIState: self.is_metric: bool = self.params.get_bool("IsMetric") self.started: bool = False self.ignition: bool = False + self.recording_audio: bool = False self.panda_type: log.PandaState.PandaType = log.PandaState.PandaType.unknown self.personality: log.LongitudinalPersonality = log.LongitudinalPersonality.standard + self.has_longitudinal_control: bool = False + self.CP: car.CarParams | None = None self.light_sensor: float = -1.0 + self._param_update_time: float = 0.0 + + # Callbacks + self._offroad_transition_callbacks: list[Callable[[], None]] = [] + self._engaged_transition_callbacks: list[Callable[[], None]] = [] - self._update_params() + self.update_params() + + def add_offroad_transition_callback(self, callback: Callable[[], None]): + self._offroad_transition_callbacks.append(callback) + + def add_engaged_transition_callback(self, callback: Callable[[], None]): + self._engaged_transition_callbacks.append(callback) @property def engaged(self) -> bool: @@ -86,6 +101,8 @@ class UIState: self.sm.update(0) self._update_state() self._update_status() + if time.monotonic() - self._param_update_time > 5.0: + self.update_params() device.update() def _update_state(self) -> None: @@ -112,6 +129,11 @@ class UIState: # Update started state self.started = self.sm["deviceState"].started and self.ignition + # Update recording audio state + self.recording_audio = self.params.get_bool("RecordAudio") and self.started + + self.is_metric = self.params.get_bool("IsMetric") + def _update_status(self) -> None: if self.started and self.sm.updated["selfdriveState"]: ss = self.sm["selfdriveState"] @@ -124,6 +146,8 @@ class UIState: # Check for engagement state changes if self.engaged != self._engaged_prev: + for callback in self._engaged_transition_callbacks: + callback() self._engaged_prev = self.engaged # Handle onroad/offroad transition @@ -133,13 +157,22 @@ class UIState: self.started_frame = self.sm.frame self.started_time = time.monotonic() + for callback in self._offroad_transition_callbacks: + callback() + self._started_prev = self.started - def _update_params(self) -> None: - try: - self.is_metric = self.params.get_bool("IsMetric") - except UnknownKeyName: - self.is_metric = False + def update_params(self) -> None: + # For slower operations + # Update longitudinal control state + CP_bytes = self.params.get("CarParamsPersistent") + if CP_bytes is not None: + self.CP = messaging.log_from_bytes(CP_bytes, car.CarParams) + if self.CP.alphaLongitudinalAvailable: + self.has_longitudinal_control = self.params.get_bool("AlphaLongitudinalEnabled") + else: + self.has_longitudinal_control = self.CP.openpilotLongitudinalControl + self._param_update_time = time.monotonic() class Device: @@ -189,14 +222,12 @@ class Device: clipped_brightness = float(np.clip(100 * clipped_brightness, 10, 100)) - self._brightness_filter.update_dt(1 / gui_app.target_fps) brightness = round(self._brightness_filter.update(clipped_brightness)) if not self._awake: brightness = 0 if brightness != self._last_brightness: if self._brightness_thread is None or not self._brightness_thread.is_alive(): - cloudlog.debug(f"setting display brightness {brightness}") self._brightness_thread = threading.Thread(target=HARDWARE.set_screen_brightness, args=(brightness,)) self._brightness_thread.start() self._last_brightness = brightness diff --git a/selfdrive/ui/update_translations.py b/selfdrive/ui/update_translations.py index 65880bdad9..643d246012 100755 --- a/selfdrive/ui/update_translations.py +++ b/selfdrive/ui/update_translations.py @@ -4,12 +4,10 @@ import json import os from openpilot.common.basedir import BASEDIR +from openpilot.system.ui.lib.multilang import UI_DIR, TRANSLATIONS_DIR, LANGUAGES_FILE -UI_DIR = os.path.join(BASEDIR, "selfdrive", "ui") -TRANSLATIONS_DIR = os.path.join(UI_DIR, "translations") -LANGUAGES_FILE = os.path.join(TRANSLATIONS_DIR, "languages.json") TRANSLATIONS_INCLUDE_FILE = os.path.join(TRANSLATIONS_DIR, "alerts_generated.h") -PLURAL_ONLY = ["main_en"] # base language, only create entries for strings with plural forms +PLURAL_ONLY = ["en"] # base language, only create entries for strings with plural forms def generate_translations_include(): diff --git a/selfdrive/ui/widgets/exp_mode_button.py b/selfdrive/ui/widgets/exp_mode_button.py index 9618768957..faa3bf877f 100644 --- a/selfdrive/ui/widgets/exp_mode_button.py +++ b/selfdrive/ui/widgets/exp_mode_button.py @@ -1,6 +1,7 @@ import pyray as rl from openpilot.common.params import Params -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget @@ -9,7 +10,7 @@ class ExperimentalModeButton(Widget): super().__init__() self.img_width = 80 - self.horizontal_padding = 50 + self.horizontal_padding = 25 self.button_height = 125 self.params = Params() @@ -18,6 +19,9 @@ class ExperimentalModeButton(Widget): self.chill_pixmap = gui_app.texture("icons/couch.png", self.img_width, self.img_width) self.experimental_pixmap = gui_app.texture("icons/experimental_grey.png", self.img_width, self.img_width) + def show_event(self): + self.experimental_mode = self.params.get_bool("ExperimentalMode") + def _get_gradient_colors(self): alpha = 0xCC if self.is_pressed else 0xFF @@ -31,16 +35,10 @@ class ExperimentalModeButton(Widget): rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), int(rect.width), int(rect.height), start_color, end_color) - def _handle_mouse_release(self, mouse_pos): - self.experimental_mode = not self.experimental_mode - # TODO: Opening settings for ExperimentalMode - self.params.put_bool("ExperimentalMode", self.experimental_mode) - def _render(self, rect): - rl.draw_rectangle_rounded(rect, 0.08, 20, rl.WHITE) - rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) self._draw_gradient_background(rect) + rl.draw_rectangle_rounded_lines_ex(self._rect, 0.19, 10, 5, rl.BLACK) rl.end_scissor_mode() # Draw vertical separator line @@ -49,9 +47,9 @@ class ExperimentalModeButton(Widget): rl.draw_line_ex(rl.Vector2(line_x, rect.y), rl.Vector2(line_x, rect.y + rect.height), 3, separator_color) # Draw text label (left aligned) - text = "EXPERIMENTAL MODE ON" if self.experimental_mode else "CHILL MODE ON" + text = tr("EXPERIMENTAL MODE ON") if self.experimental_mode else tr("CHILL MODE ON") text_x = rect.x + self.horizontal_padding - text_y = rect.y + rect.height / 2 - 45 // 2 # Center vertically + text_y = rect.y + rect.height / 2 - 45 * FONT_SCALE // 2 # Center vertically rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), text, rl.Vector2(int(text_x), int(text_y)), 45, 0, rl.BLACK) diff --git a/selfdrive/ui/widgets/offroad_alerts.py b/selfdrive/ui/widgets/offroad_alerts.py index da0ea287cf..0bd5b161b9 100644 --- a/selfdrive/ui/widgets/offroad_alerts.py +++ b/selfdrive/ui/widgets/offroad_alerts.py @@ -1,37 +1,45 @@ import pyray as rl +from enum import IntEnum from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass from openpilot.common.params import Params from openpilot.system.hardware import HARDWARE -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.html_render import HtmlRenderer from openpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS +NO_RELEASE_NOTES = tr("

No release notes available.

") + + class AlertColors: HIGH_SEVERITY = rl.Color(226, 44, 44, 255) LOW_SEVERITY = rl.Color(41, 41, 41, 255) BACKGROUND = rl.Color(57, 57, 57, 255) BUTTON = rl.WHITE + BUTTON_PRESSED = rl.Color(200, 200, 200, 255) BUTTON_TEXT = rl.BLACK SNOOZE_BG = rl.Color(79, 79, 79, 255) + SNOOZE_BG_PRESSED = rl.Color(100, 100, 100, 255) TEXT = rl.WHITE class AlertConstants: - BUTTON_SIZE = (400, 125) - SNOOZE_BUTTON_SIZE = (550, 125) - REBOOT_BUTTON_SIZE = (600, 125) + MIN_BUTTON_WIDTH = 400 + BUTTON_HEIGHT = 125 MARGIN = 50 SPACING = 30 FONT_SIZE = 48 - BORDER_RADIUS = 30 + BORDER_RADIUS = 30 * 2 # matches Qt's 30px ALERT_HEIGHT = 120 - ALERT_SPACING = 20 + ALERT_SPACING = 10 + ALERT_INSET = 60 @dataclass @@ -42,6 +50,41 @@ class AlertData: visible: bool = False +class ButtonStyle(IntEnum): + LIGHT = 0 + DARK = 1 + + +class ActionButton(Widget): + def __init__(self, text: str, style: ButtonStyle = ButtonStyle.LIGHT, + min_width: int = AlertConstants.MIN_BUTTON_WIDTH): + super().__init__() + self._style = style + self._min_width = min_width + self._font = gui_app.font(FontWeight.MEDIUM) + self.set_text(text) + + def set_text(self, text: str): + self._text = text + self._text_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), self._text, AlertConstants.FONT_SIZE) + self._rect.width = max(self._text_size.x + 60 * 2, self._min_width) + self._rect.height = AlertConstants.BUTTON_HEIGHT + + def _render(self, _): + roundness = AlertConstants.BORDER_RADIUS / self._rect.height + bg_color = AlertColors.BUTTON if self._style == ButtonStyle.LIGHT else AlertColors.SNOOZE_BG + if self.is_pressed: + bg_color = AlertColors.BUTTON_PRESSED if self._style == ButtonStyle.LIGHT else AlertColors.SNOOZE_BG_PRESSED + + rl.draw_rectangle_rounded(self._rect, roundness, 10, bg_color) + + # center text + color = rl.WHITE if self._style == ButtonStyle.DARK else rl.BLACK + text_x = int(self._rect.x + (self._rect.width - self._text_size.x) // 2) + text_y = int(self._rect.y + (self._rect.height - self._text_size.y) // 2) + rl.draw_text_ex(self._font, self._text, rl.Vector2(text_x, text_y), AlertConstants.FONT_SIZE, 0, color) + + class AbstractAlert(Widget, ABC): def __init__(self, has_reboot_btn: bool = False): super().__init__() @@ -49,17 +92,38 @@ class AbstractAlert(Widget, ABC): self.has_reboot_btn = has_reboot_btn self.dismiss_callback: Callable | None = None - self.dismiss_btn_rect = rl.Rectangle(0, 0, *AlertConstants.BUTTON_SIZE) - self.snooze_btn_rect = rl.Rectangle(0, 0, *AlertConstants.SNOOZE_BUTTON_SIZE) - self.reboot_btn_rect = rl.Rectangle(0, 0, *AlertConstants.REBOOT_BUTTON_SIZE) + def snooze_callback(): + self.params.put_bool("SnoozeUpdate", True) + if self.dismiss_callback: + self.dismiss_callback() + + def excessive_actuation_callback(): + self.params.remove("Offroad_ExcessiveActuation") + if self.dismiss_callback: + self.dismiss_callback() + + self.dismiss_btn = ActionButton(tr("Close")) + + self.snooze_btn = ActionButton(tr("Snooze Update"), style=ButtonStyle.DARK) + self.snooze_btn.set_click_callback(snooze_callback) + + self.excessive_actuation_btn = ActionButton(tr("Acknowledge Excessive Actuation"), style=ButtonStyle.DARK, min_width=800) + self.excessive_actuation_btn.set_click_callback(excessive_actuation_callback) + + self.reboot_btn = ActionButton(tr("Reboot and Update"), min_width=600) + self.reboot_btn.set_click_callback(lambda: HARDWARE.reboot()) - self.snooze_visible = False + # TODO: just use a Scroller? self.content_rect = rl.Rectangle(0, 0, 0, 0) self.scroll_panel_rect = rl.Rectangle(0, 0, 0, 0) self.scroll_panel = GuiScrollPanel() + def show_event(self): + self.scroll_panel.set_offset(0) + def set_dismiss_callback(self, callback: Callable): self.dismiss_callback = callback + self.dismiss_btn.set_click_callback(self.dismiss_callback) @abstractmethod def refresh(self) -> bool: @@ -69,28 +133,10 @@ class AbstractAlert(Widget, ABC): def get_content_height(self) -> float: pass - def _handle_mouse_release(self, mouse_pos: MousePos): - super()._handle_mouse_release(mouse_pos) - - if not self.scroll_panel.is_touch_valid(): - return - - if rl.check_collision_point_rec(mouse_pos, self.dismiss_btn_rect): - if self.dismiss_callback: - self.dismiss_callback() - - elif self.snooze_visible and rl.check_collision_point_rec(mouse_pos, self.snooze_btn_rect): - self.params.put_bool("SnoozeUpdate", True) - if self.dismiss_callback: - self.dismiss_callback() - - elif self.has_reboot_btn and rl.check_collision_point_rec(mouse_pos, self.reboot_btn_rect): - HARDWARE.reboot() - def _render(self, rect: rl.Rectangle): - rl.draw_rectangle_rounded(rect, AlertConstants.BORDER_RADIUS / rect.width, 10, AlertColors.BACKGROUND) + rl.draw_rectangle_rounded(rect, AlertConstants.BORDER_RADIUS / rect.height, 10, AlertColors.BACKGROUND) - footer_height = AlertConstants.BUTTON_SIZE[1] + AlertConstants.SPACING + footer_height = AlertConstants.BUTTON_HEIGHT + AlertConstants.SPACING content_height = rect.height - 2 * AlertConstants.MARGIN - footer_height self.content_rect = rl.Rectangle( @@ -109,7 +155,7 @@ class AbstractAlert(Widget, ABC): def _render_scrollable_content(self): content_total_height = self.get_content_height() content_bounds = rl.Rectangle(0, 0, self.scroll_panel_rect.width, content_total_height) - scroll_offset = self.scroll_panel.handle_scroll(self.scroll_panel_rect, content_bounds) + scroll_offset = self.scroll_panel.update(self.scroll_panel_rect, content_bounds) rl.begin_scissor_mode( int(self.scroll_panel_rect.x), @@ -120,7 +166,7 @@ class AbstractAlert(Widget, ABC): content_rect_with_scroll = rl.Rectangle( self.scroll_panel_rect.x, - self.scroll_panel_rect.y + scroll_offset.y, + self.scroll_panel_rect.y + scroll_offset, self.scroll_panel_rect.width, content_total_height, ) @@ -133,44 +179,26 @@ class AbstractAlert(Widget, ABC): pass def _render_footer(self, rect: rl.Rectangle): - footer_y = rect.y + rect.height - AlertConstants.MARGIN - AlertConstants.BUTTON_SIZE[1] - font = gui_app.font(FontWeight.MEDIUM) - - self.dismiss_btn_rect.x = rect.x + AlertConstants.MARGIN - self.dismiss_btn_rect.y = footer_y - rl.draw_rectangle_rounded(self.dismiss_btn_rect, 0.3, 10, AlertColors.BUTTON) - - text = "Close" - text_width = measure_text_cached(font, text, AlertConstants.FONT_SIZE).x - text_x = self.dismiss_btn_rect.x + (AlertConstants.BUTTON_SIZE[0] - text_width) // 2 - text_y = self.dismiss_btn_rect.y + (AlertConstants.BUTTON_SIZE[1] - AlertConstants.FONT_SIZE) // 2 - rl.draw_text_ex( - font, text, rl.Vector2(int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.BUTTON_TEXT - ) + footer_y = rect.y + rect.height - AlertConstants.MARGIN - AlertConstants.BUTTON_HEIGHT - if self.snooze_visible: - self.snooze_btn_rect.x = rect.x + rect.width - AlertConstants.MARGIN - AlertConstants.SNOOZE_BUTTON_SIZE[0] - self.snooze_btn_rect.y = footer_y - rl.draw_rectangle_rounded(self.snooze_btn_rect, 0.3, 10, AlertColors.SNOOZE_BG) - - text = "Snooze Update" - text_width = measure_text_cached(font, text, AlertConstants.FONT_SIZE).x - text_x = self.snooze_btn_rect.x + (AlertConstants.SNOOZE_BUTTON_SIZE[0] - text_width) // 2 - text_y = self.snooze_btn_rect.y + (AlertConstants.SNOOZE_BUTTON_SIZE[1] - AlertConstants.FONT_SIZE) // 2 - rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.TEXT) - - elif self.has_reboot_btn: - self.reboot_btn_rect.x = rect.x + rect.width - AlertConstants.MARGIN - AlertConstants.REBOOT_BUTTON_SIZE[0] - self.reboot_btn_rect.y = footer_y - rl.draw_rectangle_rounded(self.reboot_btn_rect, 0.3, 10, AlertColors.BUTTON) - - text = "Reboot and Update" - text_width = measure_text_cached(font, text, AlertConstants.FONT_SIZE).x - text_x = self.reboot_btn_rect.x + (AlertConstants.REBOOT_BUTTON_SIZE[0] - text_width) // 2 - text_y = self.reboot_btn_rect.y + (AlertConstants.REBOOT_BUTTON_SIZE[1] - AlertConstants.FONT_SIZE) // 2 - rl.draw_text_ex( - font, text, rl.Vector2(int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.BUTTON_TEXT - ) + dismiss_x = rect.x + AlertConstants.MARGIN + self.dismiss_btn.set_position(dismiss_x, footer_y) + self.dismiss_btn.render() + + if self.has_reboot_btn: + reboot_x = rect.x + rect.width - AlertConstants.MARGIN - self.reboot_btn.rect.width + self.reboot_btn.set_position(reboot_x, footer_y) + self.reboot_btn.render() + + elif self.excessive_actuation_btn.is_visible: + actuation_x = rect.x + rect.width - AlertConstants.MARGIN - self.excessive_actuation_btn.rect.width + self.excessive_actuation_btn.set_position(actuation_x, footer_y) + self.excessive_actuation_btn.render() + + elif self.snooze_btn.is_visible: + snooze_x = rect.x + rect.width - AlertConstants.MARGIN - self.snooze_btn.rect.width + self.snooze_btn.set_position(snooze_x, footer_y) + self.snooze_btn.render() class OffroadAlert(AbstractAlert): @@ -184,13 +212,14 @@ class OffroadAlert(AbstractAlert): active_count = 0 connectivity_needed = False + excessive_actuation = False for alert_data in self.sorted_alerts: text = "" alert_json = self.params.get(alert_data.key) if alert_json: - text = alert_json.get("text", "").replace("{}", alert_json.get("extra", "")) + text = alert_json.get("text", "").replace("%1", alert_json.get("extra", "")) alert_data.text = text alert_data.visible = bool(text) @@ -201,7 +230,11 @@ class OffroadAlert(AbstractAlert): if alert_data.key == "Offroad_ConnectivityNeeded" and alert_data.visible: connectivity_needed = True - self.snooze_visible = connectivity_needed + if alert_data.key == "Offroad_ExcessiveActuation" and alert_data.visible: + excessive_actuation = True + + self.excessive_actuation_btn.set_visible(excessive_actuation) + self.snooze_btn.set_visible(connectivity_needed and not excessive_actuation) return active_count def get_content_height(self) -> float: @@ -215,12 +248,12 @@ class OffroadAlert(AbstractAlert): if not alert_data.visible: continue - text_width = int(self.content_rect.width - 90) + text_width = int(self.content_rect.width - (AlertConstants.ALERT_INSET * 2)) wrapped_lines = wrap_text(font, alert_data.text, AlertConstants.FONT_SIZE, text_width) line_count = len(wrapped_lines) - text_height = line_count * (AlertConstants.FONT_SIZE + 5) - alert_item_height = max(text_height + 40, AlertConstants.ALERT_HEIGHT) - total_height += alert_item_height + AlertConstants.ALERT_SPACING + text_height = line_count * (AlertConstants.FONT_SIZE * FONT_SCALE) + alert_item_height = max(text_height + (AlertConstants.ALERT_INSET * 2), AlertConstants.ALERT_HEIGHT) + total_height += round(alert_item_height + AlertConstants.ALERT_SPACING) if total_height > 20: total_height = total_height - AlertConstants.ALERT_SPACING + 20 @@ -235,7 +268,7 @@ class OffroadAlert(AbstractAlert): self.sorted_alerts.append(alert_data) def _render_content(self, content_rect: rl.Rectangle): - y_offset = 20 + y_offset = AlertConstants.ALERT_SPACING font = gui_app.font(FontWeight.NORMAL) for alert_data in self.sorted_alerts: @@ -243,11 +276,11 @@ class OffroadAlert(AbstractAlert): continue bg_color = AlertColors.HIGH_SEVERITY if alert_data.severity > 0 else AlertColors.LOW_SEVERITY - text_width = int(content_rect.width - 90) + text_width = int(content_rect.width - (AlertConstants.ALERT_INSET * 2)) wrapped_lines = wrap_text(font, alert_data.text, AlertConstants.FONT_SIZE, text_width) line_count = len(wrapped_lines) - text_height = line_count * (AlertConstants.FONT_SIZE + 5) - alert_item_height = max(text_height + 40, AlertConstants.ALERT_HEIGHT) + text_height = line_count * (AlertConstants.FONT_SIZE * FONT_SCALE) + alert_item_height = max(text_height + (AlertConstants.ALERT_INSET * 2), AlertConstants.ALERT_HEIGHT) alert_rect = rl.Rectangle( content_rect.x + 10, @@ -256,22 +289,23 @@ class OffroadAlert(AbstractAlert): alert_item_height, ) - rl.draw_rectangle_rounded(alert_rect, 0.2, 10, bg_color) + roundness = AlertConstants.BORDER_RADIUS / min(alert_rect.height, alert_rect.width) + rl.draw_rectangle_rounded(alert_rect, roundness, 10, bg_color) - text_x = alert_rect.x + 30 - text_y = alert_rect.y + 20 + text_x = alert_rect.x + AlertConstants.ALERT_INSET + text_y = alert_rect.y + AlertConstants.ALERT_INSET for i, line in enumerate(wrapped_lines): rl.draw_text_ex( font, line, - rl.Vector2(text_x, text_y + i * (AlertConstants.FONT_SIZE + 5)), + rl.Vector2(text_x, text_y + i * AlertConstants.FONT_SIZE * FONT_SCALE), AlertConstants.FONT_SIZE, 0, AlertColors.TEXT, ) - y_offset += alert_item_height + AlertConstants.ALERT_SPACING + y_offset += round(alert_item_height + AlertConstants.ALERT_SPACING) class UpdateAlert(AbstractAlert): @@ -280,12 +314,16 @@ class UpdateAlert(AbstractAlert): self.release_notes = "" self._wrapped_release_notes = "" self._cached_content_height: float = 0.0 + self._html_renderer = HtmlRenderer(text="") def refresh(self) -> bool: update_available: bool = self.params.get_bool("UpdateAvailable") if update_available: - self.release_notes = self.params.get("UpdaterNewReleaseNotes") + self.release_notes = (self.params.get("UpdaterNewReleaseNotes") or b"").decode("utf8").strip() + self._html_renderer.parse_html_content(self.release_notes or NO_RELEASE_NOTES) self._cached_content_height = 0 + else: + self._html_renderer.parse_html_content(NO_RELEASE_NOTES) return update_available @@ -301,18 +339,5 @@ class UpdateAlert(AbstractAlert): return self._cached_content_height def _render_content(self, content_rect: rl.Rectangle): - if self.release_notes: - rl.draw_text_ex( - gui_app.font(FontWeight.NORMAL), - self._wrapped_release_notes, - rl.Vector2(content_rect.x + 30, content_rect.y + 30), - AlertConstants.FONT_SIZE, - 0.0, - AlertColors.TEXT, - ) - else: - no_notes_text = "No release notes available." - text_width = rl.measure_text(no_notes_text, AlertConstants.FONT_SIZE) - text_x = content_rect.x + (content_rect.width - text_width) // 2 - text_y = content_rect.y + 50 - rl.draw_text(no_notes_text, int(text_x), int(text_y), AlertConstants.FONT_SIZE, AlertColors.TEXT) + notes_rect = rl.Rectangle(content_rect.x + 30, content_rect.y + 30, content_rect.width - 60, content_rect.height - 60) + self._html_renderer.render(notes_rect) diff --git a/selfdrive/ui/widgets/pairing_dialog.py b/selfdrive/ui/widgets/pairing_dialog.py index 55d53125d8..85b42d1a7a 100644 --- a/selfdrive/ui/widgets/pairing_dialog.py +++ b/selfdrive/ui/widgets/pairing_dialog.py @@ -8,11 +8,24 @@ from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params from openpilot.system.ui.widgets import Widget from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.selfdrive.ui.ui_state import ui_state +class IconButton(Widget): + def __init__(self, texture: rl.Texture): + super().__init__() + self._texture = texture + + def _render(self, rect: rl.Rectangle): + color = rl.Color(180, 180, 180, 150) if self.is_pressed else rl.WHITE + draw_x = rect.x + (rect.width - self._texture.width) / 2 + draw_y = rect.y + (rect.height - self._texture.height) / 2 + rl.draw_texture(self._texture, int(draw_x), int(draw_y), color) + + class PairingDialog(Widget): """Dialog for device pairing with QR code.""" @@ -22,14 +35,16 @@ class PairingDialog(Widget): super().__init__() self.params = Params() self.qr_texture: rl.Texture | None = None - self.last_qr_generation = 0 + self.last_qr_generation = float('-inf') + self._close_btn = IconButton(gui_app.texture("icons/close.png", 80, 80)) + self._close_btn.set_click_callback(lambda: gui_app.set_modal_overlay(None)) def _get_pairing_url(self) -> str: try: dongle_id = self.params.get("DongleId") or "" token = Api(dongle_id).get_token({'pair': True}) - except Exception as e: - cloudlog.warning(f"Failed to get pairing token: {e}") + except Exception: + cloudlog.exception("Failed to get pairing token") token = "" return f"https://connect.comma.ai/?pair={token}" @@ -53,8 +68,8 @@ class PairingDialog(Widget): rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 self.qr_texture = rl.load_texture_from_image(rl_image) - except Exception as e: - cloudlog.warning(f"QR code generation failed: {e}") + except Exception: + cloudlog.exception("QR code generation failed") self.qr_texture = None def _check_qr_refresh(self) -> None: @@ -78,24 +93,14 @@ class PairingDialog(Widget): # Close button close_size = 80 - close_icon = gui_app.texture("icons/close.png", close_size, close_size) - close_rect = rl.Rectangle(content_rect.x, y, close_size, close_size) - - mouse_pos = rl.get_mouse_position() - is_hover = rl.check_collision_point_rec(mouse_pos, close_rect) - is_pressed = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) - is_released = rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) - - color = rl.Color(180, 180, 180, 150) if (is_hover and is_pressed) else rl.WHITE - rl.draw_texture(close_icon, int(content_rect.x), int(y), color) - - if (is_hover and is_released) or rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE): - return 1 + pad = 20 + close_rect = rl.Rectangle(content_rect.x - pad, y - pad, close_size + pad * 2, close_size + pad * 2) + self._close_btn.render(close_rect) y += close_size + 40 # Title - title = "Pair your device to your comma account" + title = tr("Pair your device to your comma account") title_font = gui_app.font(FontWeight.NORMAL) left_width = int(content_rect.width * 0.5 - 15) @@ -120,9 +125,9 @@ class PairingDialog(Widget): def _render_instructions(self, rect: rl.Rectangle) -> None: instructions = [ - "Go to https://connect.comma.ai on your phone", - "Click \"add new device\" and scan the QR code on the right", - "Bookmark connect.comma.ai to your home screen to use it like an app", + tr("Go to https://connect.comma.ai on your phone"), + tr("Click \"add new device\" and scan the QR code on the right"), + tr("Bookmark connect.comma.ai to your home screen to use it like an app"), ] font = gui_app.font(FontWeight.BOLD) @@ -141,8 +146,8 @@ class PairingDialog(Widget): # Circle and number rl.draw_circle(int(circle_x), int(circle_y), circle_radius, rl.Color(70, 70, 70, 255)) number = str(i + 1) - number_width = measure_text_cached(font, number, 30).x - rl.draw_text(number, int(circle_x - number_width // 2), int(circle_y - 15), 30, rl.WHITE) + number_size = measure_text_cached(font, number, 30) + rl.draw_text_ex(font, number, (int(circle_x - number_size.x // 2), int(circle_y - number_size.y // 2)), 30, 0, rl.WHITE) # Text rl.draw_text_ex(font, "\n".join(wrapped), rl.Vector2(text_x, y), 47, 0.0, rl.BLACK) @@ -153,7 +158,7 @@ class PairingDialog(Widget): rl.draw_rectangle_rounded(rect, 0.1, 20, rl.Color(240, 240, 240, 255)) error_font = gui_app.font(FontWeight.BOLD) rl.draw_text_ex( - error_font, "QR Code Error", rl.Vector2(rect.x + 20, rect.y + rect.height // 2 - 15), 30, 0.0, rl.RED + error_font, tr("QR Code Error"), rl.Vector2(rect.x + 20, rect.y + rect.height // 2 - 15), 30, 0.0, rl.RED ) return diff --git a/selfdrive/ui/widgets/prime.py b/selfdrive/ui/widgets/prime.py index 6b601f6dff..49a0e56cdc 100644 --- a/selfdrive/ui/widgets/prime.py +++ b/selfdrive/ui/widgets/prime.py @@ -2,6 +2,7 @@ import pyray as rl from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.widgets import Widget @@ -22,41 +23,41 @@ class PrimeWidget(Widget): def _render_for_non_prime_users(self, rect: rl.Rectangle): """Renders the advertisement for non-Prime users.""" - rl.draw_rectangle_rounded(rect, 0.02, 10, self.PRIME_BG_COLOR) + rl.draw_rectangle_rounded(rect, 0.025, 10, self.PRIME_BG_COLOR) # Layout x, y = rect.x + 80, rect.y + 90 w = rect.width - 160 # Title - gui_label(rl.Rectangle(x, y, w, 90), "Upgrade Now", 75, font_weight=FontWeight.BOLD) + gui_label(rl.Rectangle(x, y, w, 90), tr("Upgrade Now"), 75, font_weight=FontWeight.BOLD) # Description with wrapping desc_y = y + 140 font = gui_app.font(FontWeight.LIGHT) - wrapped_text = "\n".join(wrap_text(font, "Become a comma prime member at connect.comma.ai", 56, int(w))) + wrapped_text = "\n".join(wrap_text(font, tr("Become a comma prime member at connect.comma.ai"), 56, int(w))) text_size = measure_text_cached(font, wrapped_text, 56) rl.draw_text_ex(font, wrapped_text, rl.Vector2(x, desc_y), 56, 0, rl.WHITE) # Features section features_y = desc_y + text_size.y + 50 - gui_label(rl.Rectangle(x, features_y, w, 50), "PRIME FEATURES:", 41, font_weight=FontWeight.BOLD) + gui_label(rl.Rectangle(x, features_y, w, 50), tr("PRIME FEATURES:"), 41, font_weight=FontWeight.BOLD) # Feature list - features = ["Remote access", "24/7 LTE connectivity", "1 year of drive storage", "Remote snapshots"] + features = [tr("Remote access"), tr("24/7 LTE connectivity"), tr("1 year of drive storage"), tr("Remote snapshots")] for i, feature in enumerate(features): item_y = features_y + 80 + i * 65 - gui_label(rl.Rectangle(x, item_y, 50, 60), "✓", 50, color=rl.Color(70, 91, 234, 255)) + gui_label(rl.Rectangle(x, item_y, 100, 60), "✓", 50, color=rl.Color(70, 91, 234, 255)) gui_label(rl.Rectangle(x + 60, item_y, w - 60, 60), feature, 50) def _render_for_prime_user(self, rect: rl.Rectangle): """Renders the prime user widget with subscription status.""" - rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, 230), 0.02, 10, self.PRIME_BG_COLOR) + rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, 230), 0.1, 10, self.PRIME_BG_COLOR) x = rect.x + 56 y = rect.y + 40 font = gui_app.font(FontWeight.BOLD) - rl.draw_text_ex(font, "✓ SUBSCRIBED", rl.Vector2(x, y), 41, 0, rl.Color(134, 255, 78, 255)) - rl.draw_text_ex(font, "comma prime", rl.Vector2(x, y + 61), 75, 0, rl.WHITE) + rl.draw_text_ex(font, tr("✓ SUBSCRIBED"), rl.Vector2(x, y), 41, 0, rl.Color(134, 255, 78, 255)) + rl.draw_text_ex(font, tr("comma prime"), rl.Vector2(x, y + 61), 75, 0, rl.WHITE) diff --git a/selfdrive/ui/widgets/setup.py b/selfdrive/ui/widgets/setup.py index bf6d113f62..ea88180ef8 100644 --- a/selfdrive/ui/widgets/setup.py +++ b/selfdrive/ui/widgets/setup.py @@ -1,10 +1,14 @@ import pyray as rl +from openpilot.common.time_helpers import system_time_valid from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.confirm_dialog import alert_dialog from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.widgets.label import Label class SetupWidget(Widget): @@ -12,9 +16,10 @@ class SetupWidget(Widget): super().__init__() self._open_settings_callback = None self._pairing_dialog: PairingDialog | None = None - self._pair_device_btn = Button("Pair device", self._show_pairing, button_style=ButtonStyle.PRIMARY) - self._open_settings_btn = Button("Open", lambda: self._open_settings_callback() if self._open_settings_callback else None, + self._pair_device_btn = Button(tr("Pair device"), self._show_pairing, button_style=ButtonStyle.PRIMARY) + self._open_settings_btn = Button(tr("Open"), lambda: self._open_settings_callback() if self._open_settings_callback else None, button_style=ButtonStyle.PRIMARY) + self._firehose_label = Label(tr("🔥 Firehose Mode 🔥"), font_weight=FontWeight.MEDIUM, font_size=64) def set_open_settings_callback(self, callback): self._open_settings_callback = callback @@ -28,7 +33,7 @@ class SetupWidget(Widget): def _render_registration(self, rect: rl.Rectangle): """Render registration prompt.""" - rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, 590), 0.02, 20, rl.Color(51, 51, 51, 255)) + rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, rect.height), 0.03, 20, rl.Color(51, 51, 51, 255)) x = rect.x + 64 y = rect.y + 48 @@ -36,24 +41,24 @@ class SetupWidget(Widget): # Title font = gui_app.font(FontWeight.BOLD) - rl.draw_text_ex(font, "Finish Setup", rl.Vector2(x, y), 75, 0, rl.WHITE) + rl.draw_text_ex(font, tr("Finish Setup"), rl.Vector2(x, y), 75, 0, rl.WHITE) y += 113 # 75 + 38 spacing # Description - desc = "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." + desc = tr("Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer.") light_font = gui_app.font(FontWeight.LIGHT) wrapped = wrap_text(light_font, desc, 50, int(w)) for line in wrapped: rl.draw_text_ex(light_font, line, rl.Vector2(x, y), 50, 0, rl.WHITE) - y += 50 + y += 50 * FONT_SCALE - button_rect = rl.Rectangle(x, y + 50, w, 128) + button_rect = rl.Rectangle(x, y + 30, w, 200) self._pair_device_btn.render(button_rect) def _render_firehose_prompt(self, rect: rl.Rectangle): """Render firehose prompt widget.""" - rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, 450), 0.02, 20, rl.Color(51, 51, 51, 255)) + rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width, 500), 0.04, 20, rl.Color(51, 51, 51, 255)) # Content margins (56, 40, 56, 40) x = rect.x + 56 @@ -62,19 +67,17 @@ class SetupWidget(Widget): spacing = 42 # Title with fire emojis - title_font = gui_app.font(FontWeight.MEDIUM) - title_text = "Firehose Mode" - rl.draw_text_ex(title_font, title_text, rl.Vector2(x, y), 64, 0, rl.WHITE) + self._firehose_label.render(rl.Rectangle(rect.x, y, rect.width, 64)) y += 64 + spacing # Description desc_font = gui_app.font(FontWeight.NORMAL) - desc_text = "Maximize your training data uploads to improve openpilot's driving models." + desc_text = tr("Maximize your training data uploads to improve openpilot's driving models.") wrapped_desc = wrap_text(desc_font, desc_text, 40, int(w)) for line in wrapped_desc: rl.draw_text_ex(desc_font, line, rl.Vector2(x, y), 40, 0, rl.WHITE) - y += 40 + y += 40 * FONT_SCALE y += spacing @@ -84,6 +87,11 @@ class SetupWidget(Widget): self._open_settings_btn.render(button_rect) def _show_pairing(self): + if not system_time_valid(): + dlg = alert_dialog(tr("Please connect to Wi-Fi to complete initial pairing")) + gui_app.set_modal_overlay(dlg) + return + if not self._pairing_dialog: self._pairing_dialog = PairingDialog() gui_app.set_modal_overlay(self._pairing_dialog, lambda result: setattr(self, '_pairing_dialog', None)) diff --git a/selfdrive/ui/widgets/ssh_key.py b/selfdrive/ui/widgets/ssh_key.py index 4f4a8dcdff..e44c4aad5d 100644 --- a/selfdrive/ui/widgets/ssh_key.py +++ b/selfdrive/ui/widgets/ssh_key.py @@ -2,13 +2,15 @@ import pyray as rl import requests import threading import copy +from collections.abc import Callable from enum import Enum from openpilot.common.params import Params from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import DialogResult -from openpilot.system.ui.widgets.button import gui_button, ButtonStyle +from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.confirm_dialog import alert_dialog from openpilot.system.ui.widgets.keyboard import Keyboard from openpilot.system.ui.widgets.list_view import ( @@ -20,11 +22,13 @@ from openpilot.system.ui.widgets.list_view import ( BUTTON_WIDTH, ) +VALUE_FONT_SIZE = 48 + class SshKeyActionState(Enum): - LOADING = "LOADING" - ADD = "ADD" - REMOVE = "REMOVE" + LOADING = tr("LOADING") + ADD = tr("ADD") + REMOVE = tr("REMOVE") class SshKeyAction(ItemAction): @@ -34,13 +38,19 @@ class SshKeyAction(ItemAction): def __init__(self): super().__init__(self.MAX_WIDTH, True) - self._keyboard = Keyboard() + self._keyboard = Keyboard(min_text_size=1) self._params = Params() self._error_message: str = "" - self._text_font = gui_app.font(FontWeight.MEDIUM) + self._text_font = gui_app.font(FontWeight.NORMAL) + self._button = Button("", click_callback=self._handle_button_click, button_style=ButtonStyle.LIST_ACTION, + border_radius=BUTTON_BORDER_RADIUS, font_size=BUTTON_FONT_SIZE) self._refresh_state() + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + super().set_touch_valid_callback(touch_callback) + self._button.set_touch_valid_callback(touch_callback) + def _refresh_state(self): self._username = self._params.get("GithubUsername") self._state = SshKeyActionState.REMOVE if self._params.get("GithubSshKeys") else SshKeyActionState.ADD @@ -49,41 +59,34 @@ class SshKeyAction(ItemAction): # Show error dialog if there's an error if self._error_message: message = copy.copy(self._error_message) - gui_app.set_modal_overlay(lambda: alert_dialog(message)) + gui_app.set_modal_overlay(alert_dialog(message)) self._username = "" self._error_message = "" # Draw username if exists if self._username: - text_size = measure_text_cached(self._text_font, self._username, BUTTON_FONT_SIZE) + text_size = measure_text_cached(self._text_font, self._username, VALUE_FONT_SIZE) rl.draw_text_ex( self._text_font, self._username, (rect.x + rect.width - BUTTON_WIDTH - text_size.x - 30, rect.y + (rect.height - text_size.y) / 2), - BUTTON_FONT_SIZE, + VALUE_FONT_SIZE, 1.0, - rl.WHITE, + rl.Color(170, 170, 170, 255), ) # Draw button - if gui_button( - rl.Rectangle( - rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT - ), - self._state.value, - is_enabled=self._state != SshKeyActionState.LOADING, - border_radius=BUTTON_BORDER_RADIUS, - font_size=BUTTON_FONT_SIZE, - button_style=ButtonStyle.LIST_ACTION, - ): - self._handle_button_click() - return True + button_rect = rl.Rectangle(rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT) + self._button.set_rect(button_rect) + self._button.set_text(self._state.value) + self._button.set_enabled(self._state != SshKeyActionState.LOADING) + self._button.render(button_rect) return False def _handle_button_click(self): if self._state == SshKeyActionState.ADD: - self._keyboard.clear() - self._keyboard.set_title("Enter your GitHub username") + self._keyboard.reset() + self._keyboard.set_title(tr("Enter your GitHub username")) gui_app.set_modal_overlay(self._keyboard, callback=self._on_username_submit) elif self._state == SshKeyActionState.REMOVE: self._params.remove("GithubUsername") @@ -108,7 +111,7 @@ class SshKeyAction(ItemAction): response.raise_for_status() keys = response.text.strip() if not keys: - raise requests.exceptions.HTTPError("No SSH keys found") + raise requests.exceptions.HTTPError(tr("No SSH keys found")) # Success - save keys self._params.put("GithubUsername", username) @@ -117,10 +120,10 @@ class SshKeyAction(ItemAction): self._username = username except requests.exceptions.Timeout: - self._error_message = "Request timed out" + self._error_message = tr("Request timed out") self._state = SshKeyActionState.ADD except Exception: - self._error_message = f"No SSH keys found for user '{username}'" + self._error_message = tr("No SSH keys found for user '{}'").format(username) self._state = SshKeyActionState.ADD diff --git a/system/camerad/cameras/camera_qcom2.cc b/system/camerad/cameras/camera_qcom2.cc index 85f358977c..f2a064c606 100644 --- a/system/camerad/cameras/camera_qcom2.cc +++ b/system/camerad/cameras/camera_qcom2.cc @@ -89,7 +89,7 @@ void CameraState::set_exposure_rect() { // set areas for each camera, shouldn't be changed std::vector> ae_targets = { // (Rect, F) - std::make_pair((Rect){96, 250, 1734, 524}, 567.0), // wide + std::make_pair((Rect){96, 400, 1734, 524}, 567.0), // wide std::make_pair((Rect){96, 160, 1734, 986}, 2648.0), // road std::make_pair((Rect){96, 242, 1736, 906}, 567.0) // driver }; diff --git a/system/hardware/fan_controller.py b/system/hardware/fan_controller.py index 4c7adc0a3e..365688429a 100755 --- a/system/hardware/fan_controller.py +++ b/system/hardware/fan_controller.py @@ -18,7 +18,7 @@ class TiciFanController(BaseFanController): cloudlog.info("Setting up TICI fan handler") self.last_ignition = False - self.controller = PIDController(k_p=0, k_i=4e-3, k_f=1, rate=(1 / DT_HW)) + self.controller = PIDController(k_p=0, k_i=4e-3, rate=(1 / DT_HW)) def update(self, cur_temp: float, ignition: bool) -> int: self.controller.pos_limit = 100 if ignition else 30 diff --git a/system/hardware/tici/agnos.json b/system/hardware/tici/agnos.json index d93963cf2c..035d8aa011 100644 --- a/system/hardware/tici/agnos.json +++ b/system/hardware/tici/agnos.json @@ -1,25 +1,25 @@ [ { "name": "xbl", - "url": "https://commadist.azureedge.net/agnosupdate/xbl-effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b.img.xz", - "hash": "effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b", - "hash_raw": "effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b", + "url": "https://commadist.azureedge.net/agnosupdate/xbl-98e0642e2af77596e64d107b2c525a36e54d41d879268fd2fcab9255a2b29723.img.xz", + "hash": "98e0642e2af77596e64d107b2c525a36e54d41d879268fd2fcab9255a2b29723", + "hash_raw": "98e0642e2af77596e64d107b2c525a36e54d41d879268fd2fcab9255a2b29723", "size": 3282256, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "ed61a650bea0c56652dd0fc68465d8fc722a4e6489dc8f257630c42c6adcdc89" + "ondevice_hash": "907c705f72ebcbd3030e03da9ef4c65a3d599e056a79aa9e7c369fdff8e54dc4" }, { "name": "xbl_config", - "url": "https://commadist.azureedge.net/agnosupdate/xbl_config-63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c.img.xz", - "hash": "63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c", - "hash_raw": "63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c", + "url": "https://commadist.azureedge.net/agnosupdate/xbl_config-4d8add65e80b3e5ca49a64fac76025ee3a57a1523abd9caa407aa8c5fb721b0f.img.xz", + "hash": "4d8add65e80b3e5ca49a64fac76025ee3a57a1523abd9caa407aa8c5fb721b0f", + "hash_raw": "4d8add65e80b3e5ca49a64fac76025ee3a57a1523abd9caa407aa8c5fb721b0f", "size": 98124, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "b12801ffaa81e58e3cef914488d3b447e35483ba549b28c6cd9deb4814c3265f" + "ondevice_hash": "46c472f52fb97a4836d08d0e790f5c8512651f520ce004bc3bbc6a143fc7a3c2" }, { "name": "abl", @@ -34,51 +34,51 @@ }, { "name": "aop", - "url": "https://commadist.azureedge.net/agnosupdate/aop-21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9.img.xz", - "hash": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9", - "hash_raw": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9", + "url": "https://commadist.azureedge.net/agnosupdate/aop-d8add1d4c1b6b443debf7bb80040e88a12140d248a328650d65ceaa0df04c1b7.img.xz", + "hash": "d8add1d4c1b6b443debf7bb80040e88a12140d248a328650d65ceaa0df04c1b7", + "hash_raw": "d8add1d4c1b6b443debf7bb80040e88a12140d248a328650d65ceaa0df04c1b7", "size": 184364, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "c1be2f4aac5b3af49b904b027faec418d05efd7bd5144eb4fdfcba602bcf2180" + "ondevice_hash": "e320da0d3f73aa09277a8be740c59f9cc605d2098b46a842c93ea2ac0ac97cb0" }, { "name": "devcfg", - "url": "https://commadist.azureedge.net/agnosupdate/devcfg-d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620.img.xz", - "hash": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620", - "hash_raw": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620", + "url": "https://commadist.azureedge.net/agnosupdate/devcfg-7e8a836cf75a9097b1c78960d36f883699fcc3858d8a1d28338f889f6af25cc8.img.xz", + "hash": "7e8a836cf75a9097b1c78960d36f883699fcc3858d8a1d28338f889f6af25cc8", + "hash_raw": "7e8a836cf75a9097b1c78960d36f883699fcc3858d8a1d28338f889f6af25cc8", "size": 40336, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "17b229668b20305ff8fa3cd5f94716a3aaa1e5bf9d1c24117eff7f2f81ae719f" + "ondevice_hash": "7e9412d154036216e56c2346d24455dd45f56d6de4c9e8837597f22d59c83d93" }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-b96882012ab6cddda04f440009c798a6cff65977f984b12072e89afa592d86cb.img.xz", - "hash": "b96882012ab6cddda04f440009c798a6cff65977f984b12072e89afa592d86cb", - "hash_raw": "b96882012ab6cddda04f440009c798a6cff65977f984b12072e89afa592d86cb", - "size": 17442816, + "url": "https://commadist.azureedge.net/agnosupdate/boot-08291eb52a9d54b77e191ea1a78addf24aae28e15306ac3118d03ac9be29fbe9.img.xz", + "hash": "08291eb52a9d54b77e191ea1a78addf24aae28e15306ac3118d03ac9be29fbe9", + "hash_raw": "08291eb52a9d54b77e191ea1a78addf24aae28e15306ac3118d03ac9be29fbe9", + "size": 17868800, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "8ed6c2796be5c5b29d64e6413b8e878d5bd1a3981d15216d2b5e84140cc4ea2a" + "ondevice_hash": "18fab2e1eb2e43e5c39e20ee20e0d391586de528df6dbfdab6dabcdab835ee3e" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc.img.xz", - "hash": "325414e5c9f7516b2bf0fedb6abe6682f717897a6d84ab70d5afe91a59f244e9", - "hash_raw": "2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc", - "size": 4718592000, + "url": "https://commadist.azureedge.net/agnosupdate/system-1a653b2a2006eb19017b9f091928a51fbb0b91c1ab218971779936892c9bd71b.img.xz", + "hash": "8d4b4dd80a8a537adf82faa07928066bec4568eae73bdcf4a5f0da94fb77b485", + "hash_raw": "1a653b2a2006eb19017b9f091928a51fbb0b91c1ab218971779936892c9bd71b", + "size": 5368709120, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "79f4f6d0b5b4a416f0f31261b430943a78e37c26d0e226e0ef412fe0eae3c727", + "ondevice_hash": "a9569b9286fba882be003f9710383ae6de229a72db936e80be08dbd2c23f320e", "alt": { - "hash": "2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc", - "url": "https://commadist.azureedge.net/agnosupdate/system-2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc.img", - "size": 4718592000 + "hash": "1a653b2a2006eb19017b9f091928a51fbb0b91c1ab218971779936892c9bd71b", + "url": "https://commadist.azureedge.net/agnosupdate/system-1a653b2a2006eb19017b9f091928a51fbb0b91c1ab218971779936892c9bd71b.img", + "size": 5368709120 } } ] \ No newline at end of file diff --git a/system/hardware/tici/all-partitions.json b/system/hardware/tici/all-partitions.json index ebffc01dfd..6b99df1c38 100644 --- a/system/hardware/tici/all-partitions.json +++ b/system/hardware/tici/all-partitions.json @@ -130,25 +130,25 @@ }, { "name": "xbl", - "url": "https://commadist.azureedge.net/agnosupdate/xbl-effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b.img.xz", - "hash": "effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b", - "hash_raw": "effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b", + "url": "https://commadist.azureedge.net/agnosupdate/xbl-98e0642e2af77596e64d107b2c525a36e54d41d879268fd2fcab9255a2b29723.img.xz", + "hash": "98e0642e2af77596e64d107b2c525a36e54d41d879268fd2fcab9255a2b29723", + "hash_raw": "98e0642e2af77596e64d107b2c525a36e54d41d879268fd2fcab9255a2b29723", "size": 3282256, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "ed61a650bea0c56652dd0fc68465d8fc722a4e6489dc8f257630c42c6adcdc89" + "ondevice_hash": "907c705f72ebcbd3030e03da9ef4c65a3d599e056a79aa9e7c369fdff8e54dc4" }, { "name": "xbl_config", - "url": "https://commadist.azureedge.net/agnosupdate/xbl_config-63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c.img.xz", - "hash": "63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c", - "hash_raw": "63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c", + "url": "https://commadist.azureedge.net/agnosupdate/xbl_config-4d8add65e80b3e5ca49a64fac76025ee3a57a1523abd9caa407aa8c5fb721b0f.img.xz", + "hash": "4d8add65e80b3e5ca49a64fac76025ee3a57a1523abd9caa407aa8c5fb721b0f", + "hash_raw": "4d8add65e80b3e5ca49a64fac76025ee3a57a1523abd9caa407aa8c5fb721b0f", "size": 98124, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "b12801ffaa81e58e3cef914488d3b447e35483ba549b28c6cd9deb4814c3265f" + "ondevice_hash": "46c472f52fb97a4836d08d0e790f5c8512651f520ce004bc3bbc6a143fc7a3c2" }, { "name": "abl", @@ -163,14 +163,14 @@ }, { "name": "aop", - "url": "https://commadist.azureedge.net/agnosupdate/aop-21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9.img.xz", - "hash": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9", - "hash_raw": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9", + "url": "https://commadist.azureedge.net/agnosupdate/aop-d8add1d4c1b6b443debf7bb80040e88a12140d248a328650d65ceaa0df04c1b7.img.xz", + "hash": "d8add1d4c1b6b443debf7bb80040e88a12140d248a328650d65ceaa0df04c1b7", + "hash_raw": "d8add1d4c1b6b443debf7bb80040e88a12140d248a328650d65ceaa0df04c1b7", "size": 184364, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "c1be2f4aac5b3af49b904b027faec418d05efd7bd5144eb4fdfcba602bcf2180" + "ondevice_hash": "e320da0d3f73aa09277a8be740c59f9cc605d2098b46a842c93ea2ac0ac97cb0" }, { "name": "bluetooth", @@ -207,14 +207,14 @@ }, { "name": "devcfg", - "url": "https://commadist.azureedge.net/agnosupdate/devcfg-d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620.img.xz", - "hash": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620", - "hash_raw": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620", + "url": "https://commadist.azureedge.net/agnosupdate/devcfg-7e8a836cf75a9097b1c78960d36f883699fcc3858d8a1d28338f889f6af25cc8.img.xz", + "hash": "7e8a836cf75a9097b1c78960d36f883699fcc3858d8a1d28338f889f6af25cc8", + "hash_raw": "7e8a836cf75a9097b1c78960d36f883699fcc3858d8a1d28338f889f6af25cc8", "size": 40336, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "17b229668b20305ff8fa3cd5f94716a3aaa1e5bf9d1c24117eff7f2f81ae719f" + "ondevice_hash": "7e9412d154036216e56c2346d24455dd45f56d6de4c9e8837597f22d59c83d93" }, { "name": "devinfo", @@ -339,62 +339,62 @@ }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-b96882012ab6cddda04f440009c798a6cff65977f984b12072e89afa592d86cb.img.xz", - "hash": "b96882012ab6cddda04f440009c798a6cff65977f984b12072e89afa592d86cb", - "hash_raw": "b96882012ab6cddda04f440009c798a6cff65977f984b12072e89afa592d86cb", - "size": 17442816, + "url": "https://commadist.azureedge.net/agnosupdate/boot-08291eb52a9d54b77e191ea1a78addf24aae28e15306ac3118d03ac9be29fbe9.img.xz", + "hash": "08291eb52a9d54b77e191ea1a78addf24aae28e15306ac3118d03ac9be29fbe9", + "hash_raw": "08291eb52a9d54b77e191ea1a78addf24aae28e15306ac3118d03ac9be29fbe9", + "size": 17868800, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "8ed6c2796be5c5b29d64e6413b8e878d5bd1a3981d15216d2b5e84140cc4ea2a" + "ondevice_hash": "18fab2e1eb2e43e5c39e20ee20e0d391586de528df6dbfdab6dabcdab835ee3e" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc.img.xz", - "hash": "325414e5c9f7516b2bf0fedb6abe6682f717897a6d84ab70d5afe91a59f244e9", - "hash_raw": "2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc", - "size": 4718592000, + "url": "https://commadist.azureedge.net/agnosupdate/system-1a653b2a2006eb19017b9f091928a51fbb0b91c1ab218971779936892c9bd71b.img.xz", + "hash": "8d4b4dd80a8a537adf82faa07928066bec4568eae73bdcf4a5f0da94fb77b485", + "hash_raw": "1a653b2a2006eb19017b9f091928a51fbb0b91c1ab218971779936892c9bd71b", + "size": 5368709120, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "79f4f6d0b5b4a416f0f31261b430943a78e37c26d0e226e0ef412fe0eae3c727", + "ondevice_hash": "a9569b9286fba882be003f9710383ae6de229a72db936e80be08dbd2c23f320e", "alt": { - "hash": "2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc", - "url": "https://commadist.azureedge.net/agnosupdate/system-2b1bb223bf2100376ad5d543bfa4a483f33327b3478ec20ab36048388472c4bc.img", - "size": 4718592000 + "hash": "1a653b2a2006eb19017b9f091928a51fbb0b91c1ab218971779936892c9bd71b", + "url": "https://commadist.azureedge.net/agnosupdate/system-1a653b2a2006eb19017b9f091928a51fbb0b91c1ab218971779936892c9bd71b.img", + "size": 5368709120 } }, { "name": "userdata_90", - "url": "https://commadist.azureedge.net/agnosupdate/userdata_90-b3112984d2a8534a83d2ce43d35efdd10c7d163d9699f611f0f72ad9e9cb5af9.img.xz", - "hash": "bea163e6fb6ac6224c7f32619affb5afb834cd859971b0cab6d8297dd0098f0a", - "hash_raw": "b3112984d2a8534a83d2ce43d35efdd10c7d163d9699f611f0f72ad9e9cb5af9", + "url": "https://commadist.azureedge.net/agnosupdate/userdata_90-a154dec5ebad07f63ebef989a1f7e44c449b9fb94b1048157d426ff0e78feef8.img.xz", + "hash": "32ef650ba25cbf867eb4699096e33027aa0ab79e05de2d1dfee3601b00b4fdf6", + "hash_raw": "a154dec5ebad07f63ebef989a1f7e44c449b9fb94b1048157d426ff0e78feef8", "size": 96636764160, "sparse": true, "full_check": true, "has_ab": false, - "ondevice_hash": "f4841c6ae3207197886e5efbd50f44cc24822680d7b785fa2d2743c657f23287" + "ondevice_hash": "9f21158f9055983c237d47a8eea8e27e978b5f25383756a7a9363a7bd9f7f72e" }, { "name": "userdata_89", - "url": "https://commadist.azureedge.net/agnosupdate/userdata_89-3e63f670e4270474cec96f4da9250ee4e87e3106b0b043b7e82371e1c761e167.img.xz", - "hash": "b5458a29dd7d4a4c9b7ad77b8baa5f804142ac78d97c6668839bf2a650e32518", - "hash_raw": "3e63f670e4270474cec96f4da9250ee4e87e3106b0b043b7e82371e1c761e167", + "url": "https://commadist.azureedge.net/agnosupdate/userdata_89-31ebdff72d44d3f60bdf0920e39171795494c275b8cff023cf23ec592af7a4b3.img.xz", + "hash": "a62837b235be14b257baf05ddc6bddd026c8859bbb4f154d0323c7efa58cb938", + "hash_raw": "31ebdff72d44d3f60bdf0920e39171795494c275b8cff023cf23ec592af7a4b3", "size": 95563022336, "sparse": true, "full_check": true, "has_ab": false, - "ondevice_hash": "1dc10c542d3b019258fc08dc7dfdb49d9abad065e46d030b89bc1a2e0197f526" + "ondevice_hash": "a5caa169c840de6d1804b4186a1d26486be95e1837c4df16ec45952665356942" }, { "name": "userdata_30", - "url": "https://commadist.azureedge.net/agnosupdate/userdata_30-1d3885d4370974e55f0c6f567fd0344fc5ee10db067aa5810fbaf402eadb032c.img.xz", - "hash": "687d178cfc91be5d7e8aa1333405b610fdce01775b8333bd0985b81642b94eea", - "hash_raw": "1d3885d4370974e55f0c6f567fd0344fc5ee10db067aa5810fbaf402eadb032c", + "url": "https://commadist.azureedge.net/agnosupdate/userdata_30-16518389a1ed7ad6277dbab75d18aa13833fb4ed4010f456438f2c2ac8c61140.img.xz", + "hash": "cb8c2fc2ae83cacb86af4ce96c6d61e4bd3cd2591e612e12878c27fa51030ffa", + "hash_raw": "16518389a1ed7ad6277dbab75d18aa13833fb4ed4010f456438f2c2ac8c61140", "size": 32212254720, "sparse": true, "full_check": true, "has_ab": false, - "ondevice_hash": "9ddbd1dae6ee7dc919f018364cf2f29dad138c9203c5a49aea0cbb9bf2e137e5" + "ondevice_hash": "5dd8e1f87a3f985ece80f7a36da1cbdabd77bcc11d26fc7bb85540069eff8ead" } ] \ No newline at end of file diff --git a/system/hardware/tici/updater b/system/hardware/tici/updater index 23cdc140f4..69ce323a10 100755 --- a/system/hardware/tici/updater +++ b/system/hardware/tici/updater @@ -1,3 +1,17 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:eba5f44e6a763e1f74d1c718993218adcc72cba4caafe99b595fa701151a4c54 -size 10448792 +#!/usr/bin/env bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" + +AGNOS_PY=$1 +MANIFEST=$2 + +if [[ ! -f "$AGNOS_PY" || ! -f "$MANIFEST" ]]; then + echo "invalid args" + exit 1 +fi + +if systemctl is-active --quiet weston-ready; then + $DIR/updater_weston $AGNOS_PY $MANIFEST +else + $DIR/updater_magic $AGNOS_PY $MANIFEST +fi diff --git a/system/hardware/tici/updater_magic b/system/hardware/tici/updater_magic new file mode 100755 index 0000000000..2487973507 --- /dev/null +++ b/system/hardware/tici/updater_magic @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ffc9893e48b10096062f5fd1a14016addf7adb969f20f31ff26e68579992283c +size 20780744 diff --git a/system/hardware/tici/updater_weston b/system/hardware/tici/updater_weston new file mode 100755 index 0000000000..23cdc140f4 --- /dev/null +++ b/system/hardware/tici/updater_weston @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eba5f44e6a763e1f74d1c718993218adcc72cba4caafe99b595fa701151a4c54 +size 10448792 diff --git a/system/loggerd/loggerd.h b/system/loggerd/loggerd.h index 967caec867..8e3a74d2d9 100644 --- a/system/loggerd/loggerd.h +++ b/system/loggerd/loggerd.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "cereal/messaging/messaging.h" @@ -46,7 +47,8 @@ struct EncoderSettings { } static EncoderSettings StreamEncoderSettings() { - return EncoderSettings{.encode_type = cereal::EncodeIndex::Type::QCAMERA_H264, .bitrate = 1'000'000, .gop_size = 15}; + int _stream_bitrate = getenv("STREAM_BITRATE") ? atoi(getenv("STREAM_BITRATE")) : 1'000'000; + return EncoderSettings{.encode_type = cereal::EncodeIndex::Type::QCAMERA_H264, .bitrate = _stream_bitrate , .gop_size = 15}; } }; diff --git a/system/loggerd/uploader.py b/system/loggerd/uploader.py index 38fc0e9209..bc19572507 100755 --- a/system/loggerd/uploader.py +++ b/system/loggerd/uploader.py @@ -29,7 +29,7 @@ MAX_UPLOAD_SIZES = { "qcam": 5*1e6, } -allow_sleep = bool(os.getenv("UPLOADER_SLEEP", "1")) +allow_sleep = bool(int(os.getenv("UPLOADER_SLEEP", "1"))) force_wifi = os.getenv("FORCEWIFI") is not None fake_upload = os.getenv("FAKEUPLOAD") is not None diff --git a/system/manager/build.py b/system/manager/build.py index b6153ee8a4..c88befd454 100755 --- a/system/manager/build.py +++ b/system/manager/build.py @@ -14,7 +14,7 @@ from openpilot.system.version import get_build_metadata MAX_CACHE_SIZE = 4e9 if "CI" in os.environ else 2e9 CACHE_DIR = Path("/data/scons_cache" if AGNOS else "/tmp/scons_cache") -TOTAL_SCONS_NODES = 3275 +TOTAL_SCONS_NODES = 2280 MAX_BUILD_PROGRESS = 100 def build(spinner: Spinner, dirty: bool = False, minimal: bool = False) -> None: diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 22f159e891..0c35a3d3c9 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -80,8 +80,8 @@ procs = [ PythonProcess("dmonitoringmodeld", "selfdrive.modeld.dmonitoringmodeld", driverview, enabled=(WEBCAM or not PC)), PythonProcess("sensord", "system.sensord.sensord", only_onroad, enabled=not PC), - NativeProcess("ui", "selfdrive/ui", ["./ui"], always_run, watchdog_max_dt=(5 if not PC else None)), - PythonProcess("raylib_ui", "selfdrive.ui.ui", always_run, enabled=False, watchdog_max_dt=(5 if not PC else None)), + # NativeProcess("ui", "selfdrive/ui", ["./ui"], always_run, enabled=False, watchdog_max_dt=(5 if not PC else None)), + PythonProcess("ui", "selfdrive.ui.ui", always_run), PythonProcess("soundd", "selfdrive.ui.soundd", only_onroad), PythonProcess("locationd", "selfdrive.locationd.locationd", only_onroad), NativeProcess("_pandad", "selfdrive/pandad", ["./pandad"], always_run, enabled=False), diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 481fd98045..ff7163793d 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -2,6 +2,8 @@ import atexit import cffi import os import time +import signal +import sys import pyray as rl import threading from collections.abc import Callable @@ -11,10 +13,10 @@ from enum import StrEnum from typing import NamedTuple from importlib.resources import as_file, files from openpilot.common.swaglog import cloudlog -from openpilot.system.hardware import HARDWARE, PC +from openpilot.system.hardware import HARDWARE, PC, TICI from openpilot.common.realtime import Ratekeeper -_DEFAULT_FPS = int(os.getenv("FPS", "60")) +_DEFAULT_FPS = int(os.getenv("FPS", 20 if TICI else 60)) FPS_LOG_INTERVAL = 5 # Seconds between logging FPS drops FPS_DROP_THRESHOLD = 0.9 # FPS drop threshold for triggering a warning FPS_CRITICAL_THRESHOLD = 0.5 # Critical threshold for triggering strict actions @@ -30,6 +32,10 @@ SCALE = float(os.getenv("SCALE", "1.0")) DEFAULT_TEXT_SIZE = 60 DEFAULT_TEXT_COLOR = rl.WHITE +# Qt draws fonts accounting for ascent/descent differently, so compensate to match old styles +# The real scales for the fonts below range from 1.212 to 1.266 +FONT_SCALE = 1.242 + ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets") FONT_DIR = ASSETS_DIR.joinpath("fonts") @@ -72,7 +78,7 @@ class MouseState: self._events: deque[MouseEvent] = deque(maxlen=MOUSE_THREAD_RATE) # bound event list self._prev_mouse_event: list[MouseEvent | None] = [None] * MAX_TOUCH_SLOTS - self._rk = Ratekeeper(MOUSE_THREAD_RATE) + self._rk = Ratekeeper(MOUSE_THREAD_RATE, print_delay_threshold=None) self._lock = threading.Lock() self._exit_event = threading.Event() self._thread = None @@ -108,8 +114,8 @@ class MouseState: ev = MouseEvent( MousePos(x, y), slot, - rl.is_mouse_button_pressed(slot), - rl.is_mouse_button_released(slot), + rl.is_mouse_button_pressed(slot), # noqa: TID251 + rl.is_mouse_button_released(slot), # noqa: TID251 rl.is_mouse_button_down(slot), time.monotonic(), ) @@ -142,17 +148,25 @@ class GuiApplication: # Debug variables self._mouse_history: deque[MousePos] = deque(maxlen=MOUSE_THREAD_RATE) + @property + def target_fps(self): + return self._target_fps + def request_close(self): self._window_close_requested = True def init_window(self, title: str, fps: int = _DEFAULT_FPS): - atexit.register(self.close) # Automatically call close() on exit + def _close(sig, frame): + self.close() + sys.exit(0) + signal.signal(signal.SIGINT, _close) + atexit.register(self.close) HARDWARE.set_display_power(True) HARDWARE.set_screen_brightness(65) self._set_log_callback() - rl.set_trace_log_level(rl.TraceLogLevel.LOG_ALL) + rl.set_trace_log_level(rl.TraceLogLevel.LOG_WARNING) flags = rl.ConfigFlags.FLAG_MSAA_4X_HINT if ENABLE_VSYNC: @@ -164,59 +178,64 @@ class GuiApplication: rl.set_mouse_scale(1 / self._scale, 1 / self._scale) self._render_texture = rl.load_render_texture(self._width, self._height) rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + rl.set_target_fps(fps) - self.set_target_fps(fps) + self._target_fps = fps self._set_styles() self._load_fonts() + self._patch_text_functions() if not PC: self._mouse.start() - @property - def target_fps(self): - return self._target_fps - - def set_target_fps(self, fps: int): - self._target_fps = fps - rl.set_target_fps(fps) - def set_modal_overlay(self, overlay, callback: Callable | None = None): + if self._modal_overlay.overlay is not None: + if self._modal_overlay.callback is not None: + self._modal_overlay.callback(-1) + self._modal_overlay = ModalOverlay(overlay=overlay, callback=callback) - def texture(self, asset_path: str, width: int, height: int, alpha_premultiply=False, keep_aspect_ratio=True): + def texture(self, asset_path: str, width: int | None = None, height: int | None = None, + alpha_premultiply=False, keep_aspect_ratio=True): cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}" if cache_key in self._textures: return self._textures[cache_key] with as_file(ASSETS_DIR.joinpath(asset_path)) as fspath: - texture_obj = self._load_texture_from_image(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio) + image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio) + texture_obj = self._load_texture_from_image(image_obj) self._textures[cache_key] = texture_obj return texture_obj - def _load_texture_from_image(self, image_path: str, width: int, height: int, alpha_premultiply=False, keep_aspect_ratio=True): - """Load and resize a texture, storing it for later automatic unloading.""" + def _load_image_from_path(self, image_path: str, width: int | None = None, height: int | None = None, + alpha_premultiply: bool = False, keep_aspect_ratio: bool = True) -> rl.Image: + """Load and resize an image, storing it for later automatic unloading.""" image = rl.load_image(image_path) if alpha_premultiply: rl.image_alpha_premultiply(image) - # Resize with aspect ratio preservation if requested - if keep_aspect_ratio: - orig_width = image.width - orig_height = image.height + if width is not None and height is not None: + # Resize with aspect ratio preservation if requested + if keep_aspect_ratio: + orig_width = image.width + orig_height = image.height - scale_width = width / orig_width - scale_height = height / orig_height + scale_width = width / orig_width + scale_height = height / orig_height - # Calculate new dimensions - scale = min(scale_width, scale_height) - new_width = int(orig_width * scale) - new_height = int(orig_height * scale) + # Calculate new dimensions + scale = min(scale_width, scale_height) + new_width = int(orig_width * scale) + new_height = int(orig_height * scale) - rl.image_resize(image, new_width, new_height) - else: - rl.image_resize(image, width, height) + rl.image_resize(image, new_width, new_height) + else: + rl.image_resize(image, width, height) + return image + def _load_texture_from_image(self, image: rl.Image) -> rl.Texture: + """Send image to GPU and unload original image.""" texture = rl.load_texture_from_image(image) # Set texture filtering to smooth the result rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) @@ -334,7 +353,7 @@ class GuiApplication: for layout in KEYBOARD_LAYOUTS.values(): all_chars.update(key for row in layout for key in row) all_chars = "".join(all_chars) - all_chars += "–✓×°" + all_chars += "–✓×°§•" codepoint_count = rl.ffi.new("int *", 1) codepoints = rl.load_codepoints(all_chars, codepoint_count) @@ -355,6 +374,16 @@ class GuiApplication: rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(DEFAULT_TEXT_COLOR)) rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(rl.Color(50, 50, 50, 255))) + def _patch_text_functions(self): + # Wrap pyray text APIs to apply a global text size scale so our px sizes match Qt + if not hasattr(rl, "_orig_draw_text_ex"): + rl._orig_draw_text_ex = rl.draw_text_ex + + def _draw_text_ex_scaled(font, text, position, font_size, spacing, tint): + return rl._orig_draw_text_ex(font, text, position, font_size * FONT_SCALE, spacing, tint) + + rl.draw_text_ex = _draw_text_ex_scaled + def _set_log_callback(self): ffi_libc = cffi.FFI() ffi_libc.cdef(""" diff --git a/system/ui/lib/emoji.py b/system/ui/lib/emoji.py index 28139158a1..54f742952e 100644 --- a/system/ui/lib/emoji.py +++ b/system/ui/lib/emoji.py @@ -4,6 +4,8 @@ import re from PIL import Image, ImageDraw, ImageFont import pyray as rl +from openpilot.system.ui.lib.application import FONT_DIR + _cache: dict[str, rl.Texture] = {} EMOJI_REGEX = re.compile( @@ -26,7 +28,7 @@ EMOJI_REGEX = re.compile( \u231a \ufe0f \u3030 -]+""", +]+""".replace("\n", ""), flags=re.UNICODE ) @@ -37,11 +39,11 @@ def emoji_tex(emoji): if emoji not in _cache: img = Image.new("RGBA", (128, 128), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) - font = ImageFont.truetype("NotoColorEmoji", 109) + font = ImageFont.truetype(FONT_DIR.joinpath("NotoColorEmoji.ttf"), 109) draw.text((0, 0), emoji, font=font, embedded_color=True) - buffer = io.BytesIO() - img.save(buffer, format="PNG") - l = buffer.tell() - buffer.seek(0) - _cache[emoji] = rl.load_texture_from_image(rl.load_image_from_memory(".png", buffer.getvalue(), l)) + with io.BytesIO() as buffer: + img.save(buffer, format="PNG") + l = buffer.tell() + buffer.seek(0) + _cache[emoji] = rl.load_texture_from_image(rl.load_image_from_memory(".png", buffer.getvalue(), l)) return _cache[emoji] diff --git a/system/ui/lib/multilang.py b/system/ui/lib/multilang.py new file mode 100644 index 0000000000..c020b33d5e --- /dev/null +++ b/system/ui/lib/multilang.py @@ -0,0 +1,10 @@ +import os +import gettext +from openpilot.common.basedir import BASEDIR + +UI_DIR = os.path.join(BASEDIR, "selfdrive", "ui") +TRANSLATIONS_DIR = os.path.join(UI_DIR, "translations") +LANGUAGES_FILE = os.path.join(TRANSLATIONS_DIR, "languages.json") + +tr = gettext.gettext +trn = gettext.ngettext diff --git a/system/ui/lib/scroll_panel.py b/system/ui/lib/scroll_panel.py index e2296fd5ed..a5b9fc70d3 100644 --- a/system/ui/lib/scroll_panel.py +++ b/system/ui/lib/scroll_panel.py @@ -1,189 +1,134 @@ -import time +import math import pyray as rl -from collections import deque from enum import IntEnum -from openpilot.system.ui.lib.application import gui_app, MouseEvent, MousePos +from openpilot.system.ui.lib.application import gui_app, MouseEvent +from openpilot.common.filter_simple import FirstOrderFilter # Scroll constants for smooth scrolling behavior -MOUSE_WHEEL_SCROLL_SPEED = 30 -INERTIA_FRICTION = 0.92 # The rate at which the inertia slows down -MIN_VELOCITY = 0.5 # Minimum velocity before stopping the inertia -DRAG_THRESHOLD = 12 # Pixels of movement to consider it a drag, not a click -BOUNCE_FACTOR = 0.2 # Elastic bounce when scrolling past boundaries -BOUNCE_RETURN_SPEED = 0.15 # How quickly it returns from the bounce -MAX_BOUNCE_DISTANCE = 150 # Maximum distance for bounce effect -FLICK_MULTIPLIER = 1.8 # Multiplier for flick gestures -VELOCITY_HISTORY_SIZE = 5 # Track velocity over multiple frames for smoother motion +MOUSE_WHEEL_SCROLL_SPEED = 50 +BOUNCE_RETURN_RATE = 5 # ~0.92 at 60fps +MIN_VELOCITY = 2 # px/s, changes from auto scroll to steady state +MIN_VELOCITY_FOR_CLICKING = 2 * 60 # px/s, accepts clicks while auto scrolling below this velocity +DRAG_THRESHOLD = 12 # pixels of movement to consider it a drag, not a click + +DEBUG = False class ScrollState(IntEnum): - IDLE = 0 - DRAGGING_CONTENT = 1 - DRAGGING_SCROLLBAR = 2 - BOUNCING = 3 + IDLE = 0 # Not dragging, content may be bouncing or scrolling with inertia + DRAGGING_CONTENT = 1 # User is actively dragging the content class GuiScrollPanel: - def __init__(self, show_vertical_scroll_bar: bool = False): + def __init__(self): self._scroll_state: ScrollState = ScrollState.IDLE self._last_mouse_y: float = 0.0 self._start_mouse_y: float = 0.0 # Track the initial mouse position for drag detection - self._offset = rl.Vector2(0, 0) - self._view = rl.Rectangle(0, 0, 0, 0) - self._show_vertical_scroll_bar: bool = show_vertical_scroll_bar - self._velocity_y = 0.0 # Velocity for inertia - self._is_dragging: bool = False - self._bounce_offset: float = 0.0 - self._velocity_history: deque[float] = deque(maxlen=VELOCITY_HISTORY_SIZE) + self._offset_filter_y = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + self._velocity_filter_y = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps) self._last_drag_time: float = 0.0 - self._content_rect: rl.Rectangle | None = None - self._bounds_rect: rl.Rectangle | None = None - def handle_scroll(self, bounds: rl.Rectangle, content: rl.Rectangle) -> rl.Vector2: - # TODO: HACK: this class is driven by mouse events, so we need to ensure we have at least one event to process - for mouse_event in gui_app.mouse_events or [MouseEvent(MousePos(0, 0), 0, False, False, False, time.monotonic())]: + def update(self, bounds: rl.Rectangle, content: rl.Rectangle) -> float: + for mouse_event in gui_app.mouse_events: if mouse_event.slot == 0: self._handle_mouse_event(mouse_event, bounds, content) - return self._offset - def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, content: rl.Rectangle): - # Store rectangles for reference - self._content_rect = content - self._bounds_rect = bounds - - max_scroll_y = max(content.height - bounds.height, 0) - - # Start dragging on mouse press - if rl.check_collision_point_rec(mouse_event.pos, bounds) and mouse_event.left_pressed: - if self._scroll_state == ScrollState.IDLE or self._scroll_state == ScrollState.BOUNCING: - self._scroll_state = ScrollState.DRAGGING_CONTENT - if self._show_vertical_scroll_bar: - scrollbar_width = rl.gui_get_style(rl.GuiControl.LISTVIEW, rl.GuiListViewProperty.SCROLLBAR_WIDTH) - scrollbar_x = bounds.x + bounds.width - scrollbar_width - if mouse_event.pos.x >= scrollbar_x: - self._scroll_state = ScrollState.DRAGGING_SCROLLBAR - - # TODO: hacky - # when clicking while moving, go straight into dragging - self._is_dragging = abs(self._velocity_y) > MIN_VELOCITY - self._last_mouse_y = mouse_event.pos.y - self._start_mouse_y = mouse_event.pos.y - self._last_drag_time = mouse_event.t - self._velocity_history.clear() - self._velocity_y = 0.0 - self._bounce_offset = 0.0 - - # Handle active dragging - if self._scroll_state == ScrollState.DRAGGING_CONTENT or self._scroll_state == ScrollState.DRAGGING_SCROLLBAR: - if mouse_event.left_down: - delta_y = mouse_event.pos.y - self._last_mouse_y + self._update_state(bounds, content) - # Track velocity for inertia - time_since_last_drag = mouse_event.t - self._last_drag_time - if time_since_last_drag > 0: - # TODO: HACK: /2 since we usually get two touch events per frame - drag_velocity = delta_y / time_since_last_drag / 60.0 / 2 # TODO: shouldn't be hardcoded - self._velocity_history.append(drag_velocity) - - self._last_drag_time = mouse_event.t - - # Detect actual dragging - total_drag = abs(mouse_event.pos.y - self._start_mouse_y) - if total_drag > DRAG_THRESHOLD: - self._is_dragging = True - - if self._scroll_state == ScrollState.DRAGGING_CONTENT: - # Add resistance at boundaries - if (self._offset.y > 0 and delta_y > 0) or (self._offset.y < -max_scroll_y and delta_y < 0): - delta_y *= BOUNCE_FACTOR - - self._offset.y += delta_y - elif self._scroll_state == ScrollState.DRAGGING_SCROLLBAR: - scroll_ratio = content.height / bounds.height - self._offset.y -= delta_y * scroll_ratio - - self._last_mouse_y = mouse_event.pos.y - - elif mouse_event.left_released: - # Calculate flick velocity - if self._velocity_history: - total_weight = 0 - weighted_velocity = 0.0 - - for i, v in enumerate(self._velocity_history): - weight = i + 1 - weighted_velocity += v * weight - total_weight += weight - - if total_weight > 0: - avg_velocity = weighted_velocity / total_weight - self._velocity_y = avg_velocity * FLICK_MULTIPLIER - - # Check bounds - if self._offset.y > 0 or self._offset.y < -max_scroll_y: - self._scroll_state = ScrollState.BOUNCING - else: - self._scroll_state = ScrollState.IDLE + return float(self._offset_filter_y.x) + + def _update_state(self, bounds: rl.Rectangle, content: rl.Rectangle): + if DEBUG: + rl.draw_rectangle_lines(0, 0, abs(int(self._velocity_filter_y.x)), 10, rl.RED) # Handle mouse wheel - wheel_move = rl.get_mouse_wheel_move() - if wheel_move != 0: - self._velocity_y = 0.0 + self._offset_filter_y.x += rl.get_mouse_wheel_move() * MOUSE_WHEEL_SCROLL_SPEED - if self._show_vertical_scroll_bar: - self._offset.y += wheel_move * (MOUSE_WHEEL_SCROLL_SPEED - 20) - rl.gui_scroll_panel(bounds, rl.ffi.NULL, content, self._offset, self._view) + max_scroll_distance = max(0, content.height - bounds.height) + if self._scroll_state == ScrollState.IDLE: + above_bounds, below_bounds = self._check_bounds(bounds, content) + + # Decay velocity when idle + if abs(self._velocity_filter_y.x) > MIN_VELOCITY: + # Faster decay if bouncing back from out of bounds + friction = math.exp(-BOUNCE_RETURN_RATE * 1 / gui_app.target_fps) + self._velocity_filter_y.x *= friction ** 2 if (above_bounds or below_bounds) else friction else: - self._offset.y += wheel_move * MOUSE_WHEEL_SCROLL_SPEED + self._velocity_filter_y.x = 0.0 - if self._offset.y > 0 or self._offset.y < -max_scroll_y: - self._scroll_state = ScrollState.BOUNCING + if above_bounds or below_bounds: + if above_bounds: + self._offset_filter_y.update(0) + else: + self._offset_filter_y.update(-max_scroll_distance) - # Apply inertia (continue scrolling after mouse release) - if self._scroll_state == ScrollState.IDLE: - if abs(self._velocity_y) > MIN_VELOCITY: - self._offset.y += self._velocity_y - self._velocity_y *= INERTIA_FRICTION + self._offset_filter_y.x += self._velocity_filter_y.x / gui_app.target_fps - if self._offset.y > 0 or self._offset.y < -max_scroll_y: - self._scroll_state = ScrollState.BOUNCING - else: - self._velocity_y = 0.0 - - # Handle bouncing effect - elif self._scroll_state == ScrollState.BOUNCING: - target_y = 0.0 - if self._offset.y < -max_scroll_y: - target_y = -max_scroll_y - - distance = target_y - self._offset.y - bounce_step = distance * BOUNCE_RETURN_SPEED - self._offset.y += bounce_step - self._velocity_y *= INERTIA_FRICTION * 0.8 - - if abs(distance) < 0.5 and abs(self._velocity_y) < MIN_VELOCITY: - self._offset.y = target_y - self._velocity_y = 0.0 + elif self._scroll_state == ScrollState.DRAGGING_CONTENT: + # Mouse not moving, decay velocity + if not len(gui_app.mouse_events): + self._velocity_filter_y.update(0.0) + + # Settle to exact bounds + if abs(self._offset_filter_y.x) < 1e-2: + self._offset_filter_y.x = 0.0 + elif abs(self._offset_filter_y.x + max_scroll_distance) < 1e-2: + self._offset_filter_y.x = -max_scroll_distance + + def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, content: rl.Rectangle): + if self._scroll_state == ScrollState.IDLE: + if rl.check_collision_point_rec(mouse_event.pos, bounds): + if mouse_event.left_pressed: + self._start_mouse_y = mouse_event.pos.y + # Interrupt scrolling with new drag + # TODO: stop scrolling with any tap, need to fix is_touch_valid + if abs(self._velocity_filter_y.x) > MIN_VELOCITY_FOR_CLICKING: + self._scroll_state = ScrollState.DRAGGING_CONTENT + # Start velocity at initial measurement for more immediate response + self._velocity_filter_y.initialized = False + + if mouse_event.left_down: + if abs(mouse_event.pos.y - self._start_mouse_y) > DRAG_THRESHOLD: + self._scroll_state = ScrollState.DRAGGING_CONTENT + # Start velocity at initial measurement for more immediate response + self._velocity_filter_y.initialized = False + + elif self._scroll_state == ScrollState.DRAGGING_CONTENT: + if mouse_event.left_released: self._scroll_state = ScrollState.IDLE + else: + delta_y = mouse_event.pos.y - self._last_mouse_y + above_bounds, below_bounds = self._check_bounds(bounds, content) + # Rubber banding effect when out of bands + if above_bounds or below_bounds: + delta_y /= 3 - # Limit bounce distance - if self._scroll_state != ScrollState.DRAGGING_CONTENT: - if self._offset.y > MAX_BOUNCE_DISTANCE: - self._offset.y = MAX_BOUNCE_DISTANCE - elif self._offset.y < -(max_scroll_y + MAX_BOUNCE_DISTANCE): - self._offset.y = -(max_scroll_y + MAX_BOUNCE_DISTANCE) + self._offset_filter_y.x += delta_y - def is_touch_valid(self): - return not self._is_dragging + # Track velocity for inertia + dt = mouse_event.t - self._last_drag_time + if dt > 0: + drag_velocity = delta_y / dt + self._velocity_filter_y.update(drag_velocity) + + # TODO: just store last mouse event! + self._last_drag_time = mouse_event.t + self._last_mouse_y = mouse_event.pos.y - def get_normalized_scroll_position(self) -> float: - """Returns the current scroll position as a value from 0.0 to 1.0""" - if not self._content_rect or not self._bounds_rect: - return 0.0 + def _check_bounds(self, bounds: rl.Rectangle, content: rl.Rectangle) -> tuple[bool, bool]: + max_scroll_distance = max(0, content.height - bounds.height) + above_bounds = self._offset_filter_y.x > 0 + below_bounds = self._offset_filter_y.x < -max_scroll_distance + return above_bounds, below_bounds + + def is_touch_valid(self): + return self._scroll_state == ScrollState.IDLE and abs(self._velocity_filter_y.x) < MIN_VELOCITY_FOR_CLICKING - max_scroll_y = max(self._content_rect.height - self._bounds_rect.height, 0) - if max_scroll_y == 0: - return 0.0 + def set_offset(self, position: float) -> None: + self._offset_filter_y.x = position + self._velocity_filter_y.x = 0.0 + self._scroll_state = ScrollState.IDLE - normalized = -self._offset.y / max_scroll_y - return max(0.0, min(1.0, normalized)) + @property + def offset(self) -> float: + return float(self._offset_filter_y.x) diff --git a/system/ui/lib/shader_polygon.py b/system/ui/lib/shader_polygon.py index d03ad5d8c6..28585b08ba 100644 --- a/system/ui/lib/shader_polygon.py +++ b/system/ui/lib/shader_polygon.py @@ -1,9 +1,33 @@ import platform import pyray as rl import numpy as np -from typing import Any +from dataclasses import dataclass +from typing import Any, Optional, cast +from openpilot.system.ui.lib.application import gui_app + +MAX_GRADIENT_COLORS = 20 # includes stops as well + + +@dataclass +class Gradient: + start: tuple[float, float] + end: tuple[float, float] + colors: list[rl.Color] + stops: list[float] + + def __post_init__(self): + if len(self.colors) > MAX_GRADIENT_COLORS: + self.colors = self.colors[:MAX_GRADIENT_COLORS] + print(f"Warning: Gradient colors truncated to {MAX_GRADIENT_COLORS} entries") + + if len(self.stops) > MAX_GRADIENT_COLORS: + self.stops = self.stops[:MAX_GRADIENT_COLORS] + print(f"Warning: Gradient stops truncated to {MAX_GRADIENT_COLORS} entries") + + if not len(self.stops): + color_count = min(len(self.colors), MAX_GRADIENT_COLORS) + self.stops = [i / max(1, color_count - 1) for i in range(color_count)] -MAX_GRADIENT_COLORS = 15 VERSION = """ #version 300 es @@ -18,100 +42,43 @@ FRAGMENT_SHADER = VERSION + """ in vec2 fragTexCoord; out vec4 finalColor; -uniform vec2 points[100]; -uniform int pointCount; uniform vec4 fillColor; -uniform vec2 resolution; +// Gradient line defined in *screen pixels* uniform int useGradient; -uniform vec2 gradientStart; -uniform vec2 gradientEnd; -uniform vec4 gradientColors[15]; -uniform float gradientStops[15]; +uniform vec2 gradientStart; // e.g. vec2(0, 0) +uniform vec2 gradientEnd; // e.g. vec2(0, screenHeight) +uniform vec4 gradientColors[20]; +uniform float gradientStops[20]; uniform int gradientColorCount; -vec4 getGradientColor(vec2 pos) { - vec2 gradientDir = gradientEnd - gradientStart; - float gradientLength = length(gradientDir); - if (gradientLength < 0.001) return gradientColors[0]; - - vec2 normalizedDir = gradientDir / gradientLength; - float t = clamp(dot(pos - gradientStart, normalizedDir) / gradientLength, 0.0, 1.0); +vec4 getGradientColor(vec2 p) { + // Compute t from screen-space position + vec2 d = gradientStart - gradientEnd; + float len2 = max(dot(d, d), 1e-6); + float t = clamp(dot(p - gradientEnd, d) / len2, 0.0, 1.0); - if (gradientColorCount <= 1) return gradientColors[0]; + // Clamp to range + float t0 = gradientStops[0]; + float tn = gradientStops[gradientColorCount-1]; + if (t <= t0) return gradientColors[0]; + if (t >= tn) return gradientColors[gradientColorCount-1]; - // handle t before first / after last stop - if (t <= gradientStops[0]) return gradientColors[0]; - if (t >= gradientStops[gradientColorCount-1]) return gradientColors[gradientColorCount-1]; for (int i = 0; i < gradientColorCount - 1; i++) { - if (t >= gradientStops[i] && t <= gradientStops[i+1]) { - float segmentT = (t - gradientStops[i]) / (gradientStops[i+1] - gradientStops[i]); - return mix(gradientColors[i], gradientColors[i+1], segmentT); + float a = gradientStops[i]; + float b = gradientStops[i+1]; + if (t >= a && t <= b) { + float k = (t - a) / max(b - a, 1e-6); + return mix(gradientColors[i], gradientColors[i+1], k); } } return gradientColors[gradientColorCount-1]; } -bool isPointInsidePolygon(vec2 p) { - if (pointCount < 3) return false; - int crossings = 0; - for (int i = 0, j = pointCount - 1; i < pointCount; j = i++) { - vec2 pi = points[i]; - vec2 pj = points[j]; - if (distance(pi, pj) < 0.001) continue; - if (((pi.y > p.y) != (pj.y > p.y)) && - (p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y + 0.001) + pi.x)) { - crossings++; - } - } - return (crossings & 1) == 1; -} - -float distanceToEdge(vec2 p) { - float minDist = 1000.0; - - for (int i = 0, j = pointCount - 1; i < pointCount; j = i++) { - vec2 edge0 = points[j]; - vec2 edge1 = points[i]; - - if (distance(edge0, edge1) < 0.0001) continue; - - vec2 v1 = p - edge0; - vec2 v2 = edge1 - edge0; - float l2 = dot(v2, v2); - - if (l2 < 0.0001) { - float dist = length(v1); - minDist = min(minDist, dist); - continue; - } - - float t = clamp(dot(v1, v2) / l2, 0.0, 1.0); - vec2 projection = edge0 + t * v2; - float dist = length(p - projection); - minDist = min(minDist, dist); - } - - return minDist; -} - void main() { - vec2 pixel = fragTexCoord * resolution; - - bool inside = isPointInsidePolygon(pixel); - float sd = (inside ? 1.0 : -1.0) * distanceToEdge(pixel); - - // ~1 pixel wide anti-aliasing - float w = max(0.75, fwidth(sd)); - - float alpha = smoothstep(-w, w, sd); - if (alpha > 0.0){ - vec4 color = useGradient == 1 ? getGradientColor(pixel) : fillColor; - finalColor = vec4(color.rgb, color.a * alpha); - } else { - discard; - } + // TODO: do proper antialiasing + finalColor = useGradient == 1 ? getGradientColor(gl_FragCoord.xy) : fillColor; } """ @@ -149,14 +116,10 @@ class ShaderState: self.initialized = False self.shader = None - self.white_texture = None # Shader uniform locations self.locations = { - 'pointCount': None, 'fillColor': None, - 'resolution': None, - 'points': None, 'useGradient': None, 'gradientStart': None, 'gradientEnd': None, @@ -167,12 +130,8 @@ class ShaderState: } # Pre-allocated FFI objects - self.point_count_ptr = rl.ffi.new("int[]", [0]) - self.resolution_ptr = rl.ffi.new("float[]", [0.0, 0.0]) self.fill_color_ptr = rl.ffi.new("float[]", [0.0, 0.0, 0.0, 0.0]) self.use_gradient_ptr = rl.ffi.new("int[]", [0]) - self.gradient_start_ptr = rl.ffi.new("float[]", [0.0, 0.0]) - self.gradient_end_ptr = rl.ffi.new("float[]", [0.0, 0.0]) self.color_count_ptr = rl.ffi.new("int[]", [0]) self.gradient_colors_ptr = rl.ffi.new("float[]", MAX_GRADIENT_COLORS * 4) self.gradient_stops_ptr = rl.ffi.new("float[]", MAX_GRADIENT_COLORS) @@ -183,30 +142,19 @@ class ShaderState: self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAGMENT_SHADER) - # Create and cache white texture - white_img = rl.gen_image_color(2, 2, rl.WHITE) - self.white_texture = rl.load_texture_from_image(white_img) - rl.set_texture_filter(self.white_texture, rl.TEXTURE_FILTER_BILINEAR) - rl.unload_image(white_img) - # Cache all uniform locations for uniform in self.locations.keys(): self.locations[uniform] = rl.get_shader_location(self.shader, uniform) - # Setup default MVP matrix - mvp_ptr = rl.ffi.new("float[16]", [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]) - rl.set_shader_value_matrix(self.shader, self.locations['mvp'], rl.Matrix(*mvp_ptr)) + # Orthographic MVP (origin top-left) + proj = rl.matrix_ortho(0, gui_app.width, gui_app.height, 0, -1, 1) + rl.set_shader_value_matrix(self.shader, self.locations['mvp'], proj) self.initialized = True def cleanup(self): if not self.initialized: return - - if self.white_texture: - rl.unload_texture(self.white_texture) - self.white_texture = None - if self.shader: rl.unload_shader(self.shader) self.shader = None @@ -214,103 +162,82 @@ class ShaderState: self.initialized = False -def _configure_shader_color(state, color, gradient, clipped_rect, original_rect): - use_gradient = 1 if gradient else 0 +def _configure_shader_color(state: ShaderState, color: Optional[rl.Color], # noqa: UP045 + gradient: Gradient | None, origin_rect: rl.Rectangle): + assert (color is not None) != (gradient is not None), "Either color or gradient must be provided" + + use_gradient = 1 if (gradient is not None and len(gradient.colors) >= 1) else 0 state.use_gradient_ptr[0] = use_gradient rl.set_shader_value(state.shader, state.locations['useGradient'], state.use_gradient_ptr, UNIFORM_INT) if use_gradient: - start = np.array(gradient['start']) * np.array([original_rect.width, original_rect.height]) + np.array([original_rect.x, original_rect.y]) - end = np.array(gradient['end']) * np.array([original_rect.width, original_rect.height]) + np.array([original_rect.x, original_rect.y]) - start = start - np.array([clipped_rect.x, clipped_rect.y]) - end = end - np.array([clipped_rect.x, clipped_rect.y]) - state.gradient_start_ptr[0:2] = start.astype(np.float32) - state.gradient_end_ptr[0:2] = end.astype(np.float32) - rl.set_shader_value(state.shader, state.locations['gradientStart'], state.gradient_start_ptr, UNIFORM_VEC2) - rl.set_shader_value(state.shader, state.locations['gradientEnd'], state.gradient_end_ptr, UNIFORM_VEC2) - - colors = gradient['colors'] - color_count = min(len(colors), MAX_GRADIENT_COLORS) - state.color_count_ptr[0] = color_count - for i, c in enumerate(colors[:color_count]): - base_idx = i * 4 - state.gradient_colors_ptr[base_idx:base_idx+4] = [c.r / 255.0, c.g / 255.0, c.b / 255.0, c.a / 255.0] - rl.set_shader_value_v(state.shader, state.locations['gradientColors'], state.gradient_colors_ptr, UNIFORM_VEC4, color_count) - - stops = gradient.get('stops', [i / max(1, color_count - 1) for i in range(color_count)]) - stops = np.clip(stops[:color_count], 0.0, 1.0) - state.gradient_stops_ptr[0:color_count] = stops - rl.set_shader_value_v(state.shader, state.locations['gradientStops'], state.gradient_stops_ptr, UNIFORM_FLOAT, color_count) + gradient = cast(Gradient, gradient) + state.color_count_ptr[0] = len(gradient.colors) + for i in range(len(gradient.colors)): + c = gradient.colors[i] + base = i * 4 + state.gradient_colors_ptr[base:base + 4] = [c.r / 255.0, c.g / 255.0, c.b / 255.0, c.a / 255.0] + rl.set_shader_value_v(state.shader, state.locations['gradientColors'], state.gradient_colors_ptr, UNIFORM_VEC4, len(gradient.colors)) + + for i in range(len(gradient.stops)): + s = float(gradient.stops[i]) + state.gradient_stops_ptr[i] = 0.0 if s < 0.0 else 1.0 if s > 1.0 else s + rl.set_shader_value_v(state.shader, state.locations['gradientStops'], state.gradient_stops_ptr, UNIFORM_FLOAT, len(gradient.stops)) rl.set_shader_value(state.shader, state.locations['gradientColorCount'], state.color_count_ptr, UNIFORM_INT) + + # Map normalized start/end to screen pixels + start_vec = rl.Vector2(origin_rect.x + gradient.start[0] * origin_rect.width, origin_rect.y + gradient.start[1] * origin_rect.height) + end_vec = rl.Vector2(origin_rect.x + gradient.end[0] * origin_rect.width, origin_rect.y + gradient.end[1] * origin_rect.height) + rl.set_shader_value(state.shader, state.locations['gradientStart'], start_vec, UNIFORM_VEC2) + rl.set_shader_value(state.shader, state.locations['gradientEnd'], end_vec, UNIFORM_VEC2) else: color = color or rl.WHITE state.fill_color_ptr[0:4] = [color.r / 255.0, color.g / 255.0, color.b / 255.0, color.a / 255.0] rl.set_shader_value(state.shader, state.locations['fillColor'], state.fill_color_ptr, UNIFORM_VEC4) -def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray, color=None, gradient=None): +def triangulate(pts: np.ndarray) -> list[tuple[float, float]]: + """Only supports simple polygons with two chains (ribbon).""" + + # TODO: consider deduping close screenspace points + # interleave points to produce a triangle strip + assert len(pts) % 2 == 0, "Interleaving expects even number of points" + + tri_strip = [] + for i in range(len(pts) // 2): + tri_strip.append(pts[i]) + tri_strip.append(pts[-i - 1]) + + return cast(list, np.array(tri_strip).tolist()) + + +def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray, + color: Optional[rl.Color] = None, gradient: Gradient | None = None): # noqa: UP045 + """ - Draw a complex polygon using shader-based even-odd fill rule - - Args: - rect: Rectangle defining the drawing area - points: numpy array of (x,y) points defining the polygon - color: Solid fill color (rl.Color) - gradient: Dict with gradient parameters: - { - 'start': (x1, y1), # Start point (normalized 0-1) - 'end': (x2, y2), # End point (normalized 0-1) - 'colors': [rl.Color], # List of colors at stops - 'stops': [float] # List of positions (0-1) - } + Draw a ribbon polygon (two chains) with a triangle strip and gradient. + - Input must be [L0..Lk-1, Rk-1..R0], even count, no crossings/holes. """ if len(points) < 3: return + # Initialize shader on-demand state = ShaderState.get_instance() - if not state.initialized: - state.initialize() - - # Find bounding box - min_xy = np.min(points, axis=0) - max_xy = np.max(points, axis=0) - clip_x = max(origin_rect.x, min_xy[0]) - clip_y = max(origin_rect.y, min_xy[1]) - clip_right = min(origin_rect.x + origin_rect.width, max_xy[0]) - clip_bottom = min(origin_rect.y + origin_rect.height, max_xy[1]) - - # Check if polygon is completely off-screen - if clip_x >= clip_right or clip_y >= clip_bottom: - return - - clipped_rect = rl.Rectangle(clip_x, clip_y, clip_right - clip_x, clip_bottom - clip_y) - - # Transform points relative to the CLIPPED area - transformed_points = points - np.array([clip_x, clip_y]) - - # Set shader values - state.point_count_ptr[0] = len(transformed_points) - rl.set_shader_value(state.shader, state.locations['pointCount'], state.point_count_ptr, UNIFORM_INT) + state.initialize() - state.resolution_ptr[0:2] = [clipped_rect.width, clipped_rect.height] - rl.set_shader_value(state.shader, state.locations['resolution'], state.resolution_ptr, UNIFORM_VEC2) + # Ensure (N,2) float32 contiguous array + pts = np.ascontiguousarray(points, dtype=np.float32) + assert pts.ndim == 2 and pts.shape[1] == 2, "points must be (N,2)" - flat_points = np.ascontiguousarray(transformed_points.flatten().astype(np.float32)) - points_ptr = rl.ffi.cast("float *", flat_points.ctypes.data) - rl.set_shader_value_v(state.shader, state.locations['points'], points_ptr, UNIFORM_VEC2, len(transformed_points)) + # Configure gradient shader + _configure_shader_color(state, color, gradient, origin_rect) - _configure_shader_color(state, color, gradient, clipped_rect, origin_rect) + # Triangulate via interleaving + tri_strip = triangulate(pts) - # Render + # Draw strip, color here doesn't matter rl.begin_shader_mode(state.shader) - rl.draw_texture_pro( - state.white_texture, - rl.Rectangle(0, 0, 2, 2), - clipped_rect, - rl.Vector2(0, 0), - 0.0, - rl.WHITE, - ) + rl.draw_triangle_strip(tri_strip, len(tri_strip), rl.WHITE) rl.end_shader_mode() diff --git a/system/ui/lib/text_measure.py b/system/ui/lib/text_measure.py index c172f94251..b91090e051 100644 --- a/system/ui/lib/text_measure.py +++ b/system/ui/lib/text_measure.py @@ -1,4 +1,6 @@ import pyray as rl +from openpilot.system.ui.lib.application import FONT_SCALE +from openpilot.system.ui.lib.emoji import find_emoji _cache: dict[int, rl.Vector2] = {} @@ -9,6 +11,23 @@ def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: int = if key in _cache: return _cache[key] - result = rl.measure_text_ex(font, text, font_size, spacing) # noqa: TID251 + # Measure normal characters without emojis, then add standard width for each found emoji + emoji = find_emoji(text) + if emoji: + non_emoji_text = "" + last_index = 0 + for start, end, _ in emoji: + non_emoji_text += text[last_index:start] + last_index = end + else: + non_emoji_text = text + + result = rl.measure_text_ex(font, non_emoji_text, font_size * FONT_SCALE, spacing) # noqa: TID251 + if emoji: + result.x += len(emoji) * font_size * FONT_SCALE + # If just emoji assume a single line height + if result.y == 0: + result.y = font_size * FONT_SCALE + _cache[key] = result return result diff --git a/system/ui/lib/wifi_manager.py b/system/ui/lib/wifi_manager.py index e24686566d..5594742cb3 100644 --- a/system/ui/lib/wifi_manager.py +++ b/system/ui/lib/wifi_manager.py @@ -16,7 +16,6 @@ from jeepney.low_level import MessageType from jeepney.wrappers import Properties from openpilot.common.swaglog import cloudlog -from openpilot.common.params import Params from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_802_11_AP_SEC_PAIR_WEP40, NM_802_11_AP_SEC_PAIR_WEP104, NM_802_11_AP_SEC_GROUP_WEP40, NM_802_11_AP_SEC_GROUP_WEP104, NM_802_11_AP_SEC_KEY_MGMT_PSK, @@ -28,6 +27,11 @@ from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_80 NM_DEVICE_STATE_REASON_NEW_ACTIVATION, NM_ACTIVE_CONNECTION_IFACE, NM_IP4_CONFIG_IFACE, NMDeviceState) +try: + from openpilot.common.params import Params +except Exception: + Params = None + TETHERING_IP_ADDRESS = "192.168.43.1" DEFAULT_TETHERING_PASSWORD = "swagswagcomma" SIGNAL_QUEUE_SIZE = 10 @@ -149,9 +153,10 @@ class WifiManager: self._callback_queue: list[Callable] = [] self._tethering_ssid = "weedle" - dongle_id = Params().get("DongleId") - if dongle_id: - self._tethering_ssid += "-" + dongle_id[:4] + if Params is not None: + dongle_id = Params().get("DongleId") + if dongle_id: + self._tethering_ssid += "-" + dongle_id[:4] # Callbacks self._need_auth: list[Callable[[str], None]] = [] @@ -173,7 +178,7 @@ class WifiManager: self._scan_thread.start() self._state_thread.start() - if self._tethering_ssid not in self._get_connections(): + if Params is not None and self._tethering_ssid not in self._get_connections(): self._add_tethering_connection() self._tethering_password = self._get_tethering_password() diff --git a/system/ui/lib/wrap_text.py b/system/ui/lib/wrap_text.py index 35dc5ac401..f6caa3c5f0 100644 --- a/system/ui/lib/wrap_text.py +++ b/system/ui/lib/wrap_text.py @@ -36,7 +36,14 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) - return parts +_cache: dict[int, list[str]] = {} + + def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[str]: + key = hash((font.texture.id, text, font_size, max_width)) + if key in _cache: + return _cache[key] + if not text or max_width <= 0: return [] @@ -100,4 +107,5 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[ # Add all lines from this paragraph all_lines.extend(lines) + _cache[key] = all_lines return all_lines diff --git a/system/ui/reset.py b/system/ui/reset.py index a5cf1731dc..8f6466ce46 100755 --- a/system/ui/reset.py +++ b/system/ui/reset.py @@ -8,7 +8,7 @@ from enum import IntEnum import pyray as rl from openpilot.system.hardware import PC -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE 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, gui_text_box @@ -70,10 +70,10 @@ class Reset(Widget): exit(0) def _render(self, rect: rl.Rectangle): - label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100) + label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100 * FONT_SCALE) gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD) - text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100) + text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100 * FONT_SCALE) gui_text_box(text_rect, self._get_body_text(), 90) button_height = 160 @@ -126,7 +126,9 @@ def main(): if mode == ResetMode.FORMAT: reset.start_reset() - for _ in gui_app.render(): + for showing_dialog in gui_app.render(): + if showing_dialog: + continue if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)): break diff --git a/system/ui/setup.py b/system/ui/setup.py index a985e783be..5dbf597484 100755 --- a/system/ui/setup.py +++ b/system/ui/setup.py @@ -14,20 +14,20 @@ from cereal import log from openpilot.common.run import run_cmd from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio from openpilot.system.ui.widgets.keyboard import Keyboard -from openpilot.system.ui.widgets.label import Label, TextAlignment +from openpilot.system.ui.widgets.label import Label from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManager NetworkType = log.DeviceState.NetworkType MARGIN = 50 -TITLE_FONT_SIZE = 116 +TITLE_FONT_SIZE = 90 TITLE_FONT_WEIGHT = FontWeight.MEDIUM NEXT_BUTTON_WIDTH = 310 -BODY_FONT_SIZE = 96 +BODY_FONT_SIZE = 80 BUTTON_HEIGHT = 160 BUTTON_SPACING = 50 @@ -48,6 +48,7 @@ cd /data/openpilot exec ./launch_openpilot.sh """ + class SetupState(IntEnum): LOW_VOLTAGE = 0 GETTING_STARTED = 1 @@ -78,16 +79,17 @@ class Setup(Widget): self.warning = gui_app.texture("icons/warning.png", 150, 150) self.checkmark = gui_app.texture("icons/circled_check.png", 100, 100) - self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, TextAlignment.LEFT, text_color=rl.Color(255, 89, 79, 255)) + self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + text_color=rl.Color(255, 89, 79, 255), text_padding=20) self._low_voltage_body_label = Label("Power your device in a car with a harness or proceed at your own risk.", BODY_FONT_SIZE, - text_alignment=TextAlignment.LEFT) + text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) self._low_voltage_continue_button = Button("Continue", self._low_voltage_continue_button_callback) self._low_voltage_poweroff_button = Button("Power Off", HARDWARE.shutdown) self._getting_started_button = Button("", self._getting_started_button_callback, button_style=ButtonStyle.PRIMARY, border_radius=0) - self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT) + self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.", - BODY_FONT_SIZE, text_alignment=TextAlignment.LEFT) + BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80) self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80) @@ -95,36 +97,38 @@ class Setup(Widget): button_style=ButtonStyle.PRIMARY) self._software_selection_continue_button.set_enabled(False) self._software_selection_back_button = Button("Back", self._software_selection_back_button_callback) - self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT) + self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + text_padding=20) self._download_failed_reboot_button = Button("Reboot device", HARDWARE.reboot) self._download_failed_startover_button = Button("Start over", self._download_failed_startover_button_callback, button_style=ButtonStyle.PRIMARY) - self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT) - self._download_failed_url_label = Label("", 64, FontWeight.NORMAL, TextAlignment.LEFT) - self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=TextAlignment.LEFT) + self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + self._download_failed_url_label = Label("", 52, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) self._network_setup_back_button = Button("Back", self._network_setup_back_button_callback) self._network_setup_continue_button = Button("Waiting for internet", self._network_setup_continue_button_callback, button_style=ButtonStyle.PRIMARY) self._network_setup_continue_button.set_enabled(False) - self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT) + self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) self._custom_software_warning_continue_button = Button("Scroll to continue", self._custom_software_warning_continue_button_callback, button_style=ButtonStyle.PRIMARY) self._custom_software_warning_continue_button.set_enabled(False) self._custom_software_warning_back_button = Button("Back", self._custom_software_warning_back_button_callback) - self._custom_software_warning_title_label = Label("WARNING: Custom Software", 100, FontWeight.BOLD, TextAlignment.LEFT, text_color=rl.Color(255,89,79,255), + self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + text_color=rl.Color(255, 89, 79, 255), text_padding=60) self._custom_software_warning_body_label = Label("Use caution when installing third-party software.\n\n" - + "⚠️ It has not been tested by comma.\n\n" - + "⚠️ It may not comply with relevant safety standards.\n\n" - + "⚠️ It may cause damage to your device and/or vehicle.\n\n" - + "If you'd like to proceed, use https://flash.comma.ai " - + "to restore your device to a factory state later.", - 85, text_alignment=TextAlignment.LEFT, text_padding=60) + + "⚠️ It has not been tested by comma.\n\n" + + "⚠️ It may not comply with relevant safety standards.\n\n" + + "⚠️ It may cause damage to your device and/or vehicle.\n\n" + + "If you'd like to proceed, use https://flash.comma.ai " + + "to restore your device to a factory state later.", + 68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=60) self._custom_software_warning_body_scroll_panel = GuiScrollPanel() - self._downloading_body_label = Label("Downloading...", TITLE_FONT_SIZE, FontWeight.MEDIUM) + self._downloading_body_label = Label("Downloading...", TITLE_FONT_SIZE, FontWeight.MEDIUM, text_padding=20) try: with open("/sys/class/hwmon/hwmon1/in1_input") as f: @@ -191,8 +195,8 @@ class Setup(Widget): def render_low_voltage(self, rect: rl.Rectangle): rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE) - self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE)) - self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * 3)) + self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE * FONT_SCALE)) + self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * FONT_SCALE * 3)) button_width = (rect.width - MARGIN * 3) / 2 button_y = rect.height - MARGIN - BUTTON_HEIGHT @@ -200,8 +204,9 @@ class Setup(Widget): self._low_voltage_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT)) def render_getting_started(self, rect: rl.Rectangle): - self._getting_started_title_label.render(rl.Rectangle(rect.x + 165, rect.y + 280, rect.width - 265, TITLE_FONT_SIZE)) - self._getting_started_body_label.render(rl.Rectangle(rect.x + 165, rect.y + 280 + TITLE_FONT_SIZE, rect.width - 500, BODY_FONT_SIZE * 3)) + self._getting_started_title_label.render(rl.Rectangle(rect.x + 165, rect.y + 280, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE)) + self._getting_started_body_label.render(rl.Rectangle(rect.x + 165, rect.y + 280 + TITLE_FONT_SIZE * FONT_SCALE, rect.width - 500, + BODY_FONT_SIZE * FONT_SCALE * 3)) btn_rect = rl.Rectangle(rect.width - NEXT_BUTTON_WIDTH, 0, NEXT_BUTTON_WIDTH, rect.height) self._getting_started_button.render(btn_rect) @@ -233,10 +238,10 @@ class Setup(Widget): self.network_check_thread.join() def render_network_setup(self, rect: rl.Rectangle): - self._network_setup_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE)) + self._network_setup_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE)) - wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE + MARGIN + 25, rect.width - MARGIN * 2, - rect.height - TITLE_FONT_SIZE - 25 - BUTTON_HEIGHT - MARGIN * 3) + wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN + 25, rect.width - MARGIN * 2, + rect.height - TITLE_FONT_SIZE * FONT_SCALE - 25 - BUTTON_HEIGHT - MARGIN * 3) rl.draw_rectangle_rounded(wifi_rect, 0.05, 10, rl.Color(51, 51, 51, 255)) wifi_content_rect = rl.Rectangle(wifi_rect.x + MARGIN, wifi_rect.y, wifi_rect.width - MARGIN * 2, wifi_rect.height) self.wifi_ui.render(wifi_content_rect) @@ -254,21 +259,22 @@ class Setup(Widget): self._network_setup_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT)) def render_software_selection(self, rect: rl.Rectangle): - self._software_selection_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE)) + self._software_selection_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE)) radio_height = 230 radio_spacing = 30 self._software_selection_continue_button.set_enabled(False) - openpilot_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE + MARGIN * 2, rect.width - MARGIN * 2, radio_height) + openpilot_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2, rect.width - MARGIN * 2, radio_height) self._software_selection_openpilot_button.render(openpilot_rect) if self._software_selection_openpilot_button.selected: self._software_selection_continue_button.set_enabled(True) self._software_selection_custom_software_button.selected = False - custom_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE + MARGIN * 2 + radio_height + radio_spacing, rect.width - MARGIN * 2, radio_height) + custom_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2 + radio_height + radio_spacing, rect.width - MARGIN * 2, + radio_height) self._software_selection_custom_software_button.render(custom_rect) if self._software_selection_custom_software_button.selected: @@ -282,12 +288,13 @@ class Setup(Widget): self._software_selection_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT)) def render_downloading(self, rect: rl.Rectangle): - self._downloading_body_label.render(rl.Rectangle(rect.x, rect.y + rect.height / 2 - TITLE_FONT_SIZE / 2, rect.width, TITLE_FONT_SIZE)) + self._downloading_body_label.render(rl.Rectangle(rect.x, rect.y + rect.height / 2 - TITLE_FONT_SIZE * FONT_SCALE / 2, rect.width, + TITLE_FONT_SIZE * FONT_SCALE)) def render_download_failed(self, rect: rl.Rectangle): - self._download_failed_title_label.render(rl.Rectangle(rect.x + 117, rect.y + 185, rect.width - 117, TITLE_FONT_SIZE)) + self._download_failed_title_label.render(rl.Rectangle(rect.x + 117, rect.y + 185, rect.width - 117, TITLE_FONT_SIZE * FONT_SCALE)) self._download_failed_url_label.set_text(self.failed_url) - self._download_failed_url_label.render(rl.Rectangle(rect.x + 117, rect.y + 185 + TITLE_FONT_SIZE + 67, rect.width - 117 - 100, 64)) + self._download_failed_url_label.render(rl.Rectangle(rect.x + 117, rect.y + 185 + TITLE_FONT_SIZE * FONT_SCALE + 67, rect.width - 117 - 100, 64)) self._download_failed_body_label.set_text(self.failed_reason) self._download_failed_body_label.render(rl.Rectangle(rect.x + 117, rect.y, rect.width - 117 - 100, rect.height)) @@ -299,20 +306,20 @@ class Setup(Widget): def render_custom_software_warning(self, rect: rl.Rectangle): warn_rect = rl.Rectangle(rect.x, rect.y, rect.width, 1500) - offset = self._custom_software_warning_body_scroll_panel.handle_scroll(rect, warn_rect) + offset = self._custom_software_warning_body_scroll_panel.update(rect, warn_rect) button_width = (rect.width - MARGIN * 3) / 2 button_y = rect.height - MARGIN - BUTTON_HEIGHT - rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(button_y - BODY_FONT_SIZE)) - y_offset = rect.y + offset.y - self._custom_software_warning_title_label.render(rl.Rectangle(rect.x + 50, y_offset + 150, rect.width - 265, TITLE_FONT_SIZE)) - self._custom_software_warning_body_label.render(rl.Rectangle(rect.x + 50, y_offset + 200 , rect.width - 50, BODY_FONT_SIZE * 3)) + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(button_y - BODY_FONT_SIZE * FONT_SCALE)) + y_offset = rect.y + offset + self._custom_software_warning_title_label.render(rl.Rectangle(rect.x + 50, y_offset + 150, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE)) + self._custom_software_warning_body_label.render(rl.Rectangle(rect.x + 50, y_offset + 200, rect.width - 50, BODY_FONT_SIZE * FONT_SCALE * 3)) rl.end_scissor_mode() self._custom_software_warning_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT)) self._custom_software_warning_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT)) - if offset.y < (rect.height - warn_rect.height): + if offset < (rect.height - warn_rect.height): self._custom_software_warning_continue_button.set_enabled(True) self._custom_software_warning_continue_button.set_text("Continue") @@ -329,7 +336,7 @@ class Setup(Widget): elif result == 0: self.state = SetupState.SOFTWARE_SELECTION - self.keyboard.reset() + self.keyboard.reset(min_text_size=1) self.keyboard.set_title("Enter URL", "for Custom Software") gui_app.set_modal_overlay(self.keyboard, callback=handle_keyboard_result) @@ -343,7 +350,7 @@ class Setup(Widget): shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH) # give time for installer UI to take over - time.sleep(1) + time.sleep(0.1) gui_app.request_close() else: self.state = SetupState.NETWORK_SETUP @@ -369,7 +376,9 @@ class Setup(Widget): fd, tmpfile = tempfile.mkstemp(prefix="installer_") - headers = {"User-Agent": USER_AGENT, "X-openpilot-serial": HARDWARE.get_serial()} + headers = {"User-Agent": USER_AGENT, + "X-openpilot-serial": HARDWARE.get_serial(), + "X-openpilot-device-type": HARDWARE.get_device_type()} req = urllib.request.Request(self.download_url, headers=headers) with open(tmpfile, 'wb') as f, urllib.request.urlopen(req, timeout=30) as response: @@ -406,7 +415,7 @@ class Setup(Widget): f.write(self.download_url) # give time for installer UI to take over - time.sleep(5) + time.sleep(0.1) gui_app.request_close() except Exception: @@ -423,7 +432,9 @@ def main(): try: gui_app.init_window("Setup", 20) setup = Setup() - for _ in gui_app.render(): + for showing_dialog in gui_app.render(): + if showing_dialog: + continue setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) setup.close() except Exception as e: diff --git a/system/ui/text.py b/system/ui/text.py index 61ac043b72..707b30983b 100755 --- a/system/ui/text.py +++ b/system/ui/text.py @@ -7,7 +7,7 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.button import gui_button, ButtonStyle +from openpilot.system.ui.widgets.button import Button, ButtonStyle MARGIN = 50 SPACING = 40 @@ -53,27 +53,30 @@ class TextWindow(Widget): self._textarea_rect = rl.Rectangle(MARGIN, MARGIN, gui_app.width - MARGIN * 2, gui_app.height - MARGIN * 2) self._wrapped_lines = wrap_text(text, FONT_SIZE, self._textarea_rect.width - 20) self._content_rect = rl.Rectangle(0, 0, self._textarea_rect.width - 20, len(self._wrapped_lines) * LINE_HEIGHT) - self._scroll_panel = GuiScrollPanel(show_vertical_scroll_bar=True) - self._scroll_panel._offset.y = -max(self._content_rect.height - self._textarea_rect.height, 0) + self._scroll_panel = GuiScrollPanel() + self._scroll_panel._offset_filter_y.x = -max(self._content_rect.height - self._textarea_rect.height, 0) + + button_text = "Exit" if PC else "Reboot" + self._button = Button(button_text, click_callback=self._on_button_clicked, button_style=ButtonStyle.TRANSPARENT_WHITE_BORDER) + + @staticmethod + def _on_button_clicked(): + gui_app.request_close() + if not PC: + HARDWARE.reboot() def _render(self, rect: rl.Rectangle): - scroll = self._scroll_panel.handle_scroll(self._textarea_rect, self._content_rect) + scroll = self._scroll_panel.update(self._textarea_rect, self._content_rect) rl.begin_scissor_mode(int(self._textarea_rect.x), int(self._textarea_rect.y), int(self._textarea_rect.width), int(self._textarea_rect.height)) for i, line in enumerate(self._wrapped_lines): - position = rl.Vector2(self._textarea_rect.x + scroll.x, self._textarea_rect.y + scroll.y + i * LINE_HEIGHT) + position = rl.Vector2(self._textarea_rect.x, self._textarea_rect.y + scroll + i * LINE_HEIGHT) if position.y + LINE_HEIGHT < self._textarea_rect.y or position.y > self._textarea_rect.y + self._textarea_rect.height: continue rl.draw_text_ex(gui_app.font(), line, position, FONT_SIZE, 0, rl.WHITE) rl.end_scissor_mode() button_bounds = rl.Rectangle(rect.width - MARGIN - BUTTON_SIZE.x - SPACING, rect.height - MARGIN - BUTTON_SIZE.y, BUTTON_SIZE.x, BUTTON_SIZE.y) - ret = gui_button(button_bounds, "Exit" if PC else "Reboot", button_style=ButtonStyle.TRANSPARENT) - if ret: - if PC: - gui_app.request_close() - else: - HARDWARE.reboot() - return ret + self._button.render(button_bounds) if __name__ == "__main__": diff --git a/system/ui/updater.py b/system/ui/updater.py index b3cdc82cf5..5dd5a69c69 100755 --- a/system/ui/updater.py +++ b/system/ui/updater.py @@ -6,10 +6,10 @@ import pyray as rl from enum import IntEnum from openpilot.system.hardware import HARDWARE -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE from openpilot.system.ui.lib.wifi_manager import WifiManager from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.button import gui_button, ButtonStyle +from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.label import gui_text_box, gui_label from openpilot.system.ui.widgets.network import WifiManagerUI @@ -45,8 +45,17 @@ class Updater(Widget): self.update_thread = None self.wifi_manager_ui = WifiManagerUI(WifiManager()) + # Buttons + self._wifi_button = Button("Connect to Wi-Fi", click_callback=lambda: self.set_current_screen(Screen.WIFI)) + self._install_button = Button("Install", click_callback=self.install_update, button_style=ButtonStyle.PRIMARY) + self._back_button = Button("Back", click_callback=lambda: self.set_current_screen(Screen.PROMPT)) + self._reboot_button = Button("Reboot", click_callback=lambda: HARDWARE.reboot()) + + def set_current_screen(self, screen: Screen): + self.current_screen = screen + def install_update(self): - self.current_screen = Screen.PROGRESS + self.set_current_screen(Screen.PROGRESS) self.progress_value = 0 self.progress_text = "Downloading..." self.show_reboot_button = False @@ -80,14 +89,14 @@ class Updater(Widget): def render_prompt_screen(self, rect: rl.Rectangle): # Title - title_rect = rl.Rectangle(MARGIN + 50, 250, rect.width - MARGIN * 2 - 100, TITLE_FONT_SIZE) + title_rect = rl.Rectangle(MARGIN + 50, 250, rect.width - MARGIN * 2 - 100, TITLE_FONT_SIZE * FONT_SCALE) gui_label(title_rect, "Update Required", TITLE_FONT_SIZE, font_weight=FontWeight.BOLD) # Description desc_text = ("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " + "The download size is approximately 1GB.") - desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * 3) + desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE * FONT_SCALE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * FONT_SCALE * 4) gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE) # Buttons at the bottom @@ -96,25 +105,22 @@ class Updater(Widget): # WiFi button wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT) - if gui_button(wifi_button_rect, "Connect to Wi-Fi"): - self.current_screen = Screen.WIFI - return # Return to avoid processing other buttons after screen change + self._wifi_button.render(wifi_button_rect) # Install button install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT) - if gui_button(install_button_rect, "Install", button_style=ButtonStyle.PRIMARY): - self.install_update() - return # Return to avoid further processing after action + self._install_button.render(install_button_rect) def render_wifi_screen(self, rect: rl.Rectangle): # Draw the Wi-Fi manager UI - wifi_rect = rl.Rectangle(MARGIN + 50, MARGIN, rect.width - MARGIN * 2 - 100, rect.height - MARGIN * 2 - BUTTON_HEIGHT - 20) - self.wifi_manager_ui.render(wifi_rect) + wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, + rect.height - BUTTON_HEIGHT - MARGIN * 3) + rl.draw_rectangle_rounded(wifi_rect, 0.035, 10, rl.Color(51, 51, 51, 255)) + wifi_content_rect = rl.Rectangle(wifi_rect.x + 50, wifi_rect.y, wifi_rect.width - 100, wifi_rect.height) + self.wifi_manager_ui.render(wifi_content_rect) back_button_rect = rl.Rectangle(MARGIN, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT) - if gui_button(back_button_rect, "Back"): - self.current_screen = Screen.PROMPT - return # Return to avoid processing other interactions after screen change + self._back_button.render(back_button_rect) def render_progress_screen(self, rect: rl.Rectangle): title_rect = rl.Rectangle(MARGIN + 100, 330, rect.width - MARGIN * 2 - 200, 100) @@ -133,10 +139,7 @@ class Updater(Widget): # Show reboot button if needed if self.show_reboot_button: reboot_rect = rl.Rectangle(MARGIN + 100, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT) - if gui_button(reboot_rect, "Reboot"): - # Return True to signal main loop to exit before rebooting - HARDWARE.reboot() - return + self._reboot_button.render(reboot_rect) def _render(self, rect: rl.Rectangle): if self.current_screen == Screen.PROMPT: @@ -158,7 +161,9 @@ def main(): try: gui_app.init_window("System Update") updater = Updater(updater_path, manifest_path) - for _ in gui_app.render(): + for showing_dialog in gui_app.render(): + if showing_dialog: + continue updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) finally: # Make sure we clean up even if there's an error diff --git a/system/ui/widgets/button.py b/system/ui/widgets/button.py index 1e3f28eb8e..15d48b4e13 100644 --- a/system/ui/widgets/button.py +++ b/system/ui/widgets/button.py @@ -3,10 +3,9 @@ from enum import IntEnum import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos -from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.lib.application import FontWeight, MousePos from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.label import TextAlignment, Label +from openpilot.system.ui.widgets.label import Label class ButtonStyle(IntEnum): @@ -14,7 +13,8 @@ class ButtonStyle(IntEnum): PRIMARY = 1 # For main actions DANGER = 2 # For critical actions, like reboot or delete TRANSPARENT = 3 # For buttons with transparent background and border - TRANSPARENT_WHITE = 3 # For buttons with transparent background and border + TRANSPARENT_WHITE_TEXT = 9 # For buttons with transparent background and border and white text + TRANSPARENT_WHITE_BORDER = 10 # For buttons with transparent background and white border and text ACTION = 4 LIST_ACTION = 5 # For list items with action buttons NO_EFFECT = 6 @@ -31,7 +31,8 @@ BUTTON_TEXT_COLOR = { ButtonStyle.PRIMARY: rl.Color(228, 228, 228, 255), ButtonStyle.DANGER: rl.Color(228, 228, 228, 255), ButtonStyle.TRANSPARENT: rl.BLACK, - ButtonStyle.TRANSPARENT_WHITE: rl.WHITE, + ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.WHITE, + ButtonStyle.TRANSPARENT_WHITE_BORDER: rl.Color(228, 228, 228, 255), ButtonStyle.ACTION: rl.BLACK, ButtonStyle.LIST_ACTION: rl.Color(228, 228, 228, 255), ButtonStyle.NO_EFFECT: rl.Color(228, 228, 228, 255), @@ -40,7 +41,7 @@ BUTTON_TEXT_COLOR = { } BUTTON_DISABLED_TEXT_COLORS = { - ButtonStyle.TRANSPARENT_WHITE: rl.WHITE, + ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.WHITE, } BUTTON_BACKGROUND_COLORS = { @@ -48,7 +49,8 @@ BUTTON_BACKGROUND_COLORS = { ButtonStyle.PRIMARY: rl.Color(70, 91, 234, 255), ButtonStyle.DANGER: rl.Color(255, 36, 36, 255), ButtonStyle.TRANSPARENT: rl.BLACK, - ButtonStyle.TRANSPARENT_WHITE: rl.BLANK, + ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK, + ButtonStyle.TRANSPARENT_WHITE_BORDER: rl.BLACK, ButtonStyle.ACTION: rl.Color(189, 189, 189, 255), ButtonStyle.LIST_ACTION: rl.Color(57, 57, 57, 255), ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255), @@ -61,7 +63,8 @@ BUTTON_PRESSED_BACKGROUND_COLORS = { ButtonStyle.PRIMARY: rl.Color(48, 73, 244, 255), ButtonStyle.DANGER: rl.Color(255, 36, 36, 255), ButtonStyle.TRANSPARENT: rl.BLACK, - ButtonStyle.TRANSPARENT_WHITE: rl.BLANK, + ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK, + ButtonStyle.TRANSPARENT_WHITE_BORDER: rl.BLANK, ButtonStyle.ACTION: rl.Color(130, 130, 130, 255), ButtonStyle.LIST_ACTION: rl.Color(74, 74, 74, 74), ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255), @@ -70,107 +73,9 @@ BUTTON_PRESSED_BACKGROUND_COLORS = { } BUTTON_DISABLED_BACKGROUND_COLORS = { - ButtonStyle.TRANSPARENT_WHITE: rl.BLANK, + ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK, } -_pressed_buttons: set[str] = set() # Track mouse press state globally - - -# TODO: This should be a Widget class - -def gui_button( - rect: rl.Rectangle, - text: str, - font_size: int = DEFAULT_BUTTON_FONT_SIZE, - font_weight: FontWeight = FontWeight.MEDIUM, - button_style: ButtonStyle = ButtonStyle.NORMAL, - is_enabled: bool = True, - border_radius: int = 10, # Corner rounding in pixels - text_alignment: TextAlignment = TextAlignment.CENTER, - text_padding: int = 20, # Padding for left/right alignment - icon=None, -) -> int: - button_id = f"{rect.x}_{rect.y}_{rect.width}_{rect.height}" - result = 0 - - if button_style in (ButtonStyle.PRIMARY, ButtonStyle.DANGER) and not is_enabled: - button_style = ButtonStyle.NORMAL - - if button_style == ButtonStyle.ACTION and font_size == DEFAULT_BUTTON_FONT_SIZE: - font_size = ACTION_BUTTON_FONT_SIZE - - # Set background color based on button type - bg_color = BUTTON_BACKGROUND_COLORS[button_style] - mouse_over = is_enabled and rl.check_collision_point_rec(rl.get_mouse_position(), rect) - is_pressed = button_id in _pressed_buttons - - if mouse_over: - if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT): - # Only this button enters pressed state - _pressed_buttons.add(button_id) - is_pressed = True - - # Use pressed color when mouse is down over this button - if is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT): - bg_color = BUTTON_PRESSED_BACKGROUND_COLORS[button_style] - - # Handle button click - if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and is_pressed: - result = 1 - _pressed_buttons.remove(button_id) - - # Clean up pressed state if mouse is released anywhere - if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and button_id in _pressed_buttons: - _pressed_buttons.remove(button_id) - - # Draw the button with rounded corners - roundness = border_radius / (min(rect.width, rect.height) / 2) - if button_style != ButtonStyle.TRANSPARENT: - rl.draw_rectangle_rounded(rect, roundness, 20, bg_color) - else: - rl.draw_rectangle_rounded(rect, roundness, 20, rl.BLACK) - rl.draw_rectangle_rounded_lines_ex(rect, roundness, 20, 2, rl.WHITE) - - # Handle icon and text positioning - font = gui_app.font(font_weight) - text_size = measure_text_cached(font, text, font_size) - text_pos = rl.Vector2(0, rect.y + (rect.height - text_size.y) // 2) # Vertical centering - - # Draw icon if provided - if icon: - icon_y = rect.y + (rect.height - icon.height) / 2 - if text: - if text_alignment == TextAlignment.LEFT: - icon_x = rect.x + text_padding - text_pos.x = icon_x + icon.width + ICON_PADDING - elif text_alignment == TextAlignment.CENTER: - total_width = icon.width + ICON_PADDING + text_size.x - icon_x = rect.x + (rect.width - total_width) / 2 - text_pos.x = icon_x + icon.width + ICON_PADDING - else: # RIGHT - text_pos.x = rect.x + rect.width - text_size.x - text_padding - icon_x = text_pos.x - ICON_PADDING - icon.width - else: - # Center icon when no text - icon_x = rect.x + (rect.width - icon.width) / 2 - - rl.draw_texture_v(icon, rl.Vector2(icon_x, icon_y), rl.WHITE if is_enabled else rl.Color(255, 255, 255, 100)) - else: - # No icon, position text normally - if text_alignment == TextAlignment.LEFT: - text_pos.x = rect.x + text_padding - elif text_alignment == TextAlignment.CENTER: - text_pos.x = rect.x + (rect.width - text_size.x) // 2 - elif text_alignment == TextAlignment.RIGHT: - text_pos.x = rect.x + rect.width - text_size.x - text_padding - - # Draw the button text if any - if text: - color = BUTTON_TEXT_COLOR[button_style] if is_enabled else BUTTON_DISABLED_TEXT_COLORS.get(button_style, rl.Color(228, 228, 228, 51)) - rl.draw_text_ex(font, text, text_pos, font_size, 0, color) - - return result - class Button(Widget): def __init__(self, @@ -180,9 +85,9 @@ class Button(Widget): font_weight: FontWeight = FontWeight.MEDIUM, button_style: ButtonStyle = ButtonStyle.NORMAL, border_radius: int = 10, - text_alignment: TextAlignment = TextAlignment.CENTER, + text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER, text_padding: int = 20, - icon = None, + icon=None, multi_touch: bool = False, ): @@ -191,8 +96,8 @@ class Button(Widget): self._border_radius = border_radius self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style] - self._label = Label(text, font_size, font_weight, text_alignment, text_padding, - BUTTON_TEXT_COLOR[self._button_style], icon=icon) + self._label = Label(text, font_size, font_weight, text_alignment, text_padding=text_padding, + text_color=BUTTON_TEXT_COLOR[self._button_style], icon=icon) self._click_callback = click_callback self._multi_touch = multi_touch @@ -200,6 +105,11 @@ class Button(Widget): def set_text(self, text): self._label.set_text(text) + def set_button_style(self, button_style: ButtonStyle): + self._button_style = button_style + self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style] + self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style]) + def _update_state(self): if self.enabled: self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style]) @@ -213,7 +123,11 @@ class Button(Widget): def _render(self, _): roundness = self._border_radius / (min(self._rect.width, self._rect.height) / 2) - rl.draw_rectangle_rounded(self._rect, roundness, 10, self._background_color) + if self._button_style == ButtonStyle.TRANSPARENT_WHITE_BORDER: + rl.draw_rectangle_rounded(self._rect, roundness, 10, rl.BLACK) + rl.draw_rectangle_rounded_lines_ex(self._rect, roundness, 10, 2, rl.WHITE) + else: + rl.draw_rectangle_rounded(self._rect, roundness, 10, self._background_color) self._label.render(self._rect) @@ -223,7 +137,7 @@ class ButtonRadio(Button): icon, click_callback: Callable[[], None] | None = None, font_size: int = DEFAULT_BUTTON_FONT_SIZE, - text_alignment: TextAlignment = TextAlignment.LEFT, + text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, border_radius: int = 10, text_padding: int = 20, ): diff --git a/system/ui/widgets/confirm_dialog.py b/system/ui/widgets/confirm_dialog.py index 1021b5452b..8c5ae0aa01 100644 --- a/system/ui/widgets/confirm_dialog.py +++ b/system/ui/widgets/confirm_dialog.py @@ -1,28 +1,40 @@ import pyray as rl from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import DialogResult -from openpilot.system.ui.widgets.button import gui_button, ButtonStyle, Button -from openpilot.system.ui.widgets.label import gui_text_box, Label +from openpilot.system.ui.widgets.button import ButtonStyle, Button +from openpilot.system.ui.widgets.label import Label +from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.scroller import Scroller -DIALOG_WIDTH = 1520 -DIALOG_HEIGHT = 600 +OUTER_MARGIN = 200 +RICH_OUTER_MARGIN = 100 BUTTON_HEIGHT = 160 MARGIN = 50 -TEXT_AREA_HEIGHT_REDUCTION = 200 +TEXT_PADDING = 10 BACKGROUND_COLOR = rl.Color(27, 27, 27, 255) + class ConfirmDialog(Widget): - def __init__(self, text: str, confirm_text: str, cancel_text: str = "Cancel"): + def __init__(self, text: str, confirm_text: str, cancel_text: str | None = None, rich: bool = False): super().__init__() - self._label = Label(text, 70, FontWeight.BOLD) + if cancel_text is None: + cancel_text = tr("Cancel") + self._label = Label(text, 70, FontWeight.BOLD, text_color=rl.Color(201, 201, 201, 255)) + self._html_renderer = HtmlRenderer(text=text, text_size={ElementType.P: 50}, center_text=True) self._cancel_button = Button(cancel_text, self._cancel_button_callback) self._confirm_button = Button(confirm_text, self._confirm_button_callback, button_style=ButtonStyle.PRIMARY) + self._rich = rich self._dialog_result = DialogResult.NO_ACTION self._cancel_text = cancel_text + self._scroller = Scroller([self._html_renderer], line_separator=False, spacing=0) def set_text(self, text): - self._label.set_text(text) + if not self._rich: + self._label.set_text(text) + else: + self._html_renderer.parse_html_content(text) def reset(self): self._dialog_result = DialogResult.NO_ACTION @@ -34,9 +46,11 @@ class ConfirmDialog(Widget): self._dialog_result = DialogResult.CONFIRM def _render(self, rect: rl.Rectangle): - dialog_x = (gui_app.width - DIALOG_WIDTH) / 2 - dialog_y = (gui_app.height - DIALOG_HEIGHT) / 2 - dialog_rect = rl.Rectangle(dialog_x, dialog_y, DIALOG_WIDTH, DIALOG_HEIGHT) + dialog_x = OUTER_MARGIN if not self._rich else RICH_OUTER_MARGIN + dialog_y = OUTER_MARGIN if not self._rich else RICH_OUTER_MARGIN + dialog_width = gui_app.width - 2 * dialog_x + dialog_height = gui_app.height - 2 * dialog_y + dialog_rect = rl.Rectangle(dialog_x, dialog_y, dialog_width, dialog_height) bottom = dialog_rect.y + dialog_rect.height button_width = (dialog_rect.width - 3 * MARGIN) // 2 @@ -48,8 +62,15 @@ class ConfirmDialog(Widget): rl.draw_rectangle_rec(dialog_rect, BACKGROUND_COLOR) - text_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y, dialog_rect.width - 2 * MARGIN, dialog_rect.height - TEXT_AREA_HEIGHT_REDUCTION) - self._label.render(text_rect) + text_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + TEXT_PADDING, + dialog_rect.width - 2 * MARGIN, dialog_rect.height - BUTTON_HEIGHT - MARGIN - TEXT_PADDING * 2) + if not self._rich: + self._label.render(text_rect) + else: + html_rect = rl.Rectangle(text_rect.x, text_rect.y, text_rect.width, + self._html_renderer.get_total_height(int(text_rect.width))) + self._html_renderer.set_rect(html_rect) + self._scroller.render(text_rect) if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER): self._dialog_result = DialogResult.CONFIRM @@ -60,63 +81,14 @@ class ConfirmDialog(Widget): self._confirm_button.render(confirm_button) self._cancel_button.render(cancel_button) else: - centered_button_x = dialog_rect.x + (dialog_rect.width - button_width) / 2 - centered_confirm_button = rl.Rectangle(centered_button_x, button_y, button_width, BUTTON_HEIGHT) - self._confirm_button.render(centered_confirm_button) + full_button_width = dialog_rect.width - 2 * MARGIN + full_confirm_button = rl.Rectangle(dialog_rect.x + MARGIN, button_y, full_button_width, BUTTON_HEIGHT) + self._confirm_button.render(full_confirm_button) return self._dialog_result -def confirm_dialog(message: str, confirm_text: str, cancel_text: str = "Cancel") -> DialogResult: - dialog_x = (gui_app.width - DIALOG_WIDTH) / 2 - dialog_y = (gui_app.height - DIALOG_HEIGHT) / 2 - dialog_rect = rl.Rectangle(dialog_x, dialog_y, DIALOG_WIDTH, DIALOG_HEIGHT) - - # Calculate button positions at the bottom of the dialog - bottom = dialog_rect.y + dialog_rect.height - button_width = (dialog_rect.width - 3 * MARGIN) // 2 - no_button_x = dialog_rect.x + MARGIN - yes_button_x = dialog_rect.x + dialog_rect.width - button_width - MARGIN - button_y = bottom - BUTTON_HEIGHT - MARGIN - no_button = rl.Rectangle(no_button_x, button_y, button_width, BUTTON_HEIGHT) - yes_button = rl.Rectangle(yes_button_x, button_y, button_width, BUTTON_HEIGHT) - - # Draw the dialog background - rl.draw_rectangle_rec(dialog_rect, BACKGROUND_COLOR) - - # Draw the message in the dialog, centered - text_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y, dialog_rect.width - 2 * MARGIN, dialog_rect.height - TEXT_AREA_HEIGHT_REDUCTION) - gui_text_box( - text_rect, - message, - font_size=70, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, - font_weight=FontWeight.BOLD, - ) - - # Initialize result; -1 means no action taken yet - result = DialogResult.NO_ACTION - - # Check for keyboard input for accessibility - if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER): - result = DialogResult.CONFIRM - elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE): - result = DialogResult.CANCEL - - # Check for button clicks - if cancel_text: - if gui_button(yes_button, confirm_text, button_style=ButtonStyle.PRIMARY): - result = DialogResult.CONFIRM - if gui_button(no_button, cancel_text): - result = DialogResult.CANCEL - else: - centered_button_x = dialog_rect.x + (dialog_rect.width - button_width) / 2 - centered_yes_button = rl.Rectangle(centered_button_x, button_y, button_width, BUTTON_HEIGHT) - if gui_button(centered_yes_button, confirm_text, button_style=ButtonStyle.PRIMARY): - result = DialogResult.CONFIRM - - return result - - -def alert_dialog(message: str, button_text: str = "OK") -> DialogResult: - return confirm_dialog(message, button_text, cancel_text="") + +def alert_dialog(message: str, button_text: str | None = None): + if button_text is None: + button_text = tr("OK") + return ConfirmDialog(message, button_text, cancel_text="") diff --git a/system/ui/widgets/html_render.py b/system/ui/widgets/html_render.py index 247b7a5492..368d02cdfc 100644 --- a/system/ui/widgets/html_render.py +++ b/system/ui/widgets/html_render.py @@ -3,11 +3,15 @@ import pyray as rl from dataclasses import dataclass from enum import Enum from typing import Any -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.wrap_text import wrap_text -from openpilot.system.ui.widgets import Widget, DialogResult -from openpilot.system.ui.widgets.button import gui_button, ButtonStyle +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.lib.text_measure import measure_text_cached + +LIST_INDENT_PX = 40 class ElementType(Enum): @@ -18,40 +22,73 @@ class ElementType(Enum): H5 = "h5" H6 = "h6" P = "p" + B = "b" + UL = "ul" + LI = "li" BR = "br" +TAG_NAMES = '|'.join([t.value for t in ElementType]) +START_TAG_RE = re.compile(f'<({TAG_NAMES})>') +END_TAG_RE = re.compile(f'') + + +def is_tag(token: str) -> tuple[bool, bool, ElementType | None]: + supported_tag = bool(START_TAG_RE.fullmatch(token)) + supported_end_tag = bool(END_TAG_RE.fullmatch(token)) + tag = ElementType(token[1:-1].strip('/')) if supported_tag or supported_end_tag else None + return supported_tag, supported_end_tag, tag + + @dataclass class HtmlElement: type: ElementType content: str font_size: int font_weight: FontWeight - color: rl.Color margin_top: int margin_bottom: int - line_height: float = 1.2 + line_height: float = 0.9 # matches Qt visually, unsure why not default 1.2 + indent_level: int = 0 class HtmlRenderer(Widget): - def __init__(self, file_path: str): - self.elements: list[HtmlElement] = [] + def __init__(self, file_path: str | None = None, text: str | None = None, + text_size: dict | None = None, text_color: rl.Color = rl.WHITE, center_text: bool = False): + super().__init__() + self._text_color = text_color + self._center_text = center_text self._normal_font = gui_app.font(FontWeight.NORMAL) self._bold_font = gui_app.font(FontWeight.BOLD) - self._scroll_panel = GuiScrollPanel() + self._indent_level = 0 + if text_size is None: + text_size = {} + + # Base paragraph size (Qt stylesheet default is 48px in offroad alerts) + base_p_size = int(text_size.get(ElementType.P, 48)) + + # Untagged text defaults to

self.styles: dict[ElementType, dict[str, Any]] = { - ElementType.H1: {"size": 68, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 20, "margin_bottom": 16}, - ElementType.H2: {"size": 60, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 24, "margin_bottom": 12}, - ElementType.H3: {"size": 52, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 20, "margin_bottom": 10}, - ElementType.H4: {"size": 48, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 16, "margin_bottom": 8}, - ElementType.H5: {"size": 44, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 12, "margin_bottom": 6}, - ElementType.H6: {"size": 40, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 10, "margin_bottom": 4}, - ElementType.P: {"size": 38, "weight": FontWeight.NORMAL, "color": rl.Color(40, 40, 40, 255), "margin_top": 8, "margin_bottom": 12}, - ElementType.BR: {"size": 0, "weight": FontWeight.NORMAL, "color": rl.BLACK, "margin_top": 0, "margin_bottom": 12}, + ElementType.H1: {"size": round(base_p_size * 2), "weight": FontWeight.BOLD, "margin_top": 20, "margin_bottom": 16}, + ElementType.H2: {"size": round(base_p_size * 1.50), "weight": FontWeight.BOLD, "margin_top": 24, "margin_bottom": 12}, + ElementType.H3: {"size": round(base_p_size * 1.17), "weight": FontWeight.BOLD, "margin_top": 20, "margin_bottom": 10}, + ElementType.H4: {"size": round(base_p_size * 1.00), "weight": FontWeight.BOLD, "margin_top": 16, "margin_bottom": 8}, + ElementType.H5: {"size": round(base_p_size * 0.83), "weight": FontWeight.BOLD, "margin_top": 12, "margin_bottom": 6}, + ElementType.H6: {"size": round(base_p_size * 0.67), "weight": FontWeight.BOLD, "margin_top": 10, "margin_bottom": 4}, + ElementType.P: {"size": base_p_size, "weight": FontWeight.NORMAL, "margin_top": 8, "margin_bottom": 12}, + ElementType.B: {"size": base_p_size, "weight": FontWeight.BOLD, "margin_top": 8, "margin_bottom": 12}, + ElementType.LI: {"size": base_p_size, "weight": FontWeight.NORMAL, "color": rl.Color(40, 40, 40, 255), "margin_top": 6, "margin_bottom": 6}, + ElementType.BR: {"size": 0, "weight": FontWeight.NORMAL, "margin_top": 0, "margin_bottom": 12}, } - self.parse_html_file(file_path) + self.elements: list[HtmlElement] = [] + if file_path is not None: + self.parse_html_file(file_path) + elif text is not None: + self.parse_html_content(text) + else: + raise ValueError("Either file_path or text must be provided") def parse_html_file(self, file_path: str) -> None: with open(file_path, encoding='utf-8') as file: @@ -68,25 +105,52 @@ class HtmlRenderer(Widget): html_content = re.sub(r']*>', '', html_content) html_content = re.sub(r']*>', '', html_content) - # Find all HTML elements - pattern = r'<(h[1-6]|p)(?:[^>]*)>(.*?)|' - matches = re.finditer(pattern, html_content, re.DOTALL | re.IGNORECASE) + # Parse HTML + tokens = re.findall(r']+>|<[^>]+>|[^<\s]+', html_content) + + def close_tag(): + nonlocal current_content + nonlocal current_tag + + # If no tag is set, default to paragraph so we don't lose text + if current_tag is None: + current_tag = ElementType.P + + text = ' '.join(current_content).strip() + current_content = [] + if text: + if current_tag == ElementType.LI: + text = '• ' + text + self._add_element(current_tag, text) + + current_content: list[str] = [] + current_tag: ElementType | None = None + for token in tokens: + is_start_tag, is_end_tag, tag = is_tag(token) + if tag is not None: + if tag == ElementType.BR: + # Close current tag and add a line break + close_tag() + self._add_element(ElementType.BR, "") + + elif is_start_tag or is_end_tag: + # Always add content regardless of opening or closing tag + close_tag() + + if is_start_tag: + current_tag = tag + else: + current_tag = None + + # increment after we add the content for the current tag + if tag == ElementType.UL: + self._indent_level = self._indent_level + 1 if is_start_tag else max(0, self._indent_level - 1) - for match in matches: - if match.group(0).lower().startswith(' tags - self._add_element(ElementType.BR, "") else: - tag = match.group(1).lower() - content = match.group(2).strip() - - # Clean up content - remove extra whitespace - content = re.sub(r'\s+', ' ', content) - content = content.strip() + current_content.append(token) - if content: # Only add non-empty elements - element_type = ElementType(tag) - self._add_element(element_type, content) + if current_content: + close_tag() def _add_element(self, element_type: ElementType, content: str) -> None: style = self.styles[element_type] @@ -96,42 +160,16 @@ class HtmlRenderer(Widget): content=content, font_size=style["size"], font_weight=style["weight"], - color=style["color"], margin_top=style["margin_top"], margin_bottom=style["margin_bottom"], + indent_level=self._indent_level, ) self.elements.append(element) def _render(self, rect: rl.Rectangle): - margin = 50 - content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - (margin * 2), rect.height - (margin * 2)) - - button_height = 160 - button_spacing = 20 - scrollable_height = content_rect.height - button_height - button_spacing - - scrollable_rect = rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, scrollable_height) - - total_height = self.get_total_height(int(scrollable_rect.width)) - scroll_content_rect = rl.Rectangle(scrollable_rect.x, scrollable_rect.y, scrollable_rect.width, total_height) - scroll_offset = self._scroll_panel.handle_scroll(scrollable_rect, scroll_content_rect) - - rl.begin_scissor_mode(int(scrollable_rect.x), int(scrollable_rect.y), int(scrollable_rect.width), int(scrollable_rect.height)) - self._render_content(scrollable_rect, scroll_offset.y) - rl.end_scissor_mode() - - button_width = (rect.width - 3 * 50) // 3 - button_x = content_rect.x + (content_rect.width - button_width) / 2 - button_y = content_rect.y + content_rect.height - button_height - button_rect = rl.Rectangle(button_x, button_y, button_width, button_height) - if gui_button(button_rect, "OK", button_style=ButtonStyle.PRIMARY) == 1: - return DialogResult.CONFIRM - - return DialogResult.NO_ACTION - - def _render_content(self, rect: rl.Rectangle, scroll_offset: float = 0) -> float: - current_y = rect.y + scroll_offset + # TODO: speed up by removing duplicate calculations across renders + current_y = rect.y padding = 20 content_width = rect.width - (padding * 2) @@ -149,21 +187,28 @@ class HtmlRenderer(Widget): wrapped_lines = wrap_text(font, element.content, element.font_size, int(content_width)) for line in wrapped_lines: - if current_y < rect.y - element.font_size: - current_y += element.font_size * element.line_height + # Use FONT_SCALE from wrapped raylib text functions to match what is drawn + if current_y < rect.y - element.font_size * FONT_SCALE: + current_y += element.font_size * FONT_SCALE * element.line_height continue if current_y > rect.y + rect.height: break - rl.draw_text_ex(font, line, rl.Vector2(rect.x + padding, current_y), element.font_size, 0, rl.WHITE) + if self._center_text: + text_width = measure_text_cached(font, line, element.font_size).x + text_x = rect.x + (rect.width - text_width) / 2 + else: # left align + text_x = rect.x + (max(element.indent_level - 1, 0) * LIST_INDENT_PX) + + rl.draw_text_ex(font, line, rl.Vector2(text_x + padding, current_y), element.font_size, 0, self._text_color) - current_y += element.font_size * element.line_height + current_y += element.font_size * FONT_SCALE * element.line_height # Apply bottom margin current_y += element.margin_bottom - return current_y - rect.y - scroll_offset # Return total content height + return current_y - rect.y def get_total_height(self, content_width: int) -> float: total_height = 0.0 @@ -182,7 +227,7 @@ class HtmlRenderer(Widget): wrapped_lines = wrap_text(font, element.content, element.font_size, int(usable_width)) for _ in wrapped_lines: - total_height += element.font_size * element.line_height + total_height += element.font_size * FONT_SCALE * element.line_height total_height += element.margin_bottom @@ -192,3 +237,38 @@ class HtmlRenderer(Widget): if weight == FontWeight.BOLD: return self._bold_font return self._normal_font + + +class HtmlModal(Widget): + def __init__(self, file_path: str | None = None, text: str | None = None): + super().__init__() + self._content = HtmlRenderer(file_path=file_path, text=text) + self._scroll_panel = GuiScrollPanel() + self._ok_button = Button(tr("OK"), click_callback=lambda: gui_app.set_modal_overlay(None), button_style=ButtonStyle.PRIMARY) + + def _render(self, rect: rl.Rectangle): + margin = 50 + content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - (margin * 2), rect.height - (margin * 2)) + + button_height = 160 + button_spacing = 20 + scrollable_height = content_rect.height - button_height - button_spacing + + scrollable_rect = rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, scrollable_height) + + total_height = self._content.get_total_height(int(scrollable_rect.width)) + scroll_content_rect = rl.Rectangle(scrollable_rect.x, scrollable_rect.y, scrollable_rect.width, total_height) + scroll_offset = self._scroll_panel.update(scrollable_rect, scroll_content_rect) + scroll_content_rect.y += scroll_offset + + rl.begin_scissor_mode(int(scrollable_rect.x), int(scrollable_rect.y), int(scrollable_rect.width), int(scrollable_rect.height)) + self._content.render(scroll_content_rect) + rl.end_scissor_mode() + + button_width = (rect.width - 3 * 50) // 3 + button_x = content_rect.x + content_rect.width - button_width + button_y = content_rect.y + content_rect.height - button_height + button_rect = rl.Rectangle(button_x, button_y, button_width, button_height) + self._ok_button.render(button_rect) + + return -1 diff --git a/system/ui/widgets/inputbox.py b/system/ui/widgets/inputbox.py index 239d63037e..f53e3f0ebb 100644 --- a/system/ui/widgets/inputbox.py +++ b/system/ui/widgets/inputbox.py @@ -1,6 +1,6 @@ import pyray as rl import time -from openpilot.system.ui.lib.application import gui_app, MousePos +from openpilot.system.ui.lib.application import gui_app, MousePos, FONT_SCALE from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget @@ -130,7 +130,7 @@ class InputBox(Widget): rl.draw_text_ex( font, display_text, - rl.Vector2(int(rect.x + padding - self._text_offset), int(rect.y + rect.height / 2 - font_size / 2)), + rl.Vector2(int(rect.x + padding - self._text_offset), int(rect.y + rect.height / 2 - font_size * FONT_SCALE / 2)), font_size, 0, text_color, @@ -145,7 +145,7 @@ class InputBox(Widget): # Apply text offset to cursor position cursor_x -= self._text_offset - cursor_height = font_size + 4 + cursor_height = font_size * FONT_SCALE + 4 cursor_y = rect.y + rect.height / 2 - cursor_height / 2 rl.draw_line(int(cursor_x), int(cursor_y), int(cursor_x), int(cursor_y + cursor_height), rl.WHITE) diff --git a/system/ui/widgets/keyboard.py b/system/ui/widgets/keyboard.py index 70f06f6b9d..ac006c2545 100644 --- a/system/ui/widgets/keyboard.py +++ b/system/ui/widgets/keyboard.py @@ -5,10 +5,11 @@ from typing import Literal import pyray as rl from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import ButtonStyle, Button from openpilot.system.ui.widgets.inputbox import InputBox -from openpilot.system.ui.widgets.label import Label, TextAlignment +from openpilot.system.ui.widgets.label import Label KEY_FONT_SIZE = 96 DOUBLE_CLICK_THRESHOLD = 0.5 # seconds @@ -19,7 +20,7 @@ DELETE_REPEAT_INTERVAL = 0.07 CONTENT_MARGIN = 50 BACKSPACE_KEY = "<-" ENTER_KEY = "->" -SPACE_KEY = " " +SPACE_KEY = " " SHIFT_INACTIVE_KEY = "SHIFT_OFF" SHIFT_ACTIVE_KEY = "SHIFT_ON" CAPS_LOCK_KEY = "CAPS" @@ -62,8 +63,8 @@ class Keyboard(Widget): self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase" self._caps_lock = False self._last_shift_press_time = 0 - self._title = Label("", 90, FontWeight.BOLD, TextAlignment.LEFT) - self._sub_title = Label("", 55, FontWeight.NORMAL, TextAlignment.LEFT) + self._title = Label("", 90, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + self._sub_title = Label("", 55, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) self._max_text_size = max_text_size self._min_text_size = min_text_size @@ -77,7 +78,7 @@ class Keyboard(Widget): self._backspace_last_repeat: float = 0.0 self._render_return_status = -1 - self._cancel_button = Button("Cancel", self._cancel_button_callback) + self._cancel_button = Button(tr("Cancel"), self._cancel_button_callback) self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT) @@ -98,7 +99,7 @@ class Keyboard(Widget): if key in self._key_icons: texture = self._key_icons[key] self._all_keys[key] = Button("", partial(self._key_callback, key), icon=texture, - button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD, multi_touch=True) + button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD, multi_touch=True) else: self._all_keys[key] = Button(key, partial(self._key_callback, key), button_style=ButtonStyle.KEYBOARD, font_size=85, multi_touch=True) self._all_keys[CAPS_LOCK_KEY] = Button("", partial(self._key_callback, CAPS_LOCK_KEY), icon=self._key_icons[CAPS_LOCK_KEY], diff --git a/system/ui/widgets/label.py b/system/ui/widgets/label.py index 2da9a9f8df..99aed529a7 100644 --- a/system/ui/widgets/label.py +++ b/system/ui/widgets/label.py @@ -1,9 +1,8 @@ -from enum import IntEnum from itertools import zip_longest - +from typing import Union import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR +from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.utils import GuiStyleContext from openpilot.system.ui.lib.emoji import find_emoji, emoji_tex @@ -12,10 +11,6 @@ from openpilot.system.ui.widgets import Widget ICON_PADDING = 15 -class TextAlignment(IntEnum): - LEFT = 0 - CENTER = 1 - RIGHT = 2 # TODO: This should be a Widget class def gui_label( @@ -76,8 +71,8 @@ def gui_text_box( ): styles = [ (rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)), - (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, font_size), - (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, font_size), + (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, round(font_size * FONT_SCALE)), + (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE)), (rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_ALIGNMENT, alignment), (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical), (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD) @@ -98,10 +93,11 @@ class Label(Widget): text: str, font_size: int = DEFAULT_TEXT_SIZE, font_weight: FontWeight = FontWeight.NORMAL, - text_alignment: TextAlignment = TextAlignment.CENTER, - text_padding: int = 20, + text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + text_alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + text_padding: int = 0, text_color: rl.Color = DEFAULT_TEXT_COLOR, - icon = None, + icon: Union[rl.Texture, None] = None, # noqa: UP007 ): super().__init__() @@ -109,41 +105,50 @@ class Label(Widget): self._font = gui_app.font(self._font_weight) self._font_size = font_size self._text_alignment = text_alignment + self._text_alignment_vertical = text_alignment_vertical self._text_padding = text_padding self._text_color = text_color self._icon = icon + + self._text = text self.set_text(text) def set_text(self, text): - self._text_raw = text - self._update_text(self._text_raw) + self._text = text + self._update_text(self._text) def set_text_color(self, color): self._text_color = color + def set_font_size(self, size): + self._font_size = size + self._update_text(self._text) + def _update_layout_rects(self): - self._update_text(self._text_raw) + self._update_text(self._text) def _update_text(self, text): self._emojis = [] self._text_size = [] - self._text = wrap_text(self._font, text, self._font_size, self._rect.width - (self._text_padding*2)) - for t in self._text: + self._text_wrapped = wrap_text(self._font, text, self._font_size, round(self._rect.width - (self._text_padding * 2))) + for t in self._text_wrapped: self._emojis.append(find_emoji(t)) self._text_size.append(measure_text_cached(self._font, t, self._font_size)) def _render(self, _): - text = self._text[0] if self._text else None text_size = self._text_size[0] if self._text_size else rl.Vector2(0.0, 0.0) - text_pos = rl.Vector2(0, (self._rect.y + (self._rect.height - (text_size.y)) // 2)) + if self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: + text_pos = rl.Vector2(self._rect.x, (self._rect.y + (self._rect.height - text_size.y) // 2)) + else: + text_pos = rl.Vector2(self._rect.x, self._rect.y) if self._icon: icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2 - if text: - if self._text_alignment == TextAlignment.LEFT: + if len(self._text_wrapped) > 0: + if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: icon_x = self._rect.x + self._text_padding text_pos.x = self._icon.width + ICON_PADDING - elif self._text_alignment == TextAlignment.CENTER: + elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: total_width = self._icon.width + ICON_PADDING + text_size.x icon_x = self._rect.x + (self._rect.width - total_width) / 2 text_pos.x = self._icon.width + ICON_PADDING @@ -153,14 +158,14 @@ class Label(Widget): icon_x = self._rect.x + (self._rect.width - self._icon.width) / 2 rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE) - for text, text_size, emojis in zip_longest(self._text, self._text_size, self._emojis, fillvalue=[]): + for text, text_size, emojis in zip_longest(self._text_wrapped, self._text_size, self._emojis, fillvalue=[]): line_pos = rl.Vector2(text_pos.x, text_pos.y) - if self._text_alignment == TextAlignment.LEFT: - line_pos.x += self._rect.x + self._text_padding - elif self._text_alignment == TextAlignment.CENTER: - line_pos.x += self._rect.x + (self._rect.width - text_size.x) // 2 - elif self._text_alignment == TextAlignment.RIGHT: - line_pos.x += self._rect.x + self._rect.width - text_size.x - self._text_padding + if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: + line_pos.x += self._text_padding + elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: + line_pos.x += (self._rect.width - text_size.x) // 2 + elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: + line_pos.x += self._rect.width - text_size.x - self._text_padding prev_index = 0 for start, end, emoji in emojis: @@ -170,8 +175,8 @@ class Label(Widget): line_pos.x += width_before.x tex = emoji_tex(emoji) - rl.draw_texture_ex(tex, line_pos, 0.0, self._font_size / tex.height, self._text_color) - line_pos.x += self._font_size + rl.draw_texture_ex(tex, line_pos, 0.0, self._font_size / tex.height * FONT_SCALE, self._text_color) + line_pos.x += self._font_size * FONT_SCALE prev_index = end rl.draw_text_ex(self._font, text[prev_index:], line_pos, self._font_size, 0, self._text_color) - text_pos.y += text_size.y or self._font_size + text_pos.y += text_size.y or self._font_size * FONT_SCALE diff --git a/system/ui/widgets/list_view.py b/system/ui/widgets/list_view.py index 648ce47a6f..55abe02fe1 100644 --- a/system/ui/widgets/list_view.py +++ b/system/ui/widgets/list_view.py @@ -3,17 +3,20 @@ import pyray as rl from collections.abc import Callable from abc import ABC from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached -from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.button import Button, gui_button, ButtonStyle +from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT +from openpilot.system.ui.widgets.label import gui_label +from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType ITEM_BASE_WIDTH = 600 ITEM_BASE_HEIGHT = 170 ITEM_PADDING = 20 ITEM_TEXT_FONT_SIZE = 50 ITEM_TEXT_COLOR = rl.WHITE +ITEM_TEXT_VALUE_COLOR = rl.Color(170, 170, 170, 255) ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255) ITEM_DESC_FONT_SIZE = 40 ITEM_DESC_V_OFFSET = 140 @@ -41,6 +44,10 @@ class ItemAction(Widget, ABC): self.set_rect(rl.Rectangle(0, 0, width, 0)) self._enabled_source = enabled + def get_width_hint(self) -> float: + # Return's action ideal width, 0 means use full width + return self._rect.width + def set_enabled(self, enabled: bool | Callable[[], bool]): self._enabled_source = enabled @@ -50,10 +57,10 @@ class ItemAction(Widget, ABC): class ToggleAction(ItemAction): - def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True): + def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True, + callback: Callable[[bool], None] | None = None): super().__init__(width, enabled) - self.toggle = Toggle(initial_state=initial_state) - self.state = initial_state + self.toggle = Toggle(initial_state=initial_state, callback=callback) def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: super().set_touch_valid_callback(touch_callback) @@ -62,22 +69,22 @@ class ToggleAction(ItemAction): def _render(self, rect: rl.Rectangle) -> bool: self.toggle.set_enabled(self.enabled) clicked = self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self._rect.width, TOGGLE_HEIGHT)) - self.state = self.toggle.get_state() return bool(clicked) def set_state(self, state: bool): - self.state = state self.toggle.set_state(state) def get_state(self) -> bool: - return self.state + return self.toggle.get_state() class ButtonAction(ItemAction): def __init__(self, text: str | Callable[[], str], width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True): super().__init__(width, enabled) self._text_source = text + self._value_source: str | Callable[[], str] | None = None self._pressed = False + self._font = gui_app.font(FontWeight.NORMAL) def pressed(): self._pressed = True @@ -89,6 +96,7 @@ class ButtonAction(ItemAction): button_style=ButtonStyle.LIST_ACTION, border_radius=BUTTON_BORDER_RADIUS, click_callback=pressed, + text_padding=0, ) self.set_enabled(enabled) @@ -96,9 +104,19 @@ class ButtonAction(ItemAction): super().set_touch_valid_callback(touch_callback) self._button.set_touch_valid_callback(touch_callback) + def set_text(self, text: str | Callable[[], str]): + self._text_source = text + + def set_value(self, value: str | Callable[[], str]): + self._value_source = value + @property def text(self): - return _resolve_value(self._text_source, "Error") + return _resolve_value(self._text_source, tr("Error")) + + @property + def value(self): + return _resolve_value(self._value_source, "") def _render(self, rect: rl.Rectangle) -> bool: self._button.set_text(self.text) @@ -106,6 +124,14 @@ class ButtonAction(ItemAction): button_rect = rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT) self._button.render(button_rect) + value_text = self.value + if value_text: + spacing = 20 + text_size = measure_text_cached(self._font, value_text, ITEM_TEXT_FONT_SIZE) + text_x = button_rect.x - spacing - text_size.x + text_y = rect.y + (rect.height - text_size.y) / 2 + rl.draw_text_ex(self._font, value_text, rl.Vector2(text_x, text_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_VALUE_COLOR) + # TODO: just use the generic Widget click callbacks everywhere, no returning from render pressed = self._pressed self._pressed = False @@ -124,21 +150,21 @@ class TextAction(ItemAction): @property def text(self): - return _resolve_value(self._text_source, "Error") + return _resolve_value(self._text_source, tr("Error")) - def _update_state(self): + def get_width_hint(self) -> float: text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x - self._rect.width = int(text_width + TEXT_PADDING) + return text_width + TEXT_PADDING def _render(self, rect: rl.Rectangle) -> bool: - current_text = self.text - text_size = measure_text_cached(self._font, current_text, ITEM_TEXT_FONT_SIZE) - - text_x = rect.x + (rect.width - text_size.x) / 2 - text_y = rect.y + (rect.height - text_size.y) / 2 - rl.draw_text_ex(self._font, current_text, rl.Vector2(text_x, text_y), ITEM_TEXT_FONT_SIZE, 0, self.color) + gui_label(self._rect, self.text, font_size=ITEM_TEXT_FONT_SIZE, color=self.color, + font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) return False + def set_text(self, text: str | Callable[[], str]): + self._text_source = text + def get_width(self) -> int: text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x return int(text_width + TEXT_PADDING) @@ -149,9 +175,16 @@ class DualButtonAction(ItemAction): right_callback: Callable = None, enabled: bool | Callable[[], bool] = True): super().__init__(width=0, enabled=enabled) # Width 0 means use full width self.left_text, self.right_text = left_text, right_text - self.left_callback, self.right_callback = left_callback, right_callback - def _render(self, rect: rl.Rectangle) -> bool: + self.left_button = Button(left_text, click_callback=left_callback, button_style=ButtonStyle.LIST_ACTION, text_padding=0) + self.right_button = Button(right_text, click_callback=right_callback, button_style=ButtonStyle.DANGER, text_padding=0) + + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + super().set_touch_valid_callback(touch_callback) + self.left_button.set_touch_valid_callback(touch_callback) + self.right_button.set_touch_valid_callback(touch_callback) + + def _render(self, rect: rl.Rectangle): button_spacing = 30 button_height = 120 button_width = (rect.width - button_spacing) / 2 @@ -160,31 +193,37 @@ class DualButtonAction(ItemAction): 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) - left_clicked = gui_button(left_rect, self.left_text, button_style=ButtonStyle.LIST_ACTION) == 1 - right_clicked = gui_button(right_rect, self.right_text, button_style=ButtonStyle.DANGER) == 1 + # 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 - if left_clicked and self.left_callback: - self.left_callback() - return True - if right_clicked and self.right_callback: - self.right_callback() - return True - return False + # Render buttons + self.left_button.render(left_rect) + self.right_button.render(right_rect) class MultipleButtonAction(ItemAction): def __init__(self, buttons: list[str], button_width: int, selected_index: int = 0, callback: Callable = None): - super().__init__(width=len(buttons) * (button_width + 20), enabled=True) + super().__init__(width=len(buttons) * button_width + (len(buttons) - 1) * RIGHT_ITEM_PADDING, enabled=True) self.buttons = buttons self.button_width = button_width self.selected_button = selected_index self.callback = callback self._font = gui_app.font(FontWeight.MEDIUM) - def _render(self, rect: rl.Rectangle) -> bool: - spacing = 20 + def set_selected_button(self, index: int): + if 0 <= index < len(self.buttons): + self.selected_button = index + + def get_selected_button(self) -> int: + return self.selected_button + + def _render(self, rect: rl.Rectangle): + spacing = RIGHT_ITEM_PADDING button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2 - clicked = -1 for i, text in enumerate(self.buttons): button_x = rect.x + i * (self.button_width + spacing) @@ -192,8 +231,7 @@ class MultipleButtonAction(ItemAction): # Check button state mouse_pos = rl.get_mouse_position() - is_hovered = rl.check_collision_point_rec(mouse_pos, button_rect) and self.enabled - is_pressed = is_hovered and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and self.is_pressed + is_pressed = rl.check_collision_point_rec(mouse_pos, button_rect) and self.enabled and self.is_pressed is_selected = i == self.selected_button # Button colors @@ -217,16 +255,16 @@ class MultipleButtonAction(ItemAction): text_color = rl.Color(228, 228, 228, 255) if self.enabled else rl.Color(150, 150, 150, 255) rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, text_color) - # Handle click - if is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self.is_pressed: - clicked = i - - if clicked >= 0: - self.selected_button = clicked - if self.callback: - self.callback(clicked) - return True - return False + def _handle_mouse_release(self, mouse_pos: MousePos): + spacing = RIGHT_ITEM_PADDING + button_y = self._rect.y + (self._rect.height - BUTTON_HEIGHT) / 2 + for i, _text in enumerate(self.buttons): + button_x = self._rect.x + i * (self.button_width + spacing) + button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT) + if rl.check_collision_point_rec(mouse_pos, button_rect): + self.selected_button = i + if self.callback: + self.callback(i) class ListItem(Widget): @@ -235,21 +273,28 @@ class ListItem(Widget): action_item: ItemAction | None = None): super().__init__() self.title = title - self.icon = icon - self.description = description + self.set_icon(icon) + self._description = description self.description_visible = description_visible self.callback = callback + self.description_opened_callback: Callable | None = None self.action_item = action_item self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT)) self._font = gui_app.font(FontWeight.NORMAL) - self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) if self.icon else None + + self._html_renderer = HtmlRenderer(text="", text_size={ElementType.P: ITEM_DESC_FONT_SIZE}, + text_color=ITEM_DESC_TEXT_COLOR) + self.set_description(self.description) # Cached properties for performance - self._prev_max_width: int = 0 - self._wrapped_description: str | None = None - self._prev_description: str | None = None - self._description_height: float = 0 + self._prev_description: str | None = self.description + + def show_event(self): + self._set_description_visible(False) + + def set_description_opened_callback(self, callback: Callable) -> None: + self.description_opened_callback = callback def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: super().set_touch_valid_callback(touch_callback) @@ -271,11 +316,24 @@ class ListItem(Widget): # Click was on right item, don't toggle description return - if self.description: - self.description_visible = not self.description_visible - content_width = self.get_content_width(int(self._rect.width - ITEM_PADDING * 2)) + self._set_description_visible(not self.description_visible) + + def _set_description_visible(self, visible: bool): + if self.description and self.description_visible != visible: + self.description_visible = visible + # do callback first in case receiver changes description + if self.description_visible and self.description_opened_callback is not None: + self.description_opened_callback() + + content_width = int(self._rect.width - ITEM_PADDING * 2) self._rect.height = self.get_item_height(self._font, content_width) + def _update_state(self): + # Detect changes if description is callback + new_description = self.description + if new_description != self._prev_description: + self.set_description(new_description) + def _render(self, _): if not self.is_visible: return @@ -301,16 +359,16 @@ class ListItem(Widget): rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR) # Draw description if visible - current_description = self.get_description() - if self.description_visible and current_description and self._wrapped_description: - rl.draw_text_ex( - self._font, - self._wrapped_description, - rl.Vector2(text_x, self._rect.y + ITEM_DESC_V_OFFSET), - ITEM_DESC_FONT_SIZE, - 0, - ITEM_DESC_TEXT_COLOR, + if self.description_visible: + content_width = int(self._rect.width - ITEM_PADDING * 2) + description_height = self._html_renderer.get_total_height(content_width) + description_rect = rl.Rectangle( + self._rect.x + ITEM_PADDING, + self._rect.y + ITEM_DESC_V_OFFSET, + content_width, + description_height ) + self._html_renderer.render(description_rect) # Draw right item if present if self.action_item: @@ -321,43 +379,44 @@ class ListItem(Widget): if self.callback: self.callback() - def get_description(self): - return _resolve_value(self.description, None) + def set_icon(self, icon: str | None): + self.icon = icon + self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) if self.icon else None + + def set_description(self, description: str | Callable[[], str] | None): + self._description = description + new_desc = self.description + self._html_renderer.parse_html_content(new_desc) + self._prev_description = new_desc + + @property + def description(self): + return _resolve_value(self._description, "") def get_item_height(self, font: rl.Font, max_width: int) -> float: if not self.is_visible: return 0 - current_description = self.get_description() - if self.description_visible and current_description: - if ( - not self._wrapped_description - or current_description != self._prev_description - or max_width != self._prev_max_width - ): - self._prev_max_width = max_width - self._prev_description = current_description - - wrapped_lines = wrap_text(font, current_description, ITEM_DESC_FONT_SIZE, max_width) - self._wrapped_description = "\n".join(wrapped_lines) - self._description_height = len(wrapped_lines) * ITEM_DESC_FONT_SIZE + 10 - return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING - return ITEM_BASE_HEIGHT - - def get_content_width(self, total_width: int) -> int: - if self.action_item and self.action_item.rect.width > 0: - return total_width - int(self.action_item.rect.width) - RIGHT_ITEM_PADDING - return total_width + height = float(ITEM_BASE_HEIGHT) + if self.description_visible: + description_height = self._html_renderer.get_total_height(max_width) + height += description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING + return height def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: if not self.action_item: return rl.Rectangle(0, 0, 0, 0) - right_width = self.action_item.rect.width + right_width = self.action_item.get_width_hint() if right_width == 0: # Full width action (like DualButtonAction) return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y, item_rect.width - (ITEM_PADDING * 2), ITEM_BASE_HEIGHT) + # Clip width to available space, never overlapping this Item's title + content_width = item_rect.width - (ITEM_PADDING * 2) + title_width = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE).x + right_width = min(content_width - title_width, right_width) + right_x = item_rect.x + item_rect.width - right_width right_y = item_rect.y return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT) @@ -370,8 +429,8 @@ def simple_item(title: str, callback: Callable | None = None) -> ListItem: def toggle_item(title: str, description: str | Callable[[], str] | None = None, initial_state: bool = False, callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True) -> ListItem: - action = ToggleAction(initial_state=initial_state, enabled=enabled) - return ListItem(title=title, description=description, action_item=action, icon=icon, callback=callback) + action = ToggleAction(initial_state=initial_state, enabled=enabled, callback=callback) + return ListItem(title=title, description=description, action_item=action, icon=icon) def button_item(title: str, button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None, @@ -382,7 +441,7 @@ def button_item(title: str, button_text: str | Callable[[], str], description: s def text_item(title: str, value: str | Callable[[], str], description: str | Callable[[], str] | None = None, callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem: - action = TextAction(text=value, color=rl.Color(170, 170, 170, 255), enabled=enabled) + action = TextAction(text=value, color=ITEM_TEXT_VALUE_COLOR, enabled=enabled) return ListItem(title=title, description=description, action_item=action, callback=callback) diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index 986d7158de..a59030363b 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -3,20 +3,27 @@ from functools import partial from typing import cast import pyray as rl -from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.common.params import Params from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import ButtonStyle, Button from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog from openpilot.system.ui.widgets.keyboard import Keyboard -from openpilot.system.ui.widgets.label import TextAlignment, gui_label +from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.scroller import Scroller from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item -from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.selfdrive.ui.lib.prime_state import PrimeType + +# These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI +try: + from openpilot.common.params import Params + from openpilot.selfdrive.ui.ui_state import ui_state + from openpilot.selfdrive.ui.lib.prime_state import PrimeType +except Exception: + Params = None + ui_state = None # type: ignore + PrimeType = None # type: ignore NM_DEVICE_STATE_NEED_AUTH = 60 MIN_PASSWORD_LENGTH = 8 @@ -50,18 +57,6 @@ class NavButton(Widget): super().__init__() self.text = text self.set_rect(rl.Rectangle(0, 0, 400, 100)) - self._x_pos_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False) - self._y_pos_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False) - - def set_position(self, x: float, y: float) -> None: - self._x_pos_filter.update_dt(1 / gui_app.target_fps) - self._y_pos_filter.update_dt(1 / gui_app.target_fps) - x = self._x_pos_filter.update(x) - y = self._y_pos_filter.update(y) - changed = (self._rect.x != x or self._rect.y != y) - self._rect.x, self._rect.y = x, y - if changed: - self._update_layout_rects() def _render(self, _): color = rl.Color(74, 74, 74, 255) if self.is_pressed else rl.Color(57, 57, 57, 255) @@ -76,12 +71,9 @@ class NetworkUI(Widget): self._current_panel: PanelType = PanelType.WIFI self._wifi_panel = WifiManagerUI(wifi_manager) self._advanced_panel = AdvancedNetworkSettings(wifi_manager) - self._nav_button = NavButton("Advanced") + self._nav_button = NavButton(tr("Advanced")) self._nav_button.set_click_callback(self._cycle_panel) - def _update_state(self): - self._wifi_manager.process_callbacks() - def show_event(self): self._set_current_panel(PanelType.WIFI) self._wifi_panel.show_event() @@ -97,15 +89,15 @@ class NetworkUI(Widget): def _render(self, _): # subtract button - content_rect = rl.Rectangle(self._rect.x, self._rect.y + self._nav_button.rect.height + 20, - self._rect.width, self._rect.height - self._nav_button.rect.height - 20) + content_rect = rl.Rectangle(self._rect.x, self._rect.y + self._nav_button.rect.height + 40, + self._rect.width, self._rect.height - self._nav_button.rect.height - 40) if self._current_panel == PanelType.WIFI: - self._nav_button.text = "Advanced" - self._nav_button.set_position(self._rect.x + self._rect.width - self._nav_button.rect.width, self._rect.y + 10) + self._nav_button.text = tr("Advanced") + self._nav_button.set_position(self._rect.x + self._rect.width - self._nav_button.rect.width, self._rect.y + 20) self._wifi_panel.render(content_rect) else: - self._nav_button.text = "Back" - self._nav_button.set_position(self._rect.x, self._rect.y + 10) + self._nav_button.text = tr("Back") + self._nav_button.set_position(self._rect.x, self._rect.y + 20) self._advanced_panel.render(content_rect) self._nav_button.render() @@ -125,40 +117,40 @@ class AdvancedNetworkSettings(Widget): # Tethering self._tethering_action = ToggleAction(initial_state=False) - tethering_btn = ListItem(title="Enable Tethering", action_item=self._tethering_action, callback=self._toggle_tethering) + tethering_btn = ListItem(title=tr("Enable Tethering"), action_item=self._tethering_action, callback=self._toggle_tethering) # Edit tethering password - self._tethering_password_action = ButtonAction(text="EDIT") - tethering_password_btn = ListItem(title="Tethering Password", action_item=self._tethering_password_action, callback=self._edit_tethering_password) + self._tethering_password_action = ButtonAction(text=tr("EDIT")) + tethering_password_btn = ListItem(title=tr("Tethering Password"), action_item=self._tethering_password_action, callback=self._edit_tethering_password) # Roaming toggle roaming_enabled = self._params.get_bool("GsmRoaming") self._roaming_action = ToggleAction(initial_state=roaming_enabled) - self._roaming_btn = ListItem(title="Enable Roaming", action_item=self._roaming_action, callback=self._toggle_roaming) + self._roaming_btn = ListItem(title=tr("Enable Roaming"), action_item=self._roaming_action, callback=self._toggle_roaming) # Cellular metered toggle cellular_metered = self._params.get_bool("GsmMetered") self._cellular_metered_action = ToggleAction(initial_state=cellular_metered) - self._cellular_metered_btn = ListItem(title="Cellular Metered", description="Prevent large data uploads when on a metered cellular connection", + self._cellular_metered_btn = ListItem(title=tr("Cellular Metered"), description=tr("Prevent large data uploads when on a metered cellular connection"), action_item=self._cellular_metered_action, callback=self._toggle_cellular_metered) # APN setting - self._apn_btn = button_item("APN Setting", "EDIT", callback=self._edit_apn) + self._apn_btn = button_item(tr("APN Setting"), tr("EDIT"), callback=self._edit_apn) # Wi-Fi metered toggle - self._wifi_metered_action = MultipleButtonAction(["default", "metered", "unmetered"], 255, 0, callback=self._toggle_wifi_metered) - wifi_metered_btn = ListItem(title="Wi-Fi Network Metered", description="Prevent large data uploads when on a metered Wi-Fi connection", + self._wifi_metered_action = MultipleButtonAction([tr("default"), tr("metered"), tr("unmetered")], 255, 0, callback=self._toggle_wifi_metered) + wifi_metered_btn = ListItem(title=tr("Wi-Fi Network Metered"), description=tr("Prevent large data uploads when on a metered Wi-Fi connection"), action_item=self._wifi_metered_action) items: list[Widget] = [ tethering_btn, tethering_password_btn, - text_item("IP Address", lambda: self._wifi_manager.ipv4_address), + text_item(tr("IP Address"), lambda: self._wifi_manager.ipv4_address), self._roaming_btn, self._apn_btn, self._cellular_metered_btn, wifi_metered_btn, - button_item("Hidden Network", "CONNECT", callback=self._connect_to_hidden_network), + button_item(tr("Hidden Network"), tr("CONNECT"), callback=self._connect_to_hidden_network), ] self._scroller = Scroller(items, line_separator=True, spacing=0) @@ -207,7 +199,7 @@ class AdvancedNetworkSettings(Widget): current_apn = self._params.get("GsmApn") or "" self._keyboard.reset(min_text_size=0) - self._keyboard.set_title("Enter APN", "leave blank for automatic configuration") + self._keyboard.set_title(tr("Enter APN"), tr("leave blank for automatic configuration")) self._keyboard.set_text(current_apn) gui_app.set_modal_overlay(self._keyboard, update_apn) @@ -240,11 +232,11 @@ class AdvancedNetworkSettings(Widget): self._wifi_manager.connect_to_network(ssid, password, hidden=True) self._keyboard.reset(min_text_size=0) - self._keyboard.set_title("Enter password", f"for \"{ssid}\"") + self._keyboard.set_title(tr("Enter password"), tr("for \"{}\"").format(ssid)) gui_app.set_modal_overlay(self._keyboard, enter_password) self._keyboard.reset(min_text_size=1) - self._keyboard.set_title("Enter SSID", "") + self._keyboard.set_title(tr("Enter SSID"), "") gui_app.set_modal_overlay(self._keyboard, connect_hidden) def _edit_tethering_password(self): @@ -257,11 +249,13 @@ class AdvancedNetworkSettings(Widget): self._tethering_password_action.set_enabled(False) self._keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH) - self._keyboard.set_title("Enter new tethering password", "") + self._keyboard.set_title(tr("Enter new tethering password"), "") self._keyboard.set_text(self._wifi_manager.tethering_password) gui_app.set_modal_overlay(self._keyboard, update_password) def _update_state(self): + self._wifi_manager.process_callbacks() + # If not using prime SIM, show GSM settings and enable IPv4 forwarding show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE) self._wifi_manager.set_ipv4_forward(show_cell_settings) @@ -288,7 +282,7 @@ class WifiManagerUI(Widget): self._networks: list[Network] = [] self._networks_buttons: dict[str, Button] = {} self._forget_networks_buttons: dict[str, Button] = {} - self._confirm_dialog = ConfirmDialog("", "Forget", "Cancel") + self._confirm_dialog = ConfirmDialog("", tr("Forget"), tr("Cancel")) self._wifi_manager.set_callbacks(need_auth=self._on_need_auth, activated=self._on_activated, @@ -307,17 +301,20 @@ class WifiManagerUI(Widget): for icon in STRENGTH_ICONS + ["icons/checkmark.png", "icons/circled_slash.png", "icons/lock_closed.png"]: gui_app.texture(icon, ICON_SIZE, ICON_SIZE) + def _update_state(self): + self._wifi_manager.process_callbacks() + def _render(self, rect: rl.Rectangle): if not self._networks: - gui_label(rect, "Scanning Wi-Fi networks...", 72, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + gui_label(rect, tr("Scanning Wi-Fi networks..."), 72, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) return if self.state == UIState.NEEDS_AUTH and self._state_network: - self.keyboard.set_title("Wrong password" if self._password_retry else "Enter password", f"for {self._state_network.ssid}") + self.keyboard.set_title(tr("Wrong password") if self._password_retry else tr("Enter password"), tr("for \"{}\"").format(self._state_network.ssid)) self.keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH) gui_app.set_modal_overlay(self.keyboard, lambda result: self._on_password_entered(cast(Network, self._state_network), result)) elif self.state == UIState.SHOW_FORGET_CONFIRM and self._state_network: - self._confirm_dialog.set_text(f'Forget Wi-Fi Network "{self._state_network.ssid}"?') + self._confirm_dialog.set_text(tr("Forget Wi-Fi Network \"{}\"?").format(self._state_network.ssid)) self._confirm_dialog.reset() gui_app.set_modal_overlay(self._confirm_dialog, callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result)) else: @@ -341,24 +338,23 @@ class WifiManagerUI(Widget): def _draw_network_list(self, rect: rl.Rectangle): content_rect = rl.Rectangle(rect.x, rect.y, rect.width, len(self._networks) * ITEM_HEIGHT) - offset = self.scroll_panel.handle_scroll(rect, content_rect) - clicked = self.scroll_panel.is_touch_valid() and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) + offset = self.scroll_panel.update(rect, content_rect) rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) for i, network in enumerate(self._networks): - y_offset = rect.y + i * ITEM_HEIGHT + offset.y + y_offset = rect.y + i * ITEM_HEIGHT + offset item_rect = rl.Rectangle(rect.x, y_offset, rect.width, ITEM_HEIGHT) if not rl.check_collision_recs(item_rect, rect): continue - self._draw_network_item(item_rect, network, clicked) + self._draw_network_item(item_rect, network) if i < len(self._networks) - 1: line_y = int(item_rect.y + item_rect.height - 1) rl.draw_line(int(item_rect.x), int(line_y), int(item_rect.x + item_rect.width), line_y, rl.LIGHTGRAY) rl.end_scissor_mode() - def _draw_network_item(self, rect, network: Network, clicked: bool): + def _draw_network_item(self, rect, network: Network): spacing = 50 ssid_rect = rl.Rectangle(rect.x, rect.y, rect.width - self.btn_width * 2, ITEM_HEIGHT) signal_icon_rect = rl.Rectangle(rect.x + rect.width - ICON_SIZE, rect.y + (ITEM_HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE) @@ -368,11 +364,11 @@ class WifiManagerUI(Widget): if self.state == UIState.CONNECTING and self._state_network: if self._state_network.ssid == network.ssid: self._networks_buttons[network.ssid].set_enabled(False) - status_text = "CONNECTING..." + status_text = tr("CONNECTING...") elif self.state == UIState.FORGETTING and self._state_network: if self._state_network.ssid == network.ssid: self._networks_buttons[network.ssid].set_enabled(False) - status_text = "FORGETTING..." + status_text = tr("FORGETTING...") elif network.security_type == SecurityType.UNSUPPORTED: self._networks_buttons[network.ssid].set_enabled(False) else: @@ -398,18 +394,16 @@ class WifiManagerUI(Widget): self._draw_signal_strength_icon(signal_icon_rect, network) def _networks_buttons_callback(self, network): - if self.scroll_panel.is_touch_valid(): - if not network.is_saved and network.security_type != SecurityType.OPEN: - self.state = UIState.NEEDS_AUTH - self._state_network = network - self._password_retry = False - elif not network.is_connected: - self.connect_to_network(network) + if not network.is_saved and network.security_type != SecurityType.OPEN: + self.state = UIState.NEEDS_AUTH + self._state_network = network + self._password_retry = False + elif not network.is_connected: + self.connect_to_network(network) def _forget_networks_buttons_callback(self, network): - if self.scroll_panel.is_touch_valid(): - self.state = UIState.SHOW_FORGET_CONFIRM - self._state_network = network + self.state = UIState.SHOW_FORGET_CONFIRM + self._state_network = network def _draw_status_icon(self, rect, network: Network): """Draw the status icon based on network's connection state""" @@ -449,10 +443,12 @@ class WifiManagerUI(Widget): def _on_network_updated(self, networks: list[Network]): self._networks = networks for n in self._networks: - self._networks_buttons[n.ssid] = Button(n.ssid, partial(self._networks_buttons_callback, n), font_size=55, text_alignment=TextAlignment.LEFT, - button_style=ButtonStyle.TRANSPARENT_WHITE) - self._forget_networks_buttons[n.ssid] = Button("Forget", partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI, + self._networks_buttons[n.ssid] = Button(n.ssid, partial(self._networks_buttons_callback, n), font_size=55, + text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT) + self._networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid()) + self._forget_networks_buttons[n.ssid] = Button(tr("Forget"), partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI, font_size=45) + self._forget_networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid()) def _on_need_auth(self, ssid): network = next((n for n in self._networks if n.ssid == ssid), None) diff --git a/system/ui/widgets/option_dialog.py b/system/ui/widgets/option_dialog.py index 8f33124b5c..8c63ca3f9f 100644 --- a/system/ui/widgets/option_dialog.py +++ b/system/ui/widgets/option_dialog.py @@ -1,9 +1,10 @@ import pyray as rl from openpilot.system.ui.lib.application import FontWeight -from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel -from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.button import gui_button, ButtonStyle, TextAlignment +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import Widget, DialogResult +from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.label import gui_label +from openpilot.system.ui.widgets.scroller import Scroller # Constants MARGIN = 50 @@ -22,7 +23,22 @@ class MultiOptionDialog(Widget): self.options = options self.current = current self.selection = current - self.scroll = GuiScrollPanel() + self._result: DialogResult = DialogResult.NO_ACTION + + # Create scroller with option buttons + self.option_buttons = [Button(option, click_callback=lambda opt=option: self._on_option_clicked(opt), + text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.NORMAL, + text_padding=50) for option in options] + self.scroller = Scroller(self.option_buttons, spacing=LIST_ITEM_SPACING) + + self.cancel_button = Button(tr("Cancel"), click_callback=lambda: self._set_result(DialogResult.CANCEL)) + self.select_button = Button(tr("Select"), click_callback=lambda: self._set_result(DialogResult.CONFIRM), button_style=ButtonStyle.PRIMARY) + + def _set_result(self, result: DialogResult): + self._result = result + + def _on_option_clicked(self, option): + self.selection = option def _render(self, rect): dialog_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, rect.height - 2 * MARGIN) @@ -36,36 +52,26 @@ class MultiOptionDialog(Widget): # Options area options_y = content_rect.y + TITLE_FONT_SIZE + ITEM_SPACING options_h = content_rect.height - TITLE_FONT_SIZE - BUTTON_HEIGHT - 2 * ITEM_SPACING - view_rect = rl.Rectangle(content_rect.x, options_y, content_rect.width, options_h) - content_h = len(self.options) * (ITEM_HEIGHT + LIST_ITEM_SPACING) - list_content_rect = rl.Rectangle(content_rect.x, options_y, content_rect.width, content_h) - - # Scroll and render options - offset = self.scroll.handle_scroll(view_rect, list_content_rect) - valid_click = self.scroll.is_touch_valid() and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) + options_rect = rl.Rectangle(content_rect.x, options_y, content_rect.width, options_h) - rl.begin_scissor_mode(int(view_rect.x), int(options_y), int(view_rect.width), int(options_h)) + # Update button styles and set width based on selection for i, option in enumerate(self.options): - item_y = options_y + i * (ITEM_HEIGHT + LIST_ITEM_SPACING) + offset.y - item_rect = rl.Rectangle(view_rect.x, item_y, view_rect.width, ITEM_HEIGHT) - - if rl.check_collision_recs(item_rect, view_rect): - selected = option == self.selection - style = ButtonStyle.PRIMARY if selected else ButtonStyle.NORMAL + selected = option == self.selection + button = self.option_buttons[i] + button.set_button_style(ButtonStyle.PRIMARY if selected else ButtonStyle.NORMAL) + button.set_rect(rl.Rectangle(0, 0, options_rect.width, ITEM_HEIGHT)) - if gui_button(item_rect, option, button_style=style, text_alignment=TextAlignment.LEFT) and valid_click: - self.selection = option - rl.end_scissor_mode() + self.scroller.render(options_rect) # Buttons button_y = content_rect.y + content_rect.height - BUTTON_HEIGHT button_w = (content_rect.width - BUTTON_SPACING) / 2 - if gui_button(rl.Rectangle(content_rect.x, button_y, button_w, BUTTON_HEIGHT), "Cancel"): - return 0 + cancel_rect = rl.Rectangle(content_rect.x, button_y, button_w, BUTTON_HEIGHT) + self.cancel_button.render(cancel_rect) - if gui_button(rl.Rectangle(content_rect.x + button_w + BUTTON_SPACING, button_y, button_w, BUTTON_HEIGHT), - "Select", is_enabled=self.selection != self.current, button_style=ButtonStyle.PRIMARY): - return 1 + select_rect = rl.Rectangle(content_rect.x + button_w + BUTTON_SPACING, button_y, button_w, BUTTON_HEIGHT) + self.select_button.set_enabled(self.selection != self.current) + self.select_button.render(select_rect) - return -1 + return self._result diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index 757500a71c..f19a6fbfdb 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -52,7 +52,7 @@ class Scroller(Widget): content_height = sum(item.rect.height for item in visible_items) + self._spacing * (len(visible_items)) if not self._pad_end: content_height -= self._spacing - scroll = self.scroll_panel.handle_scroll(self._rect, rl.Rectangle(0, 0, self._rect.width, content_height)) + scroll = self.scroll_panel.update(self._rect, rl.Rectangle(0, 0, self._rect.width, content_height)) rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y), int(self._rect.width), int(self._rect.height)) @@ -68,8 +68,7 @@ class Scroller(Widget): cur_height += item.rect.height + self._spacing * (idx != 0) # Consider scroll - x += scroll.x - y += scroll.y + y += scroll # Update item state item.set_position(x, y) @@ -77,3 +76,15 @@ class Scroller(Widget): item.render() rl.end_scissor_mode() + + def show_event(self): + super().show_event() + # Reset to top + self.scroll_panel.set_offset(0) + for item in self._items: + item.show_event() + + def hide_event(self): + super().hide_event() + for item in self._items: + item.hide_event() diff --git a/system/ui/widgets/toggle.py b/system/ui/widgets/toggle.py index 968afda9c8..0fbf3c844a 100644 --- a/system/ui/widgets/toggle.py +++ b/system/ui/widgets/toggle.py @@ -1,4 +1,5 @@ import pyray as rl +from collections.abc import Callable from openpilot.system.ui.lib.application import MousePos from openpilot.system.ui.widgets import Widget @@ -14,9 +15,10 @@ ANIMATION_SPEED = 8.0 class Toggle(Widget): - def __init__(self, initial_state=False): + def __init__(self, initial_state: bool = False, callback: Callable[[bool], None] | None = None): super().__init__() self._state = initial_state + self._callback = callback self._enabled = True self._progress = 1.0 if initial_state else 0.0 self._target = self._progress @@ -32,8 +34,10 @@ class Toggle(Widget): self._clicked = True self._state = not self._state self._target = 1.0 if self._state else 0.0 + if self._callback: + self._callback(self._state) - def get_state(self): + def get_state(self) -> bool: return self._state def set_state(self, state: bool): diff --git a/system/version.py b/system/version.py index 9c5a8348f9..f59509715f 100755 --- a/system/version.py +++ b/system/version.py @@ -10,8 +10,8 @@ from openpilot.common.basedir import BASEDIR from openpilot.common.swaglog import cloudlog from openpilot.common.git import get_commit, get_origin, get_branch, get_short_branch, get_commit_date -RELEASE_BRANCHES = ['release3-staging', 'release3', 'release-tici', 'release-tizi', 'nightly'] -TESTED_BRANCHES = RELEASE_BRANCHES + ['devel', 'devel-staging', 'nightly-dev'] +RELEASE_BRANCHES = ['release-tizi-staging', 'release-tici', 'release-tizi', 'nightly'] +TESTED_BRANCHES = RELEASE_BRANCHES + ['devel-staging', 'nightly-dev'] BUILD_METADATA_FILENAME = "build.json" diff --git a/system/webrtc/device/video.py b/system/webrtc/device/video.py index 1bca909294..50feab4f4a 100644 --- a/system/webrtc/device/video.py +++ b/system/webrtc/device/video.py @@ -1,4 +1,5 @@ import asyncio +import time import av from teleoprtc.tracks import TiciVideoStreamTrack @@ -20,6 +21,7 @@ class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): self._sock = messaging.sub_sock(self.camera_to_sock_mapping[camera_type], conflate=True) self._pts = 0 + self._t0_ns = time.monotonic_ns() async def recv(self): while True: @@ -32,10 +34,10 @@ class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): packet = av.Packet(evta.header + evta.data) packet.time_base = self._time_base - packet.pts = self._pts - self.log_debug("track sending frame %s", self._pts) - self._pts += self._dt * self._clock_rate + self._pts = ((time.monotonic_ns() - self._t0_ns) * self._clock_rate) // 1_000_000_000 + packet.pts = self._pts + self.log_debug("track sending frame %d", self._pts) return packet diff --git a/system/webrtc/tests/test_stream_session.py b/system/webrtc/tests/test_stream_session.py index 113fa5e7e6..e31fda3728 100644 --- a/system/webrtc/tests/test_stream_session.py +++ b/system/webrtc/tests/test_stream_session.py @@ -1,5 +1,6 @@ import asyncio import json +import time # for aiortc and its dependencies import warnings warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -14,7 +15,6 @@ from cereal import messaging, log from openpilot.system.webrtc.webrtcd import CerealOutgoingMessageProxy, CerealIncomingMessageProxy from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack from openpilot.system.webrtc.device.audio import AudioInputStreamTrack -from openpilot.common.realtime import DT_DMON class TestStreamSession: @@ -81,7 +81,10 @@ class TestStreamSession: for i in range(5): packet = self.loop.run_until_complete(track.recv()) assert packet.time_base == VIDEO_TIME_BASE - assert packet.pts == int(i * DT_DMON * VIDEO_CLOCK_RATE) + if i == 0: + start_ns = time.monotonic_ns() + start_pts = packet.pts + assert abs(i + packet.pts - (start_pts + (((time.monotonic_ns() - start_ns) * VIDEO_CLOCK_RATE) // 1_000_000_000))) < 450 #5ms assert packet.size == 0 def test_input_audio_track(self, mocker): diff --git a/system/webrtc/webrtcd.py b/system/webrtc/webrtcd.py index fb93e565ff..c19f1bf9dd 100755 --- a/system/webrtc/webrtcd.py +++ b/system/webrtc/webrtcd.py @@ -239,6 +239,20 @@ async def get_schema(request: 'web.Request'): schema_dict = {s: generate_field(log.Event.schema.fields[s]) for s in services} return web.json_response(schema_dict) +async def post_notify(request: 'web.Request'): + try: + payload = await request.json() + except Exception as e: + raise web.HTTPBadRequest(text="Invalid JSON") from e + + for session in list(request.app.get('streams', {}).values()): + try: + ch = session.stream.get_messaging_channel() + ch.send(json.dumps(payload)) + except Exception: + continue + + return web.Response(status=200, text="OK") async def on_shutdown(app: 'web.Application'): for session in app['streams'].values(): @@ -258,6 +272,7 @@ def webrtcd_thread(host: str, port: int, debug: bool): app['debug'] = debug app.on_shutdown.append(on_shutdown) app.router.add_post("/stream", get_stream) + app.router.add_post("/notify", post_notify) app.router.add_get("/schema", get_schema) web.run_app(app, host=host, port=port) diff --git a/third_party/raylib/build.sh b/third_party/raylib/build.sh index 544feb1c59..0b50244482 100755 --- a/third_party/raylib/build.sh +++ b/third_party/raylib/build.sh @@ -30,7 +30,7 @@ fi cd raylib_repo -COMMIT=${1:-39e6d8b52db159ba2ab3214b46d89a8069e09394} +COMMIT=${1:-aa6ade09ac4bfb2847a356535f2d9f87e49ab089} git fetch origin $COMMIT git reset --hard $COMMIT git clean -xdff . @@ -57,7 +57,7 @@ if [ -f /TICI ]; then cd raylib_python_repo - BINDINGS_COMMIT="ef8141c7979d5fa630ef4108605fc221f07d8cb7" + BINDINGS_COMMIT="ab0191f445272ca66758f9dd345b7395518d6a77" git fetch origin $BINDINGS_COMMIT git reset --hard $BINDINGS_COMMIT git clean -xdff . diff --git a/third_party/raylib/larch64/libraylib.a b/third_party/raylib/larch64/libraylib.a index e1c5c19307..954aa0d486 100644 --- a/third_party/raylib/larch64/libraylib.a +++ b/third_party/raylib/larch64/libraylib.a @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c3125236db11e7bebcc6ad5868444ed0605c6343f98b212d39267c092b3b481 -size 3140628 +oid sha256:8bb734fb8733e1762081945f4f3ddf8d3f7379a0ea4790ee925803cd00129ccb +size 3156548 diff --git a/tools/op.sh b/tools/op.sh index ae12809eb9..8b5062ad9b 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -284,7 +284,7 @@ function op_venv() { function op_adb() { op_before_cmd - op_run_command tools/scripts/adb_ssh.sh + op_run_command tools/scripts/adb_ssh.sh "$@" } function op_ssh() { diff --git a/tools/scripts/adb_ssh.sh b/tools/scripts/adb_ssh.sh index 43c8e07de6..2fe2873a3d 100755 --- a/tools/scripts/adb_ssh.sh +++ b/tools/scripts/adb_ssh.sh @@ -4,4 +4,4 @@ set -e # this is a little nicer than "adb shell" since # "adb shell" doesn't do full terminal emulation adb forward tcp:2222 tcp:22 -ssh comma@localhost -p 2222 +ssh comma@localhost -p 2222 "$@" diff --git a/uv.lock b/uv.lock index 9c791b8110..c34a6d9b71 100644 --- a/uv.lock +++ b/uv.lock @@ -639,10 +639,10 @@ name = "gymnasium" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cloudpickle" }, - { name = "farama-notifications" }, - { name = "numpy" }, - { name = "typing-extensions" }, + { name = "cloudpickle", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "farama-notifications", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fd/17/c2a0e15c2cd5a8e788389b280996db927b923410de676ec5c7b2695e9261/gymnasium-1.2.0.tar.gz", hash = "sha256:344e87561012558f603880baf264ebc97f8a5c997a957b0c9f910281145534b0", size = 821142, upload-time = "2025-06-27T08:21:20.262Z" } wheels = [ @@ -844,6 +844,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, ] +[[package]] +name = "mapbox-earcut" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/70/0a322197c1178f47941e5e6e13b0a4adeaaa7c465c18e3b4ead3eba49860/mapbox_earcut-1.0.3.tar.gz", hash = "sha256:b6bac5d519d9947a6321a699c15d58e0b5740da61b9210ed229e05ad207c1c04", size = 24029, upload-time = "2024-12-25T12:49:09.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/d7/b37a45c248100e7285a40de87a8b1808ca4ca10228e265f2d0c320702d96/mapbox_earcut-1.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbf24029e7447eb0351000f4fd3185327a00dac5ed756b07330b0bdaed6932db", size = 71057, upload-time = "2024-12-25T12:48:09.131Z" }, + { url = "https://files.pythonhosted.org/packages/1b/df/2b63eb0d3a24e14f67adc816de18c2e09f3eb0997c512ace84dd59c3ed96/mapbox_earcut-1.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:998e2f1e3769538f7656a34296d08a37cb71ce57aa8cf4387572bc00029b52ce", size = 65300, upload-time = "2024-12-25T12:48:11.677Z" }, + { url = "https://files.pythonhosted.org/packages/87/37/9dd9575f5c00e35d480e7150e5bb315a35d9cf5642bfb75ca628a31e1341/mapbox_earcut-1.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df2382d84d6d168f73479673d297753e37440772f233cc03ebb54d150e37b174", size = 96965, upload-time = "2024-12-25T12:48:12.968Z" }, + { url = "https://files.pythonhosted.org/packages/3b/91/5708233941b5bf73149ba35f7aa32c6ee2cf4a33cd33069e7dba69d4129f/mapbox_earcut-1.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ccddb4bb04f11beab62943eb5a1bcd52c5a71d236bfce0ecc03e45e97fdb24b", size = 1070953, upload-time = "2024-12-25T12:48:15.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fe/b35b999ba786aa17ddc47bc04231de076665eb511e1cd58cf6fef3581172/mapbox_earcut-1.0.3-cp311-cp311-win32.whl", hash = "sha256:f19b2bcf6475bc591f48437d3214691a6730f39b1f6dfd7505b69c4345485b0c", size = 65245, upload-time = "2024-12-25T12:48:17.826Z" }, + { url = "https://files.pythonhosted.org/packages/11/81/18ac08b0bb0c22dd9028c7ecb31ae4086d31128b13fb3903e717331072ac/mapbox_earcut-1.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:811a64ad5e6ecf09b96af533e5c169299ba173e53eb4ff0209de1adcfae314be", size = 72356, upload-time = "2024-12-25T12:48:20.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/7c/707a4ce96e078f7d382cc32b4a6c2326eca68d77ead5e990f5f940d16140/mapbox_earcut-1.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5be71b7ec2180a27ce1178d53933430a3292b6ac3f94f2144513ee51d9034007", size = 70333, upload-time = "2024-12-25T12:48:22.565Z" }, + { url = "https://files.pythonhosted.org/packages/fb/47/ba2a14732f6e197b0ed879a1992b4d85054294b23627ad681b4fb1251d16/mapbox_earcut-1.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eb874f7562a49ae0fb7bd47bcc9b4854cc53e3e4f7f26674f02f3cadb006ce16", size = 64697, upload-time = "2024-12-25T12:48:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/e7/68/59a514811da76c3c801207bd6d7094ea5ba75648c2e7f15d4cb98b08216f/mapbox_earcut-1.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73b9f06f2f8a795d835342aa80e021cfceda78fdca7bc07dc1a0b4aca90239f3", size = 96182, upload-time = "2024-12-25T12:48:26.316Z" }, + { url = "https://files.pythonhosted.org/packages/3f/79/97bf509ade0f9aeb5b5f94b1aff86393c2f584379a80e392fdfcbea434ae/mapbox_earcut-1.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdc55574ef7b613004874a459d2d59c07e1ef45cebb83f86c4958f7d3e2d6069", size = 1070584, upload-time = "2024-12-25T12:48:29.065Z" }, + { url = "https://files.pythonhosted.org/packages/de/7a/5a6e205bab9ff49d1dae392f6179a444f820880d8985f26080816fa6c7ba/mapbox_earcut-1.0.3-cp312-cp312-win32.whl", hash = "sha256:790f52c67a0bd81032eaf61ebc181b1825b8b6daf01cb69e9eaa38521dd07aeb", size = 65375, upload-time = "2024-12-25T12:48:30.618Z" }, + { url = "https://files.pythonhosted.org/packages/7a/59/674a67f92772563d5a943ce2c4ed834ed341e3a0fd77b8eb4b79057f5193/mapbox_earcut-1.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:cc1bbf35be0d9853dd448374330684ddbd0112497dee7d21b7417b0ab6236ac7", size = 72575, upload-time = "2024-12-25T12:48:33.544Z" }, +] + [[package]] name = "markdown" version = "3.9" @@ -931,25 +954,25 @@ name = "metadrive-simulator" version = "0.4.2.4" source = { url = "https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl" } dependencies = [ - { name = "filelock" }, - { name = "gymnasium" }, - { name = "lxml" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "opencv-python-headless" }, - { name = "panda3d" }, - { name = "panda3d-gltf" }, - { name = "pillow" }, - { name = "progressbar" }, - { name = "psutil" }, - { name = "pygments" }, - { name = "requests" }, - { name = "shapely" }, - { name = "tqdm" }, - { name = "yapf" }, + { name = "filelock", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "gymnasium", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "lxml", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "matplotlib", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "opencv-python-headless", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "panda3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "panda3d-gltf", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "pillow", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "progressbar", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "psutil", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "pygments", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "requests", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "shapely", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "tqdm", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "yapf", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] wheels = [ - { url = "https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl", hash = "sha256:fbf0ea9be67e65cd45d38ff930e3d49f705dd76c9ddbd1e1482e3f87b61efcef" }, + { url = "https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl", hash = "sha256:d0afaf3b005e35e14b929d5491d2d5b64562d0c1cd5093ba969fb63908670dd4" }, ] [package.metadata] @@ -1270,6 +1293,7 @@ dependencies = [ { name = "json-rpc" }, { name = "kaitaistruct" }, { name = "libusb1" }, + { name = "mapbox-earcut" }, { name = "numpy" }, { name = "onnx" }, { name = "psutil" }, @@ -1281,6 +1305,7 @@ dependencies = [ { name = "pyserial" }, { name = "pyzmq" }, { name = "qrcode" }, + { name = "raylib" }, { name = "requests" }, { name = "scons" }, { name = "sentry-sdk" }, @@ -1313,7 +1338,6 @@ dev = [ { name = "pyprof2calltree" }, { name = "pytools", marker = "platform_machine != 'aarch64'" }, { name = "pywinctl" }, - { name = "raylib" }, { name = "tabulate" }, { name = "types-requests" }, { name = "types-tabulate" }, @@ -1367,6 +1391,7 @@ requires-dist = [ { name = "json-rpc" }, { name = "kaitaistruct" }, { name = "libusb1" }, + { name = "mapbox-earcut" }, { name = "matplotlib", marker = "extra == 'dev'" }, { name = "metadrive-simulator", marker = "platform_machine != 'aarch64' and extra == 'tools'", url = "https://github.com/commaai/metadrive/releases/download/MetaDrive-minimal-0.4.2.4/metadrive_simulator-0.4.2.4-py3-none-any.whl" }, { name = "mkdocs", marker = "extra == 'docs'" }, @@ -1401,7 +1426,7 @@ requires-dist = [ { name = "pywinctl", marker = "extra == 'dev'" }, { name = "pyzmq" }, { name = "qrcode" }, - { name = "raylib", marker = "extra == 'dev'", specifier = "<5.5.0.3" }, + { name = "raylib", specifier = "<5.5.0.3" }, { name = "requests" }, { name = "ruff", marker = "extra == 'testing'" }, { name = "scons" }, @@ -1454,8 +1479,8 @@ name = "panda3d-gltf" version = "0.13" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "panda3d" }, - { name = "panda3d-simplepbr" }, + { name = "panda3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "panda3d-simplepbr", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/07/7f/9f18fc3fa843a080acb891af6bcc12262e7bdf1d194a530f7042bebfc81f/panda3d-gltf-0.13.tar.gz", hash = "sha256:d06d373bdd91cf530909b669f43080e599463bbf6d3ef00c3558bad6c6b19675", size = 25573, upload-time = "2021-05-21T05:46:32.738Z" } wheels = [ @@ -1467,8 +1492,8 @@ name = "panda3d-simplepbr" version = "0.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "panda3d" }, - { name = "typing-extensions" }, + { name = "panda3d", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/be/c4d1ded04c22b357277cf6e6a44c1ab4abb285a700bd1991460460e05b99/panda3d_simplepbr-0.13.1.tar.gz", hash = "sha256:c83766d7c8f47499f365a07fe1dff078fc8b3054c2689bdc8dceabddfe7f1a35", size = 6216055, upload-time = "2025-03-30T16:57:41.087Z" } wheels = [ @@ -4205,9 +4230,9 @@ name = "pyopencl" version = "2025.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, - { name = "platformdirs" }, - { name = "pytools" }, + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "platformdirs", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "pytools", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/88/0ac460d3e2def08b2ad6345db6a13613815f616bbbd60c6f4bdf774f4c41/pyopencl-2025.1.tar.gz", hash = "sha256:0116736d7f7920f87b8db4b66a03f27b1d930d2e37ddd14518407cc22dd24779", size = 422510, upload-time = "2025-01-22T00:16:58.421Z" } wheels = [ @@ -4429,9 +4454,9 @@ name = "pytools" version = "2024.1.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "platformdirs" }, - { name = "siphash24" }, - { name = "typing-extensions" }, + { name = "platformdirs", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "siphash24", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "typing-extensions", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/56e109c0307f831b5d598ad73976aaaa84b4d0e98da29a642e797eaa940c/pytools-2024.1.10.tar.gz", hash = "sha256:9af6f4b045212c49be32bb31fe19606c478ee4b09631886d05a32459f4ce0a12", size = 81741, upload-time = "2024-07-17T18:47:38.287Z" } wheels = [ @@ -4754,7 +4779,7 @@ name = "shapely" version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ca/3c/2da625233f4e605155926566c0e7ea8dda361877f48e8b1655e53456f252/shapely-2.1.1.tar.gz", hash = "sha256:500621967f2ffe9642454808009044c21e5b35db89ce69f8a2042c2ffd0e2772", size = 315422, upload-time = "2025-05-19T11:04:41.265Z" } wheels = [ @@ -4983,7 +5008,7 @@ name = "yapf" version = "0.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "platformdirs" }, + { name = "platformdirs", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" } wheels = [