From a1795f80ddbcbc287587a51cae19068d32a670d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Thu, 13 Nov 2025 17:08:14 -0800 Subject: [PATCH 01/48] Latest tinygrad (#36615) * Latest tinygrad * jit batch size * bump again * limit upcast * latest tgf * upstream tg --- .gitmodules | 2 +- selfdrive/modeld/SConscript | 6 +++--- tinygrad_repo | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitmodules b/.gitmodules index 54c7393986..ad6530de9a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -15,4 +15,4 @@ url = ../../commaai/teleoprtc [submodule "tinygrad"] path = tinygrad_repo - url = https://github.com/commaai/tinygrad.git + url = https://github.com/tinygrad/tinygrad.git diff --git a/selfdrive/modeld/SConscript b/selfdrive/modeld/SConscript index f20855c2cb..ae549f3a76 100644 --- a/selfdrive/modeld/SConscript +++ b/selfdrive/modeld/SConscript @@ -50,9 +50,9 @@ def tg_compile(flags, model_name): # Compile small models for model_name in ['driving_vision', 'driving_policy', 'dmonitoring_model']: flags = { - 'larch64': 'DEV=QCOM', - 'Darwin': f'DEV=CPU HOME={os.path.expanduser("~")} IMAGE=0', # tinygrad calls brew which needs a $HOME in the env - }.get(arch, 'DEV=CPU CPU_LLVM=1 IMAGE=0') + 'larch64': 'DEV=QCOM FLOAT16=1 NOLOCALS=1 IMAGE=2 JIT_BATCH_SIZE=0', + 'Darwin': f'DEV=CPU HOME={os.path.expanduser("~")}', # tinygrad calls brew which needs a $HOME in the env + }.get(arch, 'DEV=CPU CPU_LLVM=1') tg_compile(flags, model_name) # Compile BIG model if USB GPU is available diff --git a/tinygrad_repo b/tinygrad_repo index 7296c74cbd..547304c471 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 7296c74cbd2666da7dce95d7ca6dab5340653a5c +Subproject commit 547304c471b26ada0b34f400ccba67f3e1eb5965 From b778da1d7c4ebcfaec7d57787efec064f53a211d Mon Sep 17 00:00:00 2001 From: ZwX1616 Date: Fri, 14 Nov 2025 14:29:04 -0800 Subject: [PATCH 02/48] dmonitoringmodeld: clean up data structures (#36624) * update onnx * get meta * start * cast * deprecate notready * more * line too long * 2 --- cereal/log.capnp | 3 +- selfdrive/modeld/SConscript | 2 +- selfdrive/modeld/dmonitoringmodeld.py | 92 +++++++------------ .../modeld/models/dmonitoring_model.onnx | 4 +- selfdrive/monitoring/helpers.py | 41 ++++----- selfdrive/monitoring/test_monitoring.py | 2 +- selfdrive/test/process_replay/model_replay.py | 2 +- 7 files changed, 59 insertions(+), 87 deletions(-) diff --git a/cereal/log.capnp b/cereal/log.capnp index 981cfd468f..86774b8d42 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -2166,7 +2166,8 @@ struct DriverStateV2 { leftBlinkProb @7 :Float32; rightBlinkProb @8 :Float32; sunglassesProb @9 :Float32; - notReadyProb @12 :List(Float32); + phoneProb @13 :Float32; + notReadyProbDEPRECATED @12 :List(Float32); occludedProbDEPRECATED @10 :Float32; readyProbDEPRECATED @11 :List(Float32); } diff --git a/selfdrive/modeld/SConscript b/selfdrive/modeld/SConscript index ae549f3a76..8b33a457f2 100644 --- a/selfdrive/modeld/SConscript +++ b/selfdrive/modeld/SConscript @@ -32,7 +32,7 @@ lenvCython.Program('models/commonmodel_pyx.so', 'models/commonmodel_pyx.pyx', LI tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=env.Dir("#").abspath) if 'pycache' not in x] # Get model metadata -for model_name in ['driving_vision', 'driving_policy']: +for model_name in ['driving_vision', 'driving_policy', 'dmonitoring_model']: fn = File(f"models/{model_name}").abspath script_files = [File(Dir("#selfdrive/modeld").File("get_model_metadata.py").abspath)] cmd = f'python3 {Dir("#selfdrive/modeld").abspath}/get_model_metadata.py {fn}.onnx' diff --git a/selfdrive/modeld/dmonitoringmodeld.py b/selfdrive/modeld/dmonitoringmodeld.py index 2851a3e7da..dc2de6f998 100755 --- a/selfdrive/modeld/dmonitoringmodeld.py +++ b/selfdrive/modeld/dmonitoringmodeld.py @@ -7,7 +7,6 @@ from tinygrad.dtype import dtypes import math import time import pickle -import ctypes import numpy as np from pathlib import Path @@ -16,47 +15,16 @@ from cereal.messaging import PubMaster, SubMaster from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf from openpilot.common.swaglog import cloudlog from openpilot.common.realtime import config_realtime_process -from openpilot.common.transformations.model import dmonitoringmodel_intrinsics, DM_INPUT_SIZE +from openpilot.common.transformations.model import dmonitoringmodel_intrinsics from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.selfdrive.modeld.models.commonmodel_pyx import CLContext, MonitoringModelFrame from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid from openpilot.selfdrive.modeld.runners.tinygrad_helpers import qcom_tensor_from_opencl_address -MODEL_WIDTH, MODEL_HEIGHT = DM_INPUT_SIZE -CALIB_LEN = 3 -FEATURE_LEN = 512 -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), - ("face_position", ctypes.c_float*3), - ("face_orientation_std", ctypes.c_float*3), - ("face_position_std", ctypes.c_float*3), - ("face_prob", ctypes.c_float), - ("_unused_a", ctypes.c_float*8), - ("left_eye_prob", ctypes.c_float), - ("_unused_b", ctypes.c_float*8), - ("right_eye_prob", ctypes.c_float), - ("left_blink_prob", ctypes.c_float), - ("right_blink_prob", ctypes.c_float), - ("sunglasses_prob", ctypes.c_float), - ("_unused_c", ctypes.c_float), - ("_unused_d", ctypes.c_float*4), - ("not_ready_prob", ctypes.c_float*2)] - - -class DMonitoringModelResult(ctypes.Structure): - _fields_ = [ - ("driver_state_lhd", DriverStateResult), - ("driver_state_rhd", DriverStateResult), - ("wheel_on_right_prob", ctypes.c_float), - ("features", ctypes.c_float*FEATURE_LEN)] +METADATA_PATH = Path(__file__).parent / 'models/dmonitoring_model_metadata.pkl' class ModelState: @@ -64,11 +32,14 @@ class ModelState: output: np.ndarray def __init__(self, cl_ctx): - assert ctypes.sizeof(DMonitoringModelResult) == OUTPUT_SIZE * ctypes.sizeof(ctypes.c_float) + with open(METADATA_PATH, 'rb') as f: + model_metadata = pickle.load(f) + self.input_shapes = model_metadata['input_shapes'] + self.output_slices = model_metadata['output_slices'] self.frame = MonitoringModelFrame(cl_ctx) self.numpy_inputs = { - 'calib': np.zeros((1, CALIB_LEN), dtype=np.float32), + 'calib': np.zeros(self.input_shapes['calib'], dtype=np.float32), } self.tensor_inputs = {k: Tensor(v, device='NPY').realize() for k,v in self.numpy_inputs.items()} @@ -84,9 +55,9 @@ class ModelState: if TICI: # The imgs tensors are backed by opencl memory, only need init once if 'input_img' not in self.tensor_inputs: - self.tensor_inputs['input_img'] = qcom_tensor_from_opencl_address(input_img_cl.mem_address, (1, MODEL_WIDTH*MODEL_HEIGHT), dtype=dtypes.uint8) + self.tensor_inputs['input_img'] = qcom_tensor_from_opencl_address(input_img_cl.mem_address, self.input_shapes['input_img'], dtype=dtypes.uint8) else: - self.tensor_inputs['input_img'] = Tensor(self.frame.buffer_from_cl(input_img_cl).reshape((1, MODEL_WIDTH*MODEL_HEIGHT)), dtype=dtypes.uint8).realize() + self.tensor_inputs['input_img'] = Tensor(self.frame.buffer_from_cl(input_img_cl).reshape(self.input_shapes['input_img']), dtype=dtypes.uint8).realize() output = self.model_run(**self.tensor_inputs).contiguous().realize().uop.base.buffer.numpy() @@ -95,31 +66,31 @@ class ModelState: return output, t2 - t1 -def fill_driver_state(msg, ds_result: DriverStateResult): - msg.faceOrientation = list(ds_result.face_orientation) - msg.faceOrientationStd = [math.exp(x) for x in ds_result.face_orientation_std] - msg.facePosition = list(ds_result.face_position[:2]) - msg.facePositionStd = [math.exp(x) for x in ds_result.face_position_std[:2]] - msg.faceProb = float(sigmoid(ds_result.face_prob)) - msg.leftEyeProb = float(sigmoid(ds_result.left_eye_prob)) - msg.rightEyeProb = float(sigmoid(ds_result.right_eye_prob)) - 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.notReadyProb = [float(sigmoid(x)) for x in ds_result.not_ready_prob] +def fill_driver_state(msg, model_output, output_slices, ds_suffix): + face_descs = model_output[output_slices[f'face_descs_{ds_suffix}']] + face_descs_std = face_descs[-6:] + msg.faceOrientation = [float(x) for x in face_descs[:3]] + msg.faceOrientationStd = [math.exp(x) for x in face_descs_std[:3]] + msg.facePosition = [float(x) for x in face_descs[3:5]] + msg.facePositionStd = [math.exp(x) for x in face_descs_std[3:5]] + msg.faceProb = float(sigmoid(model_output[output_slices[f'face_prob_{ds_suffix}']][0])) + msg.leftEyeProb = float(sigmoid(model_output[output_slices[f'left_eye_prob_{ds_suffix}']][0])) + msg.rightEyeProb = float(sigmoid(model_output[output_slices[f'right_eye_prob_{ds_suffix}']][0])) + msg.leftBlinkProb = float(sigmoid(model_output[output_slices[f'left_blink_prob_{ds_suffix}']][0])) + msg.rightBlinkProb = float(sigmoid(model_output[output_slices[f'right_blink_prob_{ds_suffix}']][0])) + msg.sunglassesProb = float(sigmoid(model_output[output_slices[f'sunglasses_prob_{ds_suffix}']][0])) + msg.phoneProb = float(sigmoid(model_output[output_slices[f'using_phone_prob_{ds_suffix}']][0])) - -def get_driverstate_packet(model_output: np.ndarray, frame_id: int, location_ts: int, execution_time: float, gpu_execution_time: float): - model_result = ctypes.cast(model_output.ctypes.data, ctypes.POINTER(DMonitoringModelResult)).contents +def get_driverstate_packet(model_output: np.ndarray, output_slices: dict[str, slice], frame_id: int, location_ts: int, exec_time: float, gpu_exec_time: float): msg = messaging.new_message('driverStateV2', valid=True) ds = msg.driverStateV2 ds.frameId = frame_id - ds.modelExecutionTime = execution_time - ds.gpuExecutionTime = gpu_execution_time - ds.wheelOnRightProb = float(sigmoid(model_result.wheel_on_right_prob)) + ds.modelExecutionTime = exec_time + ds.gpuExecutionTime = gpu_exec_time + ds.wheelOnRightProb = float(sigmoid(model_output[output_slices['wheel_on_right']][0])) ds.rawPredictions = model_output.tobytes() if SEND_RAW_PRED else b'' - fill_driver_state(ds.leftDriverData, model_result.driver_state_lhd) - fill_driver_state(ds.rightDriverData, model_result.driver_state_rhd) + fill_driver_state(ds.leftDriverData, model_output, output_slices, 'lhd') + fill_driver_state(ds.rightDriverData, model_output, output_slices, 'rhd') return msg @@ -140,7 +111,7 @@ def main(): sm = SubMaster(["liveCalibration"]) pm = PubMaster(["driverStateV2"]) - calib = np.zeros(CALIB_LEN, dtype=np.float32) + calib = np.zeros(model.numpy_inputs['calib'].size, dtype=np.float32) model_transform = None while True: @@ -160,7 +131,8 @@ def main(): model_output, gpu_execution_time = model.run(buf, calib, model_transform) t2 = time.perf_counter() - pm.send("driverStateV2", get_driverstate_packet(model_output, vipc_client.frame_id, vipc_client.timestamp_sof, t2 - t1, gpu_execution_time)) + msg = get_driverstate_packet(model_output, model.output_slices, vipc_client.frame_id, vipc_client.timestamp_sof, t2 - t1, gpu_execution_time) + pm.send("driverStateV2", msg) if __name__ == "__main__": diff --git a/selfdrive/modeld/models/dmonitoring_model.onnx b/selfdrive/modeld/models/dmonitoring_model.onnx index 1b6a8c3e93..9b1c4a1834 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:3a53626ab84757813fb16a1441704f2ae7192bef88c331bdc2415be6981d204f -size 7191776 +oid sha256:3446bf8b22e50e47669a25bf32460ae8baf8547037f346753e19ecbfcf6d4e59 +size 6954368 diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index 02d8ff5c71..f405eba537 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -37,12 +37,12 @@ class DRIVER_MONITOR_SETTINGS: self._BLINK_THRESHOLD = 0.865 if HARDWARE.get_device_type() == 'mici': - self._EE_THRESH11 = 0.75 + self._PHONE_THRESH = 0.75 else: - self._EE_THRESH11 = 0.4 - self._EE_THRESH12 = 15.0 - self._EE_MAX_OFFSET1 = 0.06 - self._EE_MIN_OFFSET1 = 0.025 + self._PHONE_THRESH = 0.4 + self._PHONE_THRESH2 = 15.0 + self._PHONE_MAX_OFFSET = 0.06 + self._PHONE_MIN_OFFSET = 0.025 self._POSE_PITCH_THRESHOLD = 0.3133 self._POSE_PITCH_THRESHOLD_SLACK = 0.3237 @@ -84,7 +84,7 @@ class DistractedType: NOT_DISTRACTED = 0 DISTRACTED_POSE = 1 << 0 DISTRACTED_BLINK = 1 << 1 - DISTRACTED_E2E = 1 << 2 + DISTRACTED_PHONE = 1 << 2 class DriverPose: def __init__(self, max_trackable): @@ -142,9 +142,9 @@ class DriverMonitoring: self.wheelpos_learner = RunningStatFilter() self.pose = DriverPose(self.settings._POSE_OFFSET_MAX_COUNT) self.blink = DriverBlink() - self.eev1 = 0. - self.ee1_offseter = RunningStatFilter(max_trackable=self.settings._POSE_OFFSET_MAX_COUNT) - self.ee1_calibrated = False + self.phone_prob = 0. + self.phone_offseter = RunningStatFilter(max_trackable=self.settings._POSE_OFFSET_MAX_COUNT) + self.phone_calibrated = False self.always_on = always_on self.distracted_types = [] @@ -242,13 +242,13 @@ class DriverMonitoring: if (self.blink.left + self.blink.right)*0.5 > self.settings._BLINK_THRESHOLD: distracted_types.append(DistractedType.DISTRACTED_BLINK) - if self.ee1_calibrated: - ee1_dist = self.eev1 > max(min(self.ee1_offseter.filtered_stat.M, self.settings._EE_MAX_OFFSET1), self.settings._EE_MIN_OFFSET1) \ - * self.settings._EE_THRESH12 + if self.phone_calibrated: + using_phone = self.phone_prob > max(min(self.phone_offseter.filtered_stat.M, self.settings._PHONE_MAX_OFFSET), self.settings._PHONE_MIN_OFFSET) \ + * self.settings._PHONE_THRESH2 else: - ee1_dist = self.eev1 > self.settings._EE_THRESH11 - if ee1_dist: - distracted_types.append(DistractedType.DISTRACTED_E2E) + using_phone = self.phone_prob > self.settings._PHONE_THRESH + if using_phone: + distracted_types.append(DistractedType.DISTRACTED_PHONE) return distracted_types @@ -267,8 +267,7 @@ class DriverMonitoring: self.wheel_on_right = self.wheel_on_right_last 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.notReadyProb)): + driver_data.faceOrientationStd, driver_data.facePositionStd)): return self.face_detected = driver_data.faceProb > self.settings._FACE_THRESHOLD @@ -284,10 +283,10 @@ class DriverMonitoring: * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD) 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.phone_prob = driver_data.phoneProb 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 + self.driver_distracted = (DistractedType.DISTRACTED_PHONE in self.distracted_types or DistractedType.DISTRACTED_POSE in self.distracted_types or DistractedType.DISTRACTED_BLINK in self.distracted_types) \ and driver_data.faceProb > self.settings._FACE_THRESHOLD and self.pose.low_std self.driver_distraction_filter.update(self.driver_distracted) @@ -297,11 +296,11 @@ class DriverMonitoring: if self.face_detected and car_speed > self.settings._POSE_CALIB_MIN_SPEED and self.pose.low_std and (not op_engaged or not self.driver_distracted): 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.phone_offseter.push_and_update(self.phone_prob) 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.phone_calibrated = self.phone_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT if self.face_detected and not self.driver_distracted: if model_std_max > self.settings._DCAM_UNCERTAIN_ALERT_THRESHOLD: diff --git a/selfdrive/monitoring/test_monitoring.py b/selfdrive/monitoring/test_monitoring.py index 1f8babe029..67234550f7 100644 --- a/selfdrive/monitoring/test_monitoring.py +++ b/selfdrive/monitoring/test_monitoring.py @@ -25,7 +25,7 @@ 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.notReadyProb = [0., 0.] + ds.leftDriverData.phoneProb = 0. return ds diff --git a/selfdrive/test/process_replay/model_replay.py b/selfdrive/test/process_replay/model_replay.py index 59b8cf8250..9ba599bac9 100755 --- a/selfdrive/test/process_replay/model_replay.py +++ b/selfdrive/test/process_replay/model_replay.py @@ -77,7 +77,7 @@ def generate_report(proposed, master, tmp, commit): (lambda x: get_idx_if_non_empty(x.leftDriverData.faceProb), "leftDriverData.faceProb"), (lambda x: get_idx_if_non_empty(x.leftDriverData.faceOrientation, 0), "leftDriverData.faceOrientation0"), (lambda x: get_idx_if_non_empty(x.leftDriverData.leftBlinkProb), "leftDriverData.leftBlinkProb"), - (lambda x: get_idx_if_non_empty(x.leftDriverData.notReadyProb, 0), "leftDriverData.notReadyProb0"), + (lambda x: get_idx_if_non_empty(x.leftDriverData.phoneProb), "leftDriverData.phoneProb"), (lambda x: get_idx_if_non_empty(x.rightDriverData.faceProb), "rightDriverData.faceProb"), ], "driverStateV2") From 81be78cd4d48c8cd963fea12fe074689bb2185fe Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 14 Nov 2025 15:48:55 -0800 Subject: [PATCH 03/48] too aggressive for now --- .github/workflows/stale.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 823df2b580..1ecd114dc4 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -5,8 +5,8 @@ on: workflow_dispatch: env: - DAYS_BEFORE_PR_CLOSE: 2 - DAYS_BEFORE_PR_STALE: 9 + DAYS_BEFORE_PR_CLOSE: 7 + DAYS_BEFORE_PR_STALE: 24 DAYS_BEFORE_PR_STALE_DRAFT: 30 jobs: From d0c6f845da33000c6f684d647e01afb6a34963ec Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 14 Nov 2025 17:33:14 -0800 Subject: [PATCH 04/48] ui: add burn in debug mode (#36625) * ui: add burn in debug mode * scary * lil less * lil cleanup * revert that * cleanup --- system/ui/README.md | 1 + system/ui/lib/application.py | 56 ++++++++++++++++++++++++++++++++- system/ui/lib/shader_polygon.py | 16 ++-------- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/system/ui/README.md b/system/ui/README.md index b124ae4d85..3f2562aae2 100644 --- a/system/ui/README.md +++ b/system/ui/README.md @@ -6,6 +6,7 @@ Quick start: * set `SHOW_FPS=1` to show the FPS * set `STRICT_MODE=1` to kill the app if it drops too much below 60fps * set `SCALE=1.5` to scale the entire UI by 1.5x +* set `BURN_IN=1` to get a burn-in heatmap version of the UI * https://www.raylib.com/cheatsheet/cheatsheet.html * https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index e9f5484a17..1d085a5a05 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -6,6 +6,7 @@ import signal import sys import pyray as rl import threading +import platform from contextlib import contextmanager from collections.abc import Callable from collections import deque @@ -34,6 +35,43 @@ SCALE = float(os.getenv("SCALE", "1.0")) PROFILE_RENDER = int(os.getenv("PROFILE_RENDER", "0")) PROFILE_STATS = int(os.getenv("PROFILE_STATS", "100")) # Number of functions to show in profile output +GL_VERSION = """ +#version 300 es +precision highp float; +""" +if platform.system() == "Darwin": + GL_VERSION = """ + #version 330 core + """ + +BURN_IN_MODE = "BURN_IN" in os.environ +BURN_IN_VERTEX_SHADER = GL_VERSION + """ +in vec3 vertexPosition; +in vec2 vertexTexCoord; +uniform mat4 mvp; +out vec2 fragTexCoord; +void main() { + fragTexCoord = vertexTexCoord; + gl_Position = mvp * vec4(vertexPosition, 1.0); +} +""" +BURN_IN_FRAGMENT_SHADER = GL_VERSION + """ +in vec2 fragTexCoord; +uniform sampler2D texture0; +out vec4 fragColor; +void main() { + vec4 sampled = texture(texture0, fragTexCoord); + float intensity = sampled.b; + // Map blue intensity to green -> yellow -> red to highlight burn-in risk. + vec3 start = vec3(0.0, 1.0, 0.0); + vec3 middle = vec3(1.0, 1.0, 0.0); + vec3 end = vec3(1.0, 0.0, 0.0); + vec3 gradient = mix(start, middle, clamp(intensity * 2.0, 0.0, 1.0)); + gradient = mix(gradient, end, clamp((intensity - 0.5) * 2.0, 0.0, 1.0)); + fragColor = vec4(gradient, sampled.a); +} +""" + DEFAULT_TEXT_SIZE = 60 DEFAULT_TEXT_COLOR = rl.WHITE @@ -155,6 +193,7 @@ class GuiApplication: self._scaled_width = int(self._width * self._scale) self._scaled_height = int(self._height * self._scale) self._render_texture: rl.RenderTexture | None = None + self._burn_in_shader: rl.Shader | None = None self._textures: dict[str, rl.Texture] = {} self._target_fps: int = _DEFAULT_FPS self._last_fps_log_time: float = time.monotonic() @@ -212,8 +251,10 @@ class GuiApplication: rl.set_config_flags(flags) rl.init_window(self._scaled_width, self._scaled_height, title) + needs_render_texture = self._scale != 1.0 or BURN_IN_MODE if self._scale != 1.0: rl.set_mouse_scale(1 / self._scale, 1 / self._scale) + if needs_render_texture: 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) @@ -222,6 +263,8 @@ class GuiApplication: self._set_styles() self._load_fonts() self._patch_text_functions() + if BURN_IN_MODE and self._burn_in_shader is None: + self._burn_in_shader = rl.load_shader_from_memory(BURN_IN_VERTEX_SHADER, BURN_IN_FRAGMENT_SHADER) if not PC: self._mouse.start() @@ -337,6 +380,10 @@ class GuiApplication: rl.unload_render_texture(self._render_texture) self._render_texture = None + if self._burn_in_shader: + rl.unload_shader(self._burn_in_shader) + self._burn_in_shader = None + if not PC: self._mouse.stop() @@ -395,7 +442,14 @@ class GuiApplication: rl.clear_background(rl.BLACK) src_rect = rl.Rectangle(0, 0, float(self._width), -float(self._height)) dst_rect = rl.Rectangle(0, 0, float(self._scaled_width), float(self._scaled_height)) - rl.draw_texture_pro(self._render_texture.texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) + texture = self._render_texture.texture + if texture: + if BURN_IN_MODE and self._burn_in_shader: + rl.begin_shader_mode(self._burn_in_shader) + rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) + rl.end_shader_mode() + else: + rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) if self._show_fps: rl.draw_fps(10, 10) diff --git a/system/ui/lib/shader_polygon.py b/system/ui/lib/shader_polygon.py index 28585b08ba..3cc480b33b 100644 --- a/system/ui/lib/shader_polygon.py +++ b/system/ui/lib/shader_polygon.py @@ -1,9 +1,8 @@ -import platform import pyray as rl import numpy as np from dataclasses import dataclass from typing import Any, Optional, cast -from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.application import gui_app, GL_VERSION MAX_GRADIENT_COLORS = 20 # includes stops as well @@ -29,16 +28,7 @@ class Gradient: self.stops = [i / max(1, color_count - 1) for i in range(color_count)] -VERSION = """ -#version 300 es -precision highp float; -""" -if platform.system() == "Darwin": - VERSION = """ - #version 330 core - """ - -FRAGMENT_SHADER = VERSION + """ +FRAGMENT_SHADER = GL_VERSION + """ in vec2 fragTexCoord; out vec4 finalColor; @@ -83,7 +73,7 @@ void main() { """ # Default vertex shader -VERTEX_SHADER = VERSION + """ +VERTEX_SHADER = GL_VERSION + """ in vec3 vertexPosition; in vec2 vertexTexCoord; out vec2 fragTexCoord; From a6d2297545ae41fd660443ce0a0136b31f172154 Mon Sep 17 00:00:00 2001 From: Alexandre Nobuharu Sato <66435071+AlexandreSato@users.noreply.github.com> Date: Sun, 16 Nov 2025 18:15:04 -0300 Subject: [PATCH 05/48] Multilang: update pt-BR translations (#36626) --- selfdrive/ui/translations/app_pt-BR.po | 132 +++++++++++++------------ 1 file changed, 68 insertions(+), 64 deletions(-) diff --git a/selfdrive/ui/translations/app_pt-BR.po b/selfdrive/ui/translations/app_pt-BR.po index 8a388d0da0..84b53c6e8d 100644 --- a/selfdrive/ui/translations/app_pt-BR.po +++ b/selfdrive/ui/translations/app_pt-BR.po @@ -15,7 +15,9 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Language: pt_BR\n" +"X-Source-Language: C\n" #: selfdrive/ui/layouts/settings/device.py:160 #, python-format @@ -78,12 +80,12 @@ msgstr "" #: selfdrive/ui/layouts/settings/device.py:148 #, python-format msgid "

Steering lag calibration is complete." -msgstr "" +msgstr "

A calibração da latência da direção está concluída." #: selfdrive/ui/layouts/settings/device.py:146 #, python-format msgid "

Steering lag calibration is {}% complete." -msgstr "" +msgstr "

A calibração da latência da direção está {}% concluída." #: selfdrive/ui/layouts/settings/firehose.py:138 #, python-format @@ -106,7 +108,7 @@ msgstr "ADICIONAR" #: system/ui/widgets/network.py:139 #, python-format msgid "APN Setting" -msgstr "" +msgstr "Configuração de APN" #: selfdrive/ui/widgets/offroad_alerts.py:109 #, python-format @@ -116,7 +118,7 @@ msgstr "Reconhecer Atuação Excessiva" #: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 #, python-format msgid "Advanced" -msgstr "" +msgstr "Avançado" #: selfdrive/ui/layouts/settings/toggles.py:98 #, python-format @@ -205,18 +207,19 @@ msgstr "CONECTAR" #: system/ui/widgets/network.py:369 #, python-format msgid "CONNECTING..." -msgstr "CONECTAR" +msgstr "CONECTANDO..." -#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35 -#: system/ui/widgets/keyboard.py:81 system/ui/widgets/network.py:318 +#: system/ui/widgets/confirm_dialog.py:23 +#: system/ui/widgets/option_dialog.py:35 system/ui/widgets/keyboard.py:81 +#: system/ui/widgets/network.py:318 #, python-format msgid "Cancel" -msgstr "" +msgstr "Cancelar" #: system/ui/widgets/network.py:134 #, python-format msgid "Cellular Metered" -msgstr "" +msgstr "Dados móveis limitados" #: selfdrive/ui/layouts/settings/device.py:68 #, python-format @@ -227,7 +230,7 @@ msgstr "Alterar Idioma" #, python-format msgid "Changing this setting will restart openpilot if the car is powered on." msgstr "" -" Alterar esta configuração reiniciará o openpilot se o carro estiver ligado." +"Alterar esta configuração reiniciará o openpilot se o carro estiver ligado." #: selfdrive/ui/widgets/pairing_dialog.py:129 #, python-format @@ -261,7 +264,7 @@ msgstr "Recusar, desinstalar o openpilot" #: selfdrive/ui/layouts/settings/settings.py:67 msgid "Developer" -msgstr "Desenvolvedor" +msgstr "Desenvolv" #: selfdrive/ui/layouts/settings/settings.py:62 msgid "Device" @@ -309,12 +312,12 @@ msgstr "Câmera do Motorista" #: selfdrive/ui/layouts/settings/toggles.py:96 #, python-format msgid "Driving Personality" -msgstr "Personalidade de Condução" +msgstr "Personalidade" #: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 #, python-format msgid "EDIT" -msgstr "" +msgstr "EDITAR" #: selfdrive/ui/layouts/sidebar.py:138 msgid "ERROR" @@ -382,22 +385,22 @@ msgstr "" #: system/ui/widgets/network.py:204 #, python-format msgid "Enter APN" -msgstr "" +msgstr "Digite APN" #: system/ui/widgets/network.py:241 #, python-format msgid "Enter SSID" -msgstr "" +msgstr "Digite SSID" #: system/ui/widgets/network.py:254 #, python-format msgid "Enter new tethering password" -msgstr "" +msgstr "Digite nova senha tethering" #: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 #, python-format msgid "Enter password" -msgstr "" +msgstr "Digite a senha" #: selfdrive/ui/widgets/ssh_key.py:89 #, python-format @@ -407,7 +410,7 @@ msgstr "Digite seu nome de usuário do GitHub" #: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 #, python-format msgid "Error" -msgstr "" +msgstr "Erro" #: selfdrive/ui/layouts/settings/toggles.py:52 #, python-format @@ -426,12 +429,12 @@ msgstr "" #: system/ui/widgets/network.py:373 #, python-format msgid "FORGETTING..." -msgstr "" +msgstr "ESQUECENDO..." #: selfdrive/ui/widgets/setup.py:44 #, python-format msgid "Finish Setup" -msgstr "Concluir Configuração" +msgstr "Configure" #: selfdrive/ui/layouts/settings/settings.py:66 msgid "Firehose" @@ -487,12 +490,12 @@ msgstr "" #: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 #, python-format msgid "Forget" -msgstr "" +msgstr "Esquecer" #: system/ui/widgets/network.py:319 #, python-format msgid "Forget Wi-Fi Network \"{}\"?" -msgstr "" +msgstr "Esquecer rede Wi-Fi \"{}\"?" #: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 msgid "GOOD" @@ -526,7 +529,7 @@ msgstr "INSTALAR" #: system/ui/widgets/network.py:150 #, python-format msgid "IP Address" -msgstr "" +msgstr "Endereço IP" #: selfdrive/ui/layouts/settings/software.py:53 #, python-format @@ -568,7 +571,7 @@ msgstr "" #: selfdrive/ui/layouts/settings/device.py:60 #, python-format msgid "N/A" -msgstr "" +msgstr "N/A" #: selfdrive/ui/layouts/sidebar.py:142 msgid "NO" @@ -670,12 +673,12 @@ msgstr "Desligar" #: system/ui/widgets/network.py:144 #, python-format msgid "Prevent large data uploads when on a metered Wi-Fi connection" -msgstr "" +msgstr "Evitar uploads grandes de dados em conexões Wi-Fi limitadas" #: system/ui/widgets/network.py:135 #, python-format msgid "Prevent large data uploads when on a metered cellular connection" -msgstr "" +msgstr "Evitar uploads grandes de dados em conexões móveis limitadas" #: selfdrive/ui/layouts/settings/device.py:25 msgid "" @@ -795,27 +798,27 @@ msgstr "Revise as regras, recursos e limitações do openpilot" #: selfdrive/ui/layouts/settings/software.py:61 #, python-format msgid "SELECT" -msgstr "" +msgstr "SELECIONAR" #: selfdrive/ui/layouts/settings/developer.py:53 #, python-format msgid "SSH Keys" -msgstr "" +msgstr "Chaves SSH" #: system/ui/widgets/network.py:310 #, python-format msgid "Scanning Wi-Fi networks..." -msgstr "" +msgstr "Procurando redes Wi-Fi..." #: system/ui/widgets/option_dialog.py:36 #, python-format msgid "Select" -msgstr "" +msgstr "Selecione" #: selfdrive/ui/layouts/settings/software.py:183 #, python-format msgid "Select a branch" -msgstr "" +msgstr "Selecione uma branch" #: selfdrive/ui/layouts/settings/device.py:91 #, python-format @@ -873,16 +876,16 @@ msgstr "TEMP" #: selfdrive/ui/layouts/settings/software.py:61 #, python-format msgid "Target Branch" -msgstr "" +msgstr "Branch Alvo" #: system/ui/widgets/network.py:124 #, python-format msgid "Tethering Password" -msgstr "" +msgstr "Senha Tethering" #: selfdrive/ui/layouts/settings/settings.py:64 msgid "Toggles" -msgstr "Alternâncias" +msgstr "Toggles" #: selfdrive/ui/layouts/settings/software.py:72 #, python-format @@ -978,12 +981,12 @@ msgstr "Wi‑Fi" #: system/ui/widgets/network.py:144 #, python-format msgid "Wi-Fi Network Metered" -msgstr "" +msgstr "Rede Wi-Fi limitada" #: system/ui/widgets/network.py:314 #, python-format msgid "Wrong password" -msgstr "" +msgstr "Senha errada" #: selfdrive/ui/layouts/onboarding.py:145 #, python-format @@ -1012,7 +1015,7 @@ msgstr "comma prime" #: system/ui/widgets/network.py:142 #, python-format msgid "default" -msgstr "" +msgstr "default" #: selfdrive/ui/layouts/settings/device.py:133 #, python-format @@ -1027,7 +1030,7 @@ msgstr "falha ao verificar atualização" #: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 #, python-format msgid "for \"{}\"" -msgstr "" +msgstr "para \"{}\"" #: selfdrive/ui/onroad/hud_renderer.py:177 #, python-format @@ -1037,7 +1040,7 @@ msgstr "km/h" #: system/ui/widgets/network.py:204 #, python-format msgid "leave blank for automatic configuration" -msgstr "" +msgstr "deixe em branco para configuração automática" #: selfdrive/ui/layouts/settings/device.py:134 #, python-format @@ -1047,7 +1050,7 @@ msgstr "à esquerda" #: system/ui/widgets/network.py:142 #, python-format msgid "metered" -msgstr "" +msgstr "limitados" #: selfdrive/ui/onroad/hud_renderer.py:177 #, python-format @@ -1077,30 +1080,30 @@ msgstr "openpilot Indisponível" #: selfdrive/ui/layouts/settings/toggles.py:158 #, python-format msgid "" -"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." +"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." msgstr "" "o openpilot dirige por padrão no modo chill. O Modo Experimental habilita " "recursos em nível alpha que não estão prontos para o modo chill. Os recursos " -"experimentais são listados abaixo:

Controle Longitudinal End-to-End
Permita que o modelo de condução controle o acelerador e os freios. O " -"openpilot dirigirá como acha que um humano faria, incluindo parar em sinais " -"e semáforos vermelhos. Como o modelo decide a velocidade, a velocidade " -"definida atuará apenas como limite superior. Este é um recurso de qualidade " -"alpha; erros devem ser esperados.

Nova Visualização de Condução
A visualização de condução mudará para a câmera grande-angular " -"voltada para a estrada em baixas velocidades para mostrar melhor algumas " -"curvas. O logotipo do Modo Experimental também será exibido no canto " -"superior direito." +"experimentais são listados abaixo:

Controle Longitudinal " +"End-to-End


Permita que o modelo de condução controle o acelerador e " +"os freios. O openpilot dirigirá como acha que um humano faria, incluindo " +"parar em sinais e semáforos vermelhos. Como o modelo decide a velocidade, a " +"velocidade definida atuará apenas como limite superior. Este é um recurso de " +"qualidade alpha; erros devem ser esperados.

Nova Visualização de " +"Condução


A visualização de condução mudará para a câmera " +"grande-angular voltada para a estrada em baixas velocidades para mostrar " +"melhor algumas curvas. O logotipo do Modo Experimental também será exibido " +"no canto superior direito." #: selfdrive/ui/layouts/settings/device.py:165 #, python-format @@ -1108,7 +1111,8 @@ msgid "" "openpilot is continuously calibrating, resetting is rarely required. " "Resetting calibration will restart openpilot if the car is powered on." msgstr "" -" Alterar esta configuração reiniciará o openpilot se o carro estiver ligado." +"O openpilot está continuamente calibrando, resetar é raramente solicitado. " +"Alterar esta configuração reiniciará o openpilot se o carro estiver ligado." #: selfdrive/ui/layouts/settings/firehose.py:20 msgid "" @@ -1146,7 +1150,7 @@ msgstr "à direita" #: system/ui/widgets/network.py:142 #, python-format msgid "unmetered" -msgstr "" +msgstr "ilimitados" #: selfdrive/ui/layouts/settings/device.py:133 #, python-format From b8a845fe93374087135dfab6f526089ebf32eb17 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 17 Nov 2025 20:46:34 -0800 Subject: [PATCH 06/48] ui: add GRID debug helper (#36630) --- system/ui/README.md | 1 + system/ui/lib/application.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/system/ui/README.md b/system/ui/README.md index 3f2562aae2..6e43c20d12 100644 --- a/system/ui/README.md +++ b/system/ui/README.md @@ -7,6 +7,7 @@ Quick start: * set `STRICT_MODE=1` to kill the app if it drops too much below 60fps * set `SCALE=1.5` to scale the entire UI by 1.5x * set `BURN_IN=1` to get a burn-in heatmap version of the UI +* set `GRID=50` to show a 50-pixel alignment grid overlay * https://www.raylib.com/cheatsheet/cheatsheet.html * https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 1d085a5a05..16b924e442 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -32,6 +32,7 @@ SHOW_FPS = os.getenv("SHOW_FPS") == "1" SHOW_TOUCHES = os.getenv("SHOW_TOUCHES") == "1" STRICT_MODE = os.getenv("STRICT_MODE") == "1" SCALE = float(os.getenv("SCALE", "1.0")) +GRID_SIZE = int(os.getenv("GRID", "0")) PROFILE_RENDER = int(os.getenv("PROFILE_RENDER", "0")) PROFILE_STATS = int(os.getenv("PROFILE_STATS", "100")) # Number of functions to show in profile output @@ -213,6 +214,7 @@ class GuiApplication: self._mouse_history: deque[MousePosWithTime] = deque(maxlen=MOUSE_THREAD_RATE) self._show_touches = SHOW_TOUCHES self._show_fps = SHOW_FPS + self._grid_size = GRID_SIZE self._profile_render_frames = PROFILE_RENDER self._render_profiler = None self._render_profile_start_time = None @@ -457,6 +459,9 @@ class GuiApplication: if self._show_touches: self._draw_touch_points() + if self._grid_size > 0: + self._draw_grid() + rl.end_drawing() self._monitor_fps() self._frame += 1 @@ -605,6 +610,19 @@ class GuiApplication: color = rl.Color(min(int(255 * (1.5 - perc)), 255), int(min(255 * (perc + 0.5), 255)), 50, 255) rl.draw_circle(int(mouse_pos.x), int(mouse_pos.y), 5, color) + def _draw_grid(self): + grid_color = rl.Color(60, 60, 60, 255) + # Draw vertical lines + x = 0 + while x <= self._scaled_width: + rl.draw_line(x, 0, x, self._scaled_height, grid_color) + x += self._grid_size + # Draw horizontal lines + y = 0 + while y <= self._scaled_height: + rl.draw_line(0, y, self._scaled_width, y, grid_color) + y += self._grid_size + def _output_render_profile(self): import io import pstats From f653c1c0c5bc58b5225fba138df831aba807d988 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 17 Nov 2025 21:31:10 -0800 Subject: [PATCH 07/48] ui: don't sleep on PC --- selfdrive/ui/ui_state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index b08b8ef28c..8871e5de2c 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -10,7 +10,7 @@ from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app -from openpilot.system.hardware import HARDWARE +from openpilot.system.hardware import HARDWARE, PC BACKLIGHT_OFFROAD = 50 @@ -246,7 +246,7 @@ class Device: callback() self._prev_timed_out = interaction_timeout - self._set_awake(ui_state.ignition or not interaction_timeout) + self._set_awake(ui_state.ignition or not interaction_timeout or PC) def _set_awake(self, on: bool): if on != self._awake: From 689f884810c5f0aa14d34ef8b9379bee04f16258 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 17 Nov 2025 21:37:40 -0800 Subject: [PATCH 08/48] DM test mode (#36631) --- selfdrive/monitoring/dmonitoringd.py | 8 +++-- selfdrive/monitoring/helpers.py | 45 ++++++++++++++++++---------- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/selfdrive/monitoring/dmonitoringd.py b/selfdrive/monitoring/dmonitoringd.py index f137b406b4..293904a8ee 100755 --- a/selfdrive/monitoring/dmonitoringd.py +++ b/selfdrive/monitoring/dmonitoringd.py @@ -13,6 +13,7 @@ def dmonitoringd_thread(): sm = messaging.SubMaster(['driverStateV2', 'liveCalibration', 'carState', 'selfdriveState', 'modelV2'], poll='driverStateV2') DM = DriverMonitoring(rhd_saved=params.get_bool("IsRhdDetected"), always_on=params.get_bool("AlwaysOnDM")) + demo_mode=False # 20Hz <- dmonitoringmodeld while True: @@ -22,8 +23,10 @@ def dmonitoringd_thread(): continue valid = sm.all_checks() - if valid: - DM.run_step(sm) + if demo_mode and sm.valid['driverStateV2']: + DM.run_step(sm, demo=demo_mode) + elif valid: + DM.run_step(sm, demo=demo_mode) # publish dat = DM.get_state_packet(valid=valid) @@ -32,6 +35,7 @@ def dmonitoringd_thread(): # load live always-on toggle if sm['driverStateV2'].frameId % 40 == 1: DM.always_on = params.get_bool("AlwaysOnDM") + demo_mode = params.get_bool("IsDriverViewEnabled") # save rhd virtual toggle every 5 mins if (sm['driverStateV2'].frameId % 6000 == 0 and diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index f405eba537..463bf2b7fa 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -211,8 +211,8 @@ class DriverMonitoring: self.step_change = self.settings._DT_DMON / self.settings._AWARENESS_TIME self.active_monitoring_mode = False - def _set_policy(self, model_data, car_speed): - bp = model_data.meta.disengagePredictions.brakeDisengageProbs[0] # brake disengage prob in next 2s + def _set_policy(self, brake_disengage_prob, car_speed): + bp = brake_disengage_prob k1 = max(-0.00156*((car_speed-16)**2)+0.6, 0.2) bp_normal = max(min(bp / k1, 0.5),0) self.pose.cfactor_pitch = np.interp(bp_normal, [0, 0.5], @@ -417,27 +417,42 @@ class DriverMonitoring: } return dat - def run_step(self, sm): - # Set strictness + def run_step(self, sm, demo=False): + if demo: + highway_speed = 30 + enabled = True + wrong_gear = False + standstill = False + driver_engaged = False + brake_disengage_prob = 1.0 + rpyCalib = [0., 0., 0.] + else: + highway_speed = sm['carState'].vEgo + enabled = sm['selfdriveState'].enabled + wrong_gear = sm['carState'].gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low) + standstill = sm['carState'].standstill + driver_engaged = sm['carState'].steeringPressed or sm['carState'].gasPressed + brake_disengage_prob = sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs[0] # brake disengage prob in next 2s + rpyCalib = sm['liveCalibration'].rpyCalib self._set_policy( - model_data=sm['modelV2'], - car_speed=sm['carState'].vEgo + brake_disengage_prob=brake_disengage_prob, + car_speed=highway_speed, ) # Parse data from dmonitoringmodeld self._update_states( driver_state=sm['driverStateV2'], - cal_rpy=sm['liveCalibration'].rpyCalib, - car_speed=sm['carState'].vEgo, - op_engaged=sm['selfdriveState'].enabled, - standstill=sm['carState'].standstill, + cal_rpy=rpyCalib, + car_speed=highway_speed, + op_engaged=enabled, + standstill=standstill, ) # Update distraction events self._update_events( - driver_engaged=sm['carState'].steeringPressed or sm['carState'].gasPressed, - op_engaged=sm['selfdriveState'].enabled, - standstill=sm['carState'].standstill, - wrong_gear=sm['carState'].gearShifter in [car.CarState.GearShifter.reverse, car.CarState.GearShifter.park], - car_speed=sm['carState'].vEgo + driver_engaged=driver_engaged, + op_engaged=enabled, + standstill=standstill, + wrong_gear=wrong_gear, + car_speed=highway_speed ) From d3cc32ddca577e17cc778a7a9e38ba09b350b298 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 17 Nov 2025 21:40:08 -0800 Subject: [PATCH 09/48] mici fcc --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f2d8c5cad0..2faa4f8ace 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,7 +177,7 @@ quiet-level = 3 # if you've got a short variable name that's getting flagged, add it here ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite" builtin = "clear,rare,informal,code,names,en-GB_to_en-US" -skip = "./third_party/*, ./tinygrad/*, ./tinygrad_repo/*, ./msgq/*, ./panda/*, ./opendbc/*, ./opendbc_repo/*, ./rednose/*, ./rednose_repo/*, ./teleoprtc/*, ./teleoprtc_repo/*, *.po, uv.lock, *.onnx, ./cereal/gen/*, */c_generated_code/*, docs/assets/*, tools/plotjuggler/layouts/*" +skip = "./third_party/*, ./tinygrad/*, ./tinygrad_repo/*, ./msgq/*, ./panda/*, ./opendbc/*, ./opendbc_repo/*, ./rednose/*, ./rednose_repo/*, ./teleoprtc/*, ./teleoprtc_repo/*, *.po, uv.lock, *.onnx, ./cereal/gen/*, */c_generated_code/*, docs/assets/*, tools/plotjuggler/layouts/*, selfdrive/assets/offroad/mici_fcc.html" [tool.mypy] python_version = "3.11" From 16abf93be89bed5dd1dde93944a4eb1dda95895f Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 17 Nov 2025 22:40:33 -0800 Subject: [PATCH 10/48] reduce ruff noise with raylib --- pyproject.toml | 1 + system/ui/lib/shader_polygon.py | 4 ++-- system/ui/widgets/label.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2faa4f8ace..3f3804ba6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -235,6 +235,7 @@ lint.ignore = [ "B027", "B024", "NPY002", # new numpy random syntax is worse + "UP045", "UP007", # these don't play nice with raylib atm ] line-length = 160 target-version ="py311" diff --git a/system/ui/lib/shader_polygon.py b/system/ui/lib/shader_polygon.py index 3cc480b33b..7be6638af1 100644 --- a/system/ui/lib/shader_polygon.py +++ b/system/ui/lib/shader_polygon.py @@ -152,7 +152,7 @@ class ShaderState: self.initialized = False -def _configure_shader_color(state: ShaderState, color: Optional[rl.Color], # noqa: UP045 +def _configure_shader_color(state: ShaderState, color: Optional[rl.Color], gradient: Gradient | None, origin_rect: rl.Rectangle): assert (color is not None) != (gradient is not None), "Either color or gradient must be provided" @@ -202,7 +202,7 @@ def triangulate(pts: np.ndarray) -> list[tuple[float, float]]: def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray, - color: Optional[rl.Color] = None, gradient: Gradient | None = None): # noqa: UP045 + color: Optional[rl.Color] = None, gradient: Gradient | None = None): """ Draw a ribbon polygon (two chains) with a triangle strip and gradient. diff --git a/system/ui/widgets/label.py b/system/ui/widgets/label.py index 7d76802565..8d33ac2fd0 100644 --- a/system/ui/widgets/label.py +++ b/system/ui/widgets/label.py @@ -105,7 +105,7 @@ class Label(Widget): text_alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, text_padding: int = 0, text_color: rl.Color = DEFAULT_TEXT_COLOR, - icon: Union[rl.Texture, None] = None, # noqa: UP007 + icon: Union[rl.Texture, None] = None, elide_right: bool = False, ): From 67cbeebc7b571898a75072f42dab39f77562775e Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Tue, 18 Nov 2025 10:10:32 -0800 Subject: [PATCH 11/48] sensord: magnetometer is only for tizi --- system/sensord/sensord.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/system/sensord/sensord.py b/system/sensord/sensord.py index 2b6467fa78..cc0366881b 100755 --- a/system/sensord/sensord.py +++ b/system/sensord/sensord.py @@ -11,6 +11,7 @@ from openpilot.common.util import sudo_write from openpilot.common.realtime import config_realtime_process, Ratekeeper from openpilot.common.swaglog import cloudlog from openpilot.common.gpio import gpiochip_get_ro_value_fd, gpioevent_data +from openpilot.system.hardware import HARDWARE from openpilot.system.sensord.sensors.i2c_sensor import Sensor from openpilot.system.sensord.sensors.lsm6ds3_accel import LSM6DS3_Accel @@ -95,8 +96,11 @@ def main() -> None: (LSM6DS3_Accel(I2C_BUS_IMU), "accelerometer", True), (LSM6DS3_Gyro(I2C_BUS_IMU), "gyroscope", True), (LSM6DS3_Temp(I2C_BUS_IMU), "temperatureSensor", False), - (MMC5603NJ_Magn(I2C_BUS_IMU), "magnetometer", False), ] + if HARDWARE.get_device_type() == "tizi": + sensors_cfg.append( + (MMC5603NJ_Magn(I2C_BUS_IMU), "magnetometer", False), + ) # Reset sensors for sensor, _, _ in sensors_cfg: From 4ef0d3ee99b376dd6ca23ef4899903bb8ce9d413 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Tue, 18 Nov 2025 10:11:06 -0800 Subject: [PATCH 12/48] setup sound for DM test mode --- system/manager/process_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 940e7f9125..8cf3e8c14c 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -81,7 +81,7 @@ procs = [ PythonProcess("sensord", "system.sensord.sensord", only_onroad, enabled=not PC), PythonProcess("ui", "selfdrive.ui.ui", always_run), - PythonProcess("soundd", "selfdrive.ui.soundd", only_onroad), + PythonProcess("soundd", "selfdrive.ui.soundd", driverview), PythonProcess("locationd", "selfdrive.locationd.locationd", only_onroad), NativeProcess("_pandad", "selfdrive/pandad", ["./pandad"], always_run, enabled=False), PythonProcess("calibrationd", "selfdrive.locationd.calibrationd", only_onroad), From ad7f3d2b24dd8310b03028a02519225fe5beb267 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Tue, 18 Nov 2025 10:39:32 -0800 Subject: [PATCH 13/48] selfdrived: prep for mici (#36633) * selfdrived: prep for mici * tizi reverts * more revert * lil more: * invert it * cleanup --- selfdrive/selfdrived/events.py | 89 ++++++++++++++++++++++++++++-- selfdrive/selfdrived/selfdrived.py | 3 + 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/selfdrive/selfdrived/events.py b/selfdrive/selfdrived/events.py index c865cc94a6..a9b1683c51 100755 --- a/selfdrive/selfdrived/events.py +++ b/selfdrive/selfdrived/events.py @@ -13,6 +13,7 @@ from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.locationd.calibrationd import MIN_SPEED_FILTER from openpilot.system.micd import SAMPLE_RATE, SAMPLE_BUFFER from openpilot.selfdrive.ui.feedback.feedbackd import FEEDBACK_MAX_DURATION +from openpilot.system.hardware import HARDWARE AlertSize = log.SelfdriveState.AlertSize AlertStatus = log.SelfdriveState.AlertStatus @@ -150,6 +151,8 @@ class NoEntryAlert(Alert): def __init__(self, alert_text_2: str, alert_text_1: str = "openpilot Unavailable", visual_alert: car.CarControl.HUDControl.VisualAlert=VisualAlert.none): + if HARDWARE.get_device_type() == 'mici': + alert_text_1, alert_text_2 = alert_text_2, alert_text_1 super().__init__(alert_text_1, alert_text_2, AlertStatus.normal, AlertSize.mid, Priority.LOW, visual_alert, AudibleAlert.refuse, 3.) @@ -195,8 +198,13 @@ class NormalPermanentAlert(Alert): class StartupAlert(Alert): def __init__(self, alert_text_1: str, alert_text_2: str = "Always keep hands on wheel and eyes on road", alert_status=AlertStatus.normal): + alert_size = AlertSize.mid + if HARDWARE.get_device_type() == 'mici': + if alert_text_2 == "Always keep hands on wheel and eyes on road": + alert_text_2 = "" + alert_size = AlertSize.small super().__init__(alert_text_1, alert_text_2, - alert_status, AlertSize.mid, + alert_status, alert_size, Priority.LOWER, VisualAlert.none, AudibleAlert.none, 5.), @@ -246,10 +254,19 @@ def below_steer_speed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.S Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 0.4) -def calibration_incomplete_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - first_word = 'Recalibration' if sm['liveCalibration'].calStatus == log.LiveCalibrationData.Status.recalibrating else 'Calibration' +def steer_saturated_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: + steer_text2 = "Steer Left" if sm['carControl'].actuators.torque > 0 else "Steer Right" return Alert( - f"{first_word} in Progress: {sm['liveCalibration'].calPerc:.0f}%", + "Take Control", + steer_text2, + AlertStatus.userPrompt, AlertSize.mid, + Priority.LOW, VisualAlert.steerRequired, AudibleAlert.promptRepeat, 2.) + + +def calibration_incomplete_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: + first_word = 'Recalibrating' if sm['liveCalibration'].calStatus == log.LiveCalibrationData.Status.recalibrating else 'Calibrating' + return Alert( + f"{first_word}: {sm['liveCalibration'].calPerc:.0f}%", f"Drive Above {get_display_speed(MIN_SPEED_FILTER, metric)}", AlertStatus.normal, AlertSize.mid, Priority.LOWEST, VisualAlert.none, AudibleAlert.none, .2) @@ -1013,6 +1030,70 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { } +if HARDWARE.get_device_type() == 'mici': + EVENTS.update({ + EventName.preDriverDistracted: { + ET.PERMANENT: Alert( + "Pay Attention", + "", + AlertStatus.normal, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlert.none, 2), + }, + EventName.promptDriverDistracted: { + ET.PERMANENT: Alert( + "Pay Attention", + "Driver Distracted", + AlertStatus.userPrompt, AlertSize.mid, + Priority.MID, VisualAlert.steerRequired, AudibleAlert.promptDistracted, 1), + }, + EventName.resumeRequired: { + ET.WARNING: Alert( + "Press Resume", + "", + AlertStatus.userPrompt, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlert.none, .2), + }, + EventName.preLaneChangeLeft: { + ET.WARNING: Alert( + "Steer Left", + "Confirm Lane Change", + AlertStatus.normal, AlertSize.mid, + Priority.LOW, VisualAlert.none, AudibleAlert.none, .1), + }, + EventName.preLaneChangeRight: { + ET.WARNING: Alert( + "Steer Right", + "Confirm Lane Change", + AlertStatus.normal, AlertSize.mid, + Priority.LOW, VisualAlert.none, AudibleAlert.none, .1), + }, + EventName.laneChangeBlocked: { + ET.WARNING: Alert( + "Car in Blindspot", + "", + AlertStatus.userPrompt, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlert.prompt, .1), + }, + EventName.steerSaturated: { + ET.WARNING: steer_saturated_alert, + }, + EventName.calibrationIncomplete: { + ET.PERMANENT: calibration_incomplete_alert, + ET.SOFT_DISABLE: soft_disable_alert("Calibration Incomplete"), + ET.NO_ENTRY: NoEntryAlert("Calibrating"), + }, + EventName.reverseGear: { + ET.PERMANENT: Alert( + "Reverse", + "", + AlertStatus.normal, AlertSize.full, + Priority.LOWEST, VisualAlert.none, AudibleAlert.none, .2, creation_delay=0.5), + ET.USER_DISABLE: ImmediateDisableAlert("Reverse"), + ET.NO_ENTRY: NoEntryAlert("Reverse"), + }, + }) + + if __name__ == '__main__': # print all alerts by type and priority from cereal.services import SERVICE_LIST diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index e9c62fe327..997c7e3770 100755 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -22,6 +22,7 @@ from openpilot.selfdrive.selfdrived.state import StateMachine from openpilot.selfdrive.selfdrived.alertmanager import AlertManager, set_offroad_alert from openpilot.system.version import get_build_metadata +from openpilot.system.hardware import HARDWARE REPLAY = "REPLAY" in os.environ SIMULATION = "SIMULATION" in os.environ @@ -123,6 +124,8 @@ class SelfdriveD: # Determine startup event self.startup_event = EventName.startup if build_metadata.openpilot.comma_remote and build_metadata.tested_channel else EventName.startupMaster + if HARDWARE.get_device_type() == 'mici': + self.startup_event = None if not car_recognized: self.startup_event = EventName.startupNoCar elif car_recognized and self.CP.passive: From e1e41be1a994339412fe49c920c19af1a5520b05 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Tue, 18 Nov 2025 10:42:06 -0800 Subject: [PATCH 14/48] common: add BounceFilter --- common/filter_simple.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/common/filter_simple.py b/common/filter_simple.py index 9ea6fe3070..212e1a8f40 100644 --- a/common/filter_simple.py +++ b/common/filter_simple.py @@ -15,3 +15,20 @@ class FirstOrderFilter: self.initialized = True self.x = x return self.x + + +class BounceFilter(FirstOrderFilter): + def __init__(self, x0, rc, dt, initialized=True, bounce=2): + self.velocity = FirstOrderFilter(0.0, 0.15, dt) + self.bounce = bounce + super().__init__(x0, rc, dt, initialized) + + def update(self, x): + super().update(x) + scale = self.dt / (1.0 / 60.0) # tuned at 60 fps + self.velocity.x += (x - self.x) * self.bounce * scale * self.dt + self.velocity.update(0.0) + if abs(self.velocity.x) < 1e-5: + self.velocity.x = 0.0 + self.x += self.velocity.x + return self.x From bca727a3cba9ad4237ef74cd1da54fccf192a488 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 18 Nov 2025 19:48:04 -0800 Subject: [PATCH 15/48] Fix strength check --- selfdrive/ui/layouts/sidebar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/ui/layouts/sidebar.py b/selfdrive/ui/layouts/sidebar.py index d468442b15..050cd795bf 100644 --- a/selfdrive/ui/layouts/sidebar.py +++ b/selfdrive/ui/layouts/sidebar.py @@ -116,7 +116,7 @@ class Sidebar(Widget): def _update_network_status(self, device_state): self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown")) strength = device_state.networkStrength - self._net_strength = max(0, min(5, strength.raw + 1)) if strength > 0 else 0 + self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0 def _update_temperature_status(self, device_state): thermal_status = device_state.thermalStatus From e449ffcc36827b44dd2b24bb29507acb4319b6a8 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 18 Nov 2025 21:09:20 -0800 Subject: [PATCH 16/48] Tuneup offroad alerts --- selfdrive/selfdrived/alerts_offroad.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/selfdrive/selfdrived/alerts_offroad.json b/selfdrive/selfdrived/alerts_offroad.json index b52dfa4d88..0fc11b9636 100644 --- a/selfdrive/selfdrived/alerts_offroad.json +++ b/selfdrive/selfdrived/alerts_offroad.json @@ -26,7 +26,7 @@ "severity": 0 }, "Offroad_UnregisteredHardware": { - "text": "Device failed to register with the comma.ai backend. It will not connect or upload to comma.ai servers, and receives no support from comma.ai. If this is a device purchased at comma.ai/shop, open a ticket at https://comma.ai/support.", + "text": "Failed to register with comma.ai backend. It will not connect or upload to comma.ai servers, and receives no support from comma.ai. If this is a device purchased at comma.ai/shop, open a ticket at https://comma.ai/support.", "severity": 1 }, "Offroad_CarUnrecognized": { @@ -38,11 +38,11 @@ "severity": 0 }, "Offroad_DriverMonitoringUncertain": { - "text": "openpilot detected poor visibility for driver monitoring. Ensure the device has a clear view of the driver. This can be checked using Settings -> Device -> Driver Camera Preview. Extreme lighting conditions and/or unconventional mounting positions may also trigger this alert.", + "text": "Poor visibility detected for driver monitoring. Ensure the device has a clear view of the driver. This can be checked in the device settings. Extreme lighting conditions and/or unconventional mounting positions may also trigger this alert.", "severity": 0 }, "Offroad_ExcessiveActuation": { - "text": "openpilot detected excessive %1 actuation on your last drive. Please contact support at https://comma.ai/support and share your device's Dongle ID for troubleshooting.", + "text": "Excessive %1 actuation detected on your last drive. Please contact support at https://comma.ai/support and share your device's Dongle ID for troubleshooting.", "severity": 1, "_comment": "Set extra field to lateral or longitudinal." } From b73be441b3f471f69016becd047589417a267e92 Mon Sep 17 00:00:00 2001 From: Maxime Desroches Date: Tue, 18 Nov 2025 21:56:17 -0800 Subject: [PATCH 17/48] bump updater --- system/hardware/tici/updater_magic | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/system/hardware/tici/updater_magic b/system/hardware/tici/updater_magic index b4dfa9be2e..ec586dbcb3 100755 --- a/system/hardware/tici/updater_magic +++ b/system/hardware/tici/updater_magic @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7990262878becdf2eaed40ffcc96835a6fc6bc4bdf52f4df88e8b6fcadd1bff8 -size 13664323 +oid sha256:c44fb88b3b1643b6b44ae8ac9880348bd0257ff90f4084cbe889de91d71653fe +size 25111329 From 3aaf249236ee3b0e31e8c90fa3fe651f561b42ec Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 18 Nov 2025 22:27:45 -0800 Subject: [PATCH 18/48] comma four (#36639) * squash squash squash * scroller tici --- selfdrive/assets/fonts/process.py | 6 +- selfdrive/assets/icons/eyes_crossed.png | 3 + selfdrive/assets/icons/eyes_open.png | 3 + .../icons_mici/buttons/button_circle.png | 3 + .../buttons/button_circle_disabled.png | 3 + .../buttons/button_circle_hover.png | 3 + .../buttons/button_circle_pressed.png | 3 + .../icons_mici/buttons/button_circle_red.png | 3 + .../buttons/button_circle_red_hover.png | 3 + .../buttons/button_circle_red_pressed.png | 3 + .../icons_mici/buttons/button_rectangle.png | 3 + .../buttons/button_rectangle_disabled.png | 3 + .../buttons/button_rectangle_hover.png | 3 + .../buttons/button_rectangle_pressed.png | 3 + .../icons_mici/buttons/button_side_back.png | 3 + .../buttons/button_side_back_pressed.png | 3 + .../icons_mici/buttons/button_side_check.png | 3 + .../buttons/button_side_check_pressed.png | 3 + .../icons_mici/buttons/button_side_home.png | 3 + .../assets/icons_mici/buttons/slider_bg.png | 3 + .../buttons/toggle_dot_disabled.png | 3 + .../icons_mici/buttons/toggle_dot_enabled.png | 3 + .../icons_mici/buttons/toggle_dot_orange.png | 3 + .../buttons/toggle_pill_disabled.png | 3 + .../buttons/toggle_pill_enabled.png | 3 + .../assets/icons_mici/exclamation_point.png | 3 + .../assets/icons_mici/experimental_mode.png | 3 + selfdrive/assets/icons_mici/eye.png | 3 + selfdrive/assets/icons_mici/eye_crossed.png | 3 + selfdrive/assets/icons_mici/microphone.png | 3 + .../icons_mici/notifications/blue_large.png | 3 + .../icons_mici/notifications/blue_small.png | 3 + .../icons_mici/notifications/icons/cable.png | 3 + .../icons_mici/notifications/icons/camera.png | 3 + .../icons_mici/notifications/icons/close.png | 3 + .../notifications/icons/critical.png | 3 + .../icons_mici/notifications/icons/eye.png | 3 + .../icons_mici/notifications/icons/fan.png | 3 + .../notifications/icons/temperature.png | 3 + .../icons_mici/notifications/icons/wheel.png | 3 + .../icons_mici/notifications/normal_large.png | 3 + .../icons_mici/notifications/normal_small.png | 3 + .../icons_mici/notifications/orange_large.png | 3 + .../icons_mici/notifications/orange_small.png | 3 + .../icons_mici/notifications/red_large.png | 3 + .../icons_mici/notifications/red_small.png | 3 + .../icons_mici/offroad_alerts/big_alert.png | 3 + .../offroad_alerts/big_alert_pressed.png | 3 + .../icons_mici/offroad_alerts/green_wheel.png | 3 + .../offroad_alerts/medium_alert.png | 3 + .../offroad_alerts/medium_alert_pressed.png | 3 + .../offroad_alerts/orange_warning.png | 3 + .../icons_mici/offroad_alerts/red_warning.png | 3 + .../icons_mici/offroad_alerts/small_alert.png | 3 + .../offroad_alerts/small_alert_pressed.png | 3 + .../icons_mici/onroad/blind_spot_left.png | 3 + .../icons_mici/onroad/blind_spot_right.png | 3 + .../assets/icons_mici/onroad/bookmark.png | 3 + .../driver_monitoring/dm_background.png | 3 + .../onroad/driver_monitoring/dm_center.png | 3 + .../onroad/driver_monitoring/dm_cone.png | 3 + .../onroad/driver_monitoring/dm_person.png | 3 + .../assets/icons_mici/onroad/eye_fill.png | 3 + .../assets/icons_mici/onroad/eye_orange.png | 3 + .../assets/icons_mici/onroad/glasses.png | 3 + .../assets/icons_mici/onroad/onroad_fade.png | 3 + .../assets/icons_mici/onroad/sunglasses.png | 3 + .../icons_mici/onroad/turn_signal_left.png | 3 + .../icons_mici/onroad/turn_signal_right.png | 3 + selfdrive/assets/icons_mici/settings.png | 3 + .../assets/icons_mici/settings/comma_icon.png | 3 + .../icons_mici/settings/developer/adb.png | 3 + .../settings/developer/debug_mode.png | 3 + .../icons_mici/settings/developer/ssh.png | 3 + .../icons_mici/settings/developer_icon.png | 3 + .../icons_mici/settings/device/cameras.png | 3 + .../icons_mici/settings/device/cancel.png | 3 + .../settings/device/downloading.png | 3 + .../icons_mici/settings/device/fcc_logo.png | 3 + .../icons_mici/settings/device/info.png | 3 + .../icons_mici/settings/device/language.png | 3 + .../icons_mici/settings/device/lkas.png | 3 + .../icons_mici/settings/device/pair.png | 3 + .../icons_mici/settings/device/power.png | 3 + .../icons_mici/settings/device/reboot.png | 3 + .../icons_mici/settings/device/uninstall.png | 3 + .../icons_mici/settings/device/up_to_date.png | 3 + .../icons_mici/settings/device/update.png | 3 + .../icons_mici/settings/device_icon.png | 3 + .../icons_mici/settings/keyboard/back.png | 3 + .../settings/keyboard/backspace.png | 3 + .../settings/keyboard/caps_lock.png | 3 + .../settings/keyboard/caps_lower.png | 3 + .../settings/keyboard/caps_upper.png | 3 + .../icons_mici/settings/keyboard/confirm.png | 3 + .../settings/keyboard/keyboard_background.png | 3 + .../icons_mici/settings/keyboard/space.png | 3 + .../icons_mici/settings/manual_icon.png | 3 + .../settings/network/cell_strength_full.png | 3 + .../settings/network/cell_strength_high.png | 3 + .../settings/network/cell_strength_low.png | 3 + .../settings/network/cell_strength_medium.png | 3 + .../settings/network/cell_strength_none.png | 3 + .../icons_mici/settings/network/connect.png | 3 + .../settings/network/connect_disabled.png | 3 + .../settings/network/connect_pressed.png | 3 + .../settings/network/forget_pill.png | 3 + .../settings/network/forget_pill_pressed.png | 3 + .../settings/network/new/connect_button.png | 3 + .../network/new/connect_button_pressed.png | 3 + .../settings/network/new/forget_button.png | 3 + .../network/new/forget_button_pressed.png | 3 + .../network/new/full_connect_button.png | 3 + .../new/full_connect_button_pressed.png | 3 + .../icons_mici/settings/network/new/lock.png | 3 + .../icons_mici/settings/network/new/trash.png | 3 + .../settings/network/new/wifi_selected.png | 3 + .../icons_mici/settings/network/tethering.png | 3 + .../icons_mici/settings/network/trash.png | 3 + .../settings/network/wifi_strength_full.png | 3 + .../settings/network/wifi_strength_low.png | 3 + .../settings/network/wifi_strength_medium.png | 3 + .../settings/network/wifi_strength_none.png | 3 + .../settings/network/wifi_strength_slash.png | 3 + .../icons_mici/settings/toggles_icon.png | 3 + .../settings/vertical_scroll_indicator.png | 3 + selfdrive/assets/icons_mici/setup/arrow.png | 3 + selfdrive/assets/icons_mici/setup/back.png | 3 + .../assets/icons_mici/setup/back_new.png | 3 + .../assets/icons_mici/setup/green_button.png | 3 + .../icons_mici/setup/green_button_pressed.png | 3 + .../assets/icons_mici/setup/green_car.png | 3 + .../assets/icons_mici/setup/green_dm.png | 3 + .../assets/icons_mici/setup/green_info.png | 3 + .../assets/icons_mici/setup/green_pedal.png | 3 + .../icons_mici/setup/medium_button_bg.png | 3 + .../setup/medium_button_pressed_bg.png | 3 + selfdrive/assets/icons_mici/setup/reboot.png | 3 + .../assets/icons_mici/setup/red_warning.png | 3 + .../icons_mici/setup/reset/small_button.png | 3 + .../setup/reset/small_button_pressed.png | 3 + .../icons_mici/setup/reset/wide_button.png | 3 + .../setup/reset/wide_button_pressed.png | 3 + selfdrive/assets/icons_mici/setup/restore.png | 3 + .../setup/scroll_down_indicator.png | 3 + .../assets/icons_mici/setup/small_button.png | 3 + .../setup/small_button_disabled.png | 3 + .../icons_mici/setup/small_button_pressed.png | 3 + .../icons_mici/setup/small_red_pill.png | 3 + .../setup/small_red_pill_pressed.png | 3 + .../setup/small_slider/slider_arrow.png | 3 + .../small_slider/slider_arrow_outline.png | 3 + .../setup/small_slider/slider_bg.png | 3 + .../setup/small_slider/slider_bg_larger.png | 3 + .../slider_black_rounded_rectangle.png | 3 + .../slider_green_rounded_rectangle.png | 3 + .../setup/small_slider/slider_red_circle.png | 3 + .../icons_mici/setup/smaller_button.png | 3 + .../setup/smaller_button_disabled.png | 3 + .../setup/smaller_button_pressed.png | 3 + selfdrive/assets/icons_mici/setup/warning.png | 3 + .../assets/icons_mici/setup/widish_button.png | 3 + .../setup/widish_button_disabled.png | 3 + .../setup/widish_button_pressed.png | 3 + .../assets/icons_mici/turn_intent_left.png | 3 + .../assets/icons_mici/turn_intent_right.png | 3 + selfdrive/assets/icons_mici/wheel.png | 3 + .../assets/icons_mici/wheel_critical.png | 3 + selfdrive/assets/offroad/mici_fcc.html | 16 + selfdrive/assets/sounds/disengage.wav | 4 +- selfdrive/assets/sounds/disengage_tizi.wav | 3 + selfdrive/assets/sounds/engage.wav | 4 +- selfdrive/assets/sounds/engage_tizi.wav | 3 + selfdrive/ui/SConscript | 6 +- selfdrive/ui/installer/installer.cc | 62 +- selfdrive/ui/layouts/home.py | 2 - selfdrive/ui/layouts/settings/common.py | 5 + selfdrive/ui/layouts/settings/developer.py | 2 +- selfdrive/ui/layouts/settings/device.py | 2 +- selfdrive/ui/layouts/settings/software.py | 2 +- selfdrive/ui/layouts/settings/toggles.py | 2 +- selfdrive/ui/mici/layouts/__init__.py | 0 selfdrive/ui/mici/layouts/home.py | 272 +++++++ selfdrive/ui/mici/layouts/main.py | 149 ++++ selfdrive/ui/mici/layouts/offroad_alerts.py | 307 ++++++++ selfdrive/ui/mici/layouts/onboarding.py | 552 +++++++++++++ .../ui/mici/layouts/settings/developer.py | 151 ++++ selfdrive/ui/mici/layouts/settings/device.py | 378 +++++++++ .../ui/mici/layouts/settings/firehose.py | 223 ++++++ selfdrive/ui/mici/layouts/settings/network.py | 558 ++++++++++++++ .../ui/mici/layouts/settings/settings.py | 113 +++ selfdrive/ui/mici/layouts/settings/toggles.py | 95 +++ selfdrive/ui/mici/onroad/__init__.py | 12 + selfdrive/ui/mici/onroad/alert_renderer.py | 361 +++++++++ .../ui/mici/onroad/augmented_road_view.py | 358 +++++++++ selfdrive/ui/mici/onroad/cameraview.py | 390 ++++++++++ selfdrive/ui/mici/onroad/confidence_ball.py | 78 ++ .../ui/mici/onroad/driver_camera_dialog.py | 241 ++++++ selfdrive/ui/mici/onroad/driver_state.py | 227 ++++++ selfdrive/ui/mici/onroad/hud_renderer.py | 287 +++++++ selfdrive/ui/mici/onroad/model_renderer.py | 479 ++++++++++++ selfdrive/ui/mici/onroad/torque_bar.py | 253 ++++++ selfdrive/ui/mici/widgets/button.py | 375 +++++++++ selfdrive/ui/mici/widgets/dialog.py | 395 ++++++++++ selfdrive/ui/mici/widgets/pairing_dialog.py | 116 +++ selfdrive/ui/mici/widgets/side_button.py | 31 + selfdrive/ui/soundd.py | 12 +- selfdrive/ui/tests/profile_onroad.py | 9 +- .../ui/tests/test_ui/raylib_screenshots.py | 1 + selfdrive/ui/ui.py | 6 +- selfdrive/ui/ui_state.py | 21 +- system/ui/README.md | 1 + system/ui/lib/application.py | 32 +- system/ui/lib/scroll_panel2.py | 219 ++++++ system/ui/lib/shader_polygon.py | 4 +- system/ui/lib/text_measure.py | 3 +- system/ui/lib/wifi_manager.py | 5 +- system/ui/lib/wrap_text.py | 17 +- system/ui/mici_reset.py | 160 ++++ system/ui/mici_setup.py | 727 ++++++++++++++++++ system/ui/mici_updater.py | 200 +++++ system/ui/reset.py | 135 +--- system/ui/setup.py | 450 +---------- system/ui/spinner.py | 19 +- system/ui/text.py | 23 +- system/ui/tici_reset.py | 136 ++++ system/ui/tici_setup.py | 451 +++++++++++ system/ui/tici_updater.py | 173 +++++ system/ui/updater.py | 172 +---- system/ui/widgets/__init__.py | 218 +++++- system/ui/widgets/button.py | 119 ++- system/ui/widgets/confirm_dialog.py | 2 +- system/ui/widgets/label.py | 511 +++++++++++- system/ui/widgets/mici_keyboard.py | 388 ++++++++++ system/ui/widgets/network.py | 2 +- system/ui/widgets/option_dialog.py | 2 +- system/ui/widgets/scroller.py | 203 ++++- system/ui/widgets/scroller_tici.py | 90 +++ system/ui/widgets/slider.py | 183 +++++ 239 files changed, 10876 insertions(+), 839 deletions(-) create mode 100644 selfdrive/assets/icons/eyes_crossed.png create mode 100644 selfdrive/assets/icons/eyes_open.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_circle.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_circle_disabled.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_circle_hover.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_circle_pressed.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_circle_red.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_rectangle.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_side_back.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_side_back_pressed.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_side_check.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_side_check_pressed.png create mode 100644 selfdrive/assets/icons_mici/buttons/button_side_home.png create mode 100644 selfdrive/assets/icons_mici/buttons/slider_bg.png create mode 100644 selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png create mode 100644 selfdrive/assets/icons_mici/buttons/toggle_dot_enabled.png create mode 100644 selfdrive/assets/icons_mici/buttons/toggle_dot_orange.png create mode 100644 selfdrive/assets/icons_mici/buttons/toggle_pill_disabled.png create mode 100644 selfdrive/assets/icons_mici/buttons/toggle_pill_enabled.png create mode 100644 selfdrive/assets/icons_mici/exclamation_point.png create mode 100644 selfdrive/assets/icons_mici/experimental_mode.png create mode 100644 selfdrive/assets/icons_mici/eye.png create mode 100644 selfdrive/assets/icons_mici/eye_crossed.png create mode 100644 selfdrive/assets/icons_mici/microphone.png create mode 100644 selfdrive/assets/icons_mici/notifications/blue_large.png create mode 100644 selfdrive/assets/icons_mici/notifications/blue_small.png create mode 100644 selfdrive/assets/icons_mici/notifications/icons/cable.png create mode 100644 selfdrive/assets/icons_mici/notifications/icons/camera.png create mode 100644 selfdrive/assets/icons_mici/notifications/icons/close.png create mode 100644 selfdrive/assets/icons_mici/notifications/icons/critical.png create mode 100644 selfdrive/assets/icons_mici/notifications/icons/eye.png create mode 100644 selfdrive/assets/icons_mici/notifications/icons/fan.png create mode 100644 selfdrive/assets/icons_mici/notifications/icons/temperature.png create mode 100644 selfdrive/assets/icons_mici/notifications/icons/wheel.png create mode 100644 selfdrive/assets/icons_mici/notifications/normal_large.png create mode 100644 selfdrive/assets/icons_mici/notifications/normal_small.png create mode 100644 selfdrive/assets/icons_mici/notifications/orange_large.png create mode 100644 selfdrive/assets/icons_mici/notifications/orange_small.png create mode 100644 selfdrive/assets/icons_mici/notifications/red_large.png create mode 100644 selfdrive/assets/icons_mici/notifications/red_small.png create mode 100644 selfdrive/assets/icons_mici/offroad_alerts/big_alert.png create mode 100644 selfdrive/assets/icons_mici/offroad_alerts/big_alert_pressed.png create mode 100644 selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png create mode 100644 selfdrive/assets/icons_mici/offroad_alerts/medium_alert.png create mode 100644 selfdrive/assets/icons_mici/offroad_alerts/medium_alert_pressed.png create mode 100644 selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png create mode 100644 selfdrive/assets/icons_mici/offroad_alerts/red_warning.png create mode 100644 selfdrive/assets/icons_mici/offroad_alerts/small_alert.png create mode 100644 selfdrive/assets/icons_mici/offroad_alerts/small_alert_pressed.png create mode 100644 selfdrive/assets/icons_mici/onroad/blind_spot_left.png create mode 100644 selfdrive/assets/icons_mici/onroad/blind_spot_right.png create mode 100644 selfdrive/assets/icons_mici/onroad/bookmark.png create mode 100644 selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png create mode 100644 selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png create mode 100644 selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_cone.png create mode 100644 selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png create mode 100644 selfdrive/assets/icons_mici/onroad/eye_fill.png create mode 100644 selfdrive/assets/icons_mici/onroad/eye_orange.png create mode 100644 selfdrive/assets/icons_mici/onroad/glasses.png create mode 100644 selfdrive/assets/icons_mici/onroad/onroad_fade.png create mode 100644 selfdrive/assets/icons_mici/onroad/sunglasses.png create mode 100644 selfdrive/assets/icons_mici/onroad/turn_signal_left.png create mode 100644 selfdrive/assets/icons_mici/onroad/turn_signal_right.png create mode 100644 selfdrive/assets/icons_mici/settings.png create mode 100644 selfdrive/assets/icons_mici/settings/comma_icon.png create mode 100644 selfdrive/assets/icons_mici/settings/developer/adb.png create mode 100644 selfdrive/assets/icons_mici/settings/developer/debug_mode.png create mode 100644 selfdrive/assets/icons_mici/settings/developer/ssh.png create mode 100644 selfdrive/assets/icons_mici/settings/developer_icon.png create mode 100644 selfdrive/assets/icons_mici/settings/device/cameras.png create mode 100644 selfdrive/assets/icons_mici/settings/device/cancel.png create mode 100644 selfdrive/assets/icons_mici/settings/device/downloading.png create mode 100644 selfdrive/assets/icons_mici/settings/device/fcc_logo.png create mode 100644 selfdrive/assets/icons_mici/settings/device/info.png create mode 100644 selfdrive/assets/icons_mici/settings/device/language.png create mode 100644 selfdrive/assets/icons_mici/settings/device/lkas.png create mode 100644 selfdrive/assets/icons_mici/settings/device/pair.png create mode 100644 selfdrive/assets/icons_mici/settings/device/power.png create mode 100644 selfdrive/assets/icons_mici/settings/device/reboot.png create mode 100644 selfdrive/assets/icons_mici/settings/device/uninstall.png create mode 100644 selfdrive/assets/icons_mici/settings/device/up_to_date.png create mode 100644 selfdrive/assets/icons_mici/settings/device/update.png create mode 100644 selfdrive/assets/icons_mici/settings/device_icon.png create mode 100644 selfdrive/assets/icons_mici/settings/keyboard/back.png create mode 100644 selfdrive/assets/icons_mici/settings/keyboard/backspace.png create mode 100644 selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png create mode 100644 selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png create mode 100644 selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png create mode 100644 selfdrive/assets/icons_mici/settings/keyboard/confirm.png create mode 100644 selfdrive/assets/icons_mici/settings/keyboard/keyboard_background.png create mode 100644 selfdrive/assets/icons_mici/settings/keyboard/space.png create mode 100644 selfdrive/assets/icons_mici/settings/manual_icon.png create mode 100644 selfdrive/assets/icons_mici/settings/network/cell_strength_full.png create mode 100644 selfdrive/assets/icons_mici/settings/network/cell_strength_high.png create mode 100644 selfdrive/assets/icons_mici/settings/network/cell_strength_low.png create mode 100644 selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png create mode 100644 selfdrive/assets/icons_mici/settings/network/cell_strength_none.png create mode 100644 selfdrive/assets/icons_mici/settings/network/connect.png create mode 100644 selfdrive/assets/icons_mici/settings/network/connect_disabled.png create mode 100644 selfdrive/assets/icons_mici/settings/network/connect_pressed.png create mode 100644 selfdrive/assets/icons_mici/settings/network/forget_pill.png create mode 100644 selfdrive/assets/icons_mici/settings/network/forget_pill_pressed.png create mode 100644 selfdrive/assets/icons_mici/settings/network/new/connect_button.png create mode 100644 selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png create mode 100644 selfdrive/assets/icons_mici/settings/network/new/forget_button.png create mode 100644 selfdrive/assets/icons_mici/settings/network/new/forget_button_pressed.png create mode 100644 selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png create mode 100644 selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png create mode 100644 selfdrive/assets/icons_mici/settings/network/new/lock.png create mode 100644 selfdrive/assets/icons_mici/settings/network/new/trash.png create mode 100644 selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png create mode 100644 selfdrive/assets/icons_mici/settings/network/tethering.png create mode 100644 selfdrive/assets/icons_mici/settings/network/trash.png create mode 100644 selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png create mode 100644 selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png create mode 100644 selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png create mode 100644 selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png create mode 100644 selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png create mode 100644 selfdrive/assets/icons_mici/settings/toggles_icon.png create mode 100644 selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png create mode 100644 selfdrive/assets/icons_mici/setup/arrow.png create mode 100644 selfdrive/assets/icons_mici/setup/back.png create mode 100644 selfdrive/assets/icons_mici/setup/back_new.png create mode 100644 selfdrive/assets/icons_mici/setup/green_button.png create mode 100644 selfdrive/assets/icons_mici/setup/green_button_pressed.png create mode 100644 selfdrive/assets/icons_mici/setup/green_car.png create mode 100644 selfdrive/assets/icons_mici/setup/green_dm.png create mode 100644 selfdrive/assets/icons_mici/setup/green_info.png create mode 100644 selfdrive/assets/icons_mici/setup/green_pedal.png create mode 100644 selfdrive/assets/icons_mici/setup/medium_button_bg.png create mode 100644 selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png create mode 100644 selfdrive/assets/icons_mici/setup/reboot.png create mode 100644 selfdrive/assets/icons_mici/setup/red_warning.png create mode 100644 selfdrive/assets/icons_mici/setup/reset/small_button.png create mode 100644 selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png create mode 100644 selfdrive/assets/icons_mici/setup/reset/wide_button.png create mode 100644 selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png create mode 100644 selfdrive/assets/icons_mici/setup/restore.png create mode 100644 selfdrive/assets/icons_mici/setup/scroll_down_indicator.png create mode 100644 selfdrive/assets/icons_mici/setup/small_button.png create mode 100644 selfdrive/assets/icons_mici/setup/small_button_disabled.png create mode 100644 selfdrive/assets/icons_mici/setup/small_button_pressed.png create mode 100644 selfdrive/assets/icons_mici/setup/small_red_pill.png create mode 100644 selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png create mode 100644 selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png create mode 100644 selfdrive/assets/icons_mici/setup/small_slider/slider_arrow_outline.png create mode 100644 selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png create mode 100644 selfdrive/assets/icons_mici/setup/small_slider/slider_bg_larger.png create mode 100644 selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle.png create mode 100644 selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png create mode 100644 selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png create mode 100644 selfdrive/assets/icons_mici/setup/smaller_button.png create mode 100644 selfdrive/assets/icons_mici/setup/smaller_button_disabled.png create mode 100644 selfdrive/assets/icons_mici/setup/smaller_button_pressed.png create mode 100644 selfdrive/assets/icons_mici/setup/warning.png create mode 100644 selfdrive/assets/icons_mici/setup/widish_button.png create mode 100644 selfdrive/assets/icons_mici/setup/widish_button_disabled.png create mode 100644 selfdrive/assets/icons_mici/setup/widish_button_pressed.png create mode 100644 selfdrive/assets/icons_mici/turn_intent_left.png create mode 100644 selfdrive/assets/icons_mici/turn_intent_right.png create mode 100644 selfdrive/assets/icons_mici/wheel.png create mode 100644 selfdrive/assets/icons_mici/wheel_critical.png create mode 100644 selfdrive/assets/offroad/mici_fcc.html create mode 100644 selfdrive/assets/sounds/disengage_tizi.wav create mode 100644 selfdrive/assets/sounds/engage_tizi.wav create mode 100644 selfdrive/ui/layouts/settings/common.py create mode 100644 selfdrive/ui/mici/layouts/__init__.py create mode 100644 selfdrive/ui/mici/layouts/home.py create mode 100644 selfdrive/ui/mici/layouts/main.py create mode 100644 selfdrive/ui/mici/layouts/offroad_alerts.py create mode 100644 selfdrive/ui/mici/layouts/onboarding.py create mode 100644 selfdrive/ui/mici/layouts/settings/developer.py create mode 100644 selfdrive/ui/mici/layouts/settings/device.py create mode 100644 selfdrive/ui/mici/layouts/settings/firehose.py create mode 100644 selfdrive/ui/mici/layouts/settings/network.py create mode 100644 selfdrive/ui/mici/layouts/settings/settings.py create mode 100644 selfdrive/ui/mici/layouts/settings/toggles.py create mode 100644 selfdrive/ui/mici/onroad/__init__.py create mode 100644 selfdrive/ui/mici/onroad/alert_renderer.py create mode 100644 selfdrive/ui/mici/onroad/augmented_road_view.py create mode 100644 selfdrive/ui/mici/onroad/cameraview.py create mode 100644 selfdrive/ui/mici/onroad/confidence_ball.py create mode 100644 selfdrive/ui/mici/onroad/driver_camera_dialog.py create mode 100644 selfdrive/ui/mici/onroad/driver_state.py create mode 100644 selfdrive/ui/mici/onroad/hud_renderer.py create mode 100644 selfdrive/ui/mici/onroad/model_renderer.py create mode 100644 selfdrive/ui/mici/onroad/torque_bar.py create mode 100644 selfdrive/ui/mici/widgets/button.py create mode 100644 selfdrive/ui/mici/widgets/dialog.py create mode 100644 selfdrive/ui/mici/widgets/pairing_dialog.py create mode 100644 selfdrive/ui/mici/widgets/side_button.py create mode 100644 system/ui/lib/scroll_panel2.py create mode 100755 system/ui/mici_reset.py create mode 100755 system/ui/mici_setup.py create mode 100755 system/ui/mici_updater.py create mode 100755 system/ui/tici_reset.py create mode 100755 system/ui/tici_setup.py create mode 100755 system/ui/tici_updater.py create mode 100644 system/ui/widgets/mici_keyboard.py create mode 100644 system/ui/widgets/scroller_tici.py create mode 100644 system/ui/widgets/slider.py diff --git a/selfdrive/assets/fonts/process.py b/selfdrive/assets/fonts/process.py index a0d01af148..ddc8b3a868 100755 --- a/selfdrive/assets/fonts/process.py +++ b/selfdrive/assets/fonts/process.py @@ -10,7 +10,7 @@ TRANSLATIONS_DIR = SELFDRIVE_DIR / "ui" / "translations" LANGUAGES_FILE = TRANSLATIONS_DIR / "languages.json" GLYPH_PADDING = 6 -EXTRA_CHARS = "–‑✓×°§•€£¥" +EXTRA_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥" UNIFONT_LANGUAGES = {"ar", "th", "zh-CHT", "zh-CHS", "ko", "ja"} @@ -68,6 +68,10 @@ def _glyph_metrics(glyphs, rects, codepoints): def _write_bmfont(path: Path, font_size: int, face: str, atlas_name: str, line_height: int, base: int, atlas_size, entries): + # TODO: why doesn't raylib calculate these metrics correctly? + if line_height != font_size: + print("using font size for line height", atlas_name) + line_height = font_size lines = [ f"info face=\"{face}\" size=-{font_size} bold=0 italic=0 charset=\"\" unicode=1 stretchH=100 smooth=0 aa=1 padding=0,0,0,0 spacing=0,0 outline=0", f"common lineHeight={line_height} base={base} scaleW={atlas_size[0]} scaleH={atlas_size[1]} pages=1 packed=0 alphaChnl=0 redChnl=4 greenChnl=4 blueChnl=4", diff --git a/selfdrive/assets/icons/eyes_crossed.png b/selfdrive/assets/icons/eyes_crossed.png new file mode 100644 index 0000000000..af2122cd9a --- /dev/null +++ b/selfdrive/assets/icons/eyes_crossed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4def42b5faffc6a8f747c210d24c3a1a8a7f82891738ff7f3317091e63326ba5 +size 1083 diff --git a/selfdrive/assets/icons/eyes_open.png b/selfdrive/assets/icons/eyes_open.png new file mode 100644 index 0000000000..ad9afc3a3e --- /dev/null +++ b/selfdrive/assets/icons/eyes_open.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52019b72834e478588114584820313af866d2d7a737591a166c413ccaab6acf5 +size 931 diff --git a/selfdrive/assets/icons_mici/buttons/button_circle.png b/selfdrive/assets/icons_mici/buttons/button_circle.png new file mode 100644 index 0000000000..b6f4cc9d12 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_circle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f92e5f0b7fc50c3b64bd18ecee8a8d518017b5461104de76dee6feb0f4f0d70d +size 7496 diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_disabled.png b/selfdrive/assets/icons_mici/buttons/button_circle_disabled.png new file mode 100644 index 0000000000..d2104df4e1 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_circle_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:947aa3beb7eff6afb44101daf0aeaae7b7f31961c273df00eec0ca8359233c56 +size 5175 diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_hover.png b/selfdrive/assets/icons_mici/buttons/button_circle_hover.png new file mode 100644 index 0000000000..5cae152106 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_circle_hover.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20024203288f144633014422e16119278477099f24fba5c155a804a1864a26b4 +size 7511 diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_pressed.png b/selfdrive/assets/icons_mici/buttons/button_circle_pressed.png new file mode 100644 index 0000000000..45027a372e --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_circle_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d378fdbb9d683d5c94536ebf9c466146721b1f65859eb38667d5c2e9589e54c3 +size 12590 diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_red.png b/selfdrive/assets/icons_mici/buttons/button_circle_red.png new file mode 100644 index 0000000000..68ae400b1e --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_circle_red.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b48d8a191979f27dae8a336f99d944008e2536f698c58cffa5f3dddc17429b45 +size 11451 diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png b/selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png new file mode 100644 index 0000000000..3696334d5e --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:279c1d8f95eb9f4a3058dff76b0f316ce9eef7bc8f4296936ad25fd08703ce13 +size 10380 diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png b/selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png new file mode 100644 index 0000000000..08b2e318d4 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19d53ff0cb49ffc43507e5bac11583bc9b3037c2e2ed5e12b96bfd97d71e397f +size 26113 diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle.png b/selfdrive/assets/icons_mici/buttons/button_rectangle.png new file mode 100644 index 0000000000..230c537d6d --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_rectangle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ffb293236f5f8f7da44b5a3c4c0b72e86c4e1fdb04f89c94507af008ff7de139 +size 8210 diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png b/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png new file mode 100644 index 0000000000..76e75d5421 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bda53863c9a46c50a1e2920a76c2d2f1fe4df8a94b8d2e26f5d83eef3a9c3bd3 +size 3627 diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png b/selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png new file mode 100644 index 0000000000..a9fd28cc35 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b55e43c50e805ac5e8357e5943374ed02d756cefa3aaffb58c568a0b125c30b +size 7750 diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png b/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png new file mode 100644 index 0000000000..779c219fcb --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5528e9c041b824f005bf1ef6e49b2dbbc4ba10f994b0726d2a17a4fbf8c80f55 +size 21379 diff --git a/selfdrive/assets/icons_mici/buttons/button_side_back.png b/selfdrive/assets/icons_mici/buttons/button_side_back.png new file mode 100644 index 0000000000..3d648d34f1 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_side_back.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9df44871e9f5fa910622b0b92205b92a54d137dbdc3827b92e8622d85ff2e08e +size 5189 diff --git a/selfdrive/assets/icons_mici/buttons/button_side_back_pressed.png b/selfdrive/assets/icons_mici/buttons/button_side_back_pressed.png new file mode 100644 index 0000000000..e431cb0c73 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_side_back_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:013b368b38b17d9b2ef6aaf0f498f672deed95888084b7287f42bdfba617cbb6 +size 10142 diff --git a/selfdrive/assets/icons_mici/buttons/button_side_check.png b/selfdrive/assets/icons_mici/buttons/button_side_check.png new file mode 100644 index 0000000000..820b236066 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_side_check.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8fd563eec78d5ce4a8204c2f596789e1090cb3e26a35b4ffeacee4ab61968538 +size 8303 diff --git a/selfdrive/assets/icons_mici/buttons/button_side_check_pressed.png b/selfdrive/assets/icons_mici/buttons/button_side_check_pressed.png new file mode 100644 index 0000000000..6c38508af9 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_side_check_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0be8d5eddcd9f87acbf1daccf446be6218522120f64aee1ee0a3c0b31560f076 +size 15761 diff --git a/selfdrive/assets/icons_mici/buttons/button_side_home.png b/selfdrive/assets/icons_mici/buttons/button_side_home.png new file mode 100644 index 0000000000..99c5ea6509 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/button_side_home.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ec30f6ba49e7a7bc89e8369800ad71c8b57950bbf6b3f169fa944626a3ded59 +size 5258 diff --git a/selfdrive/assets/icons_mici/buttons/slider_bg.png b/selfdrive/assets/icons_mici/buttons/slider_bg.png new file mode 100644 index 0000000000..9164f74bad --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/slider_bg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ca620a05e9e69351b9bbcfcf021dae11fde26be50d7f1a39257d319f6303616 +size 9779 diff --git a/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png b/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png new file mode 100644 index 0000000000..0e21bc1b5a --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:613af9ed79bb26c60fbd19c094214f0881736c0e293f6d000b530cde0478a273 +size 2470 diff --git a/selfdrive/assets/icons_mici/buttons/toggle_dot_enabled.png b/selfdrive/assets/icons_mici/buttons/toggle_dot_enabled.png new file mode 100644 index 0000000000..5bb4d778f8 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/toggle_dot_enabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:532bf0e8535e3f9bc13af13029a27d6c14ae788d52224b6c65623334f62fada0 +size 6048 diff --git a/selfdrive/assets/icons_mici/buttons/toggle_dot_orange.png b/selfdrive/assets/icons_mici/buttons/toggle_dot_orange.png new file mode 100644 index 0000000000..bf8559ec87 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/toggle_dot_orange.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ca418dab8eab77569e3cc446deccdc5b468d79159711e6629d704eb531009d9 +size 6191 diff --git a/selfdrive/assets/icons_mici/buttons/toggle_pill_disabled.png b/selfdrive/assets/icons_mici/buttons/toggle_pill_disabled.png new file mode 100644 index 0000000000..555c16e095 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/toggle_pill_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7891a628bd9cedc1097114e89fcec4a50a88021c7d6c63f1329d087be9e1783e +size 3065 diff --git a/selfdrive/assets/icons_mici/buttons/toggle_pill_enabled.png b/selfdrive/assets/icons_mici/buttons/toggle_pill_enabled.png new file mode 100644 index 0000000000..d95039da92 --- /dev/null +++ b/selfdrive/assets/icons_mici/buttons/toggle_pill_enabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad31da78544edd18d0ac154670f22d1cd1ac57f50576002f04701d22c59502a8 +size 8257 diff --git a/selfdrive/assets/icons_mici/exclamation_point.png b/selfdrive/assets/icons_mici/exclamation_point.png new file mode 100644 index 0000000000..246fc015ec --- /dev/null +++ b/selfdrive/assets/icons_mici/exclamation_point.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b77579c099c688d1a27f356197fba9c2c8efcf4d391af580b4b29f0e70587919 +size 2086 diff --git a/selfdrive/assets/icons_mici/experimental_mode.png b/selfdrive/assets/icons_mici/experimental_mode.png new file mode 100644 index 0000000000..e0138bfd65 --- /dev/null +++ b/selfdrive/assets/icons_mici/experimental_mode.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb42b8d6259238beb26f286dc28fb2dc8d91b00fec1f7a7655296b5769439a15 +size 15690 diff --git a/selfdrive/assets/icons_mici/eye.png b/selfdrive/assets/icons_mici/eye.png new file mode 100644 index 0000000000..db2953b690 --- /dev/null +++ b/selfdrive/assets/icons_mici/eye.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9c7f03c2784eac6882eb59d5f1c547008c12f19a861d04e9ca3549edb41218c +size 1988 diff --git a/selfdrive/assets/icons_mici/eye_crossed.png b/selfdrive/assets/icons_mici/eye_crossed.png new file mode 100644 index 0000000000..11197fcf57 --- /dev/null +++ b/selfdrive/assets/icons_mici/eye_crossed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f54bdb8dbb94682ff474174b8e3c605dba867f1b6613a3efcc5fbbedc462a95 +size 1811 diff --git a/selfdrive/assets/icons_mici/microphone.png b/selfdrive/assets/icons_mici/microphone.png new file mode 100644 index 0000000000..9718a6b135 --- /dev/null +++ b/selfdrive/assets/icons_mici/microphone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17b6fe530598cbad34bcf31d4f21f929b792aacedef51b3ffef1941c86017811 +size 7331 diff --git a/selfdrive/assets/icons_mici/notifications/blue_large.png b/selfdrive/assets/icons_mici/notifications/blue_large.png new file mode 100644 index 0000000000..e4aa33a135 --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/blue_large.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2566b38173e6048f54ef62dd68a158b07747638f74f2724406cfef02ae38022b +size 14052 diff --git a/selfdrive/assets/icons_mici/notifications/blue_small.png b/selfdrive/assets/icons_mici/notifications/blue_small.png new file mode 100644 index 0000000000..500f48e36f --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/blue_small.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7020a7ec5a1a31320a0cc2a381a79ed0d650bc220a9c60ae334c646868e12295 +size 10662 diff --git a/selfdrive/assets/icons_mici/notifications/icons/cable.png b/selfdrive/assets/icons_mici/notifications/icons/cable.png new file mode 100644 index 0000000000..72b64f0732 --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/icons/cable.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:028bb5611f41b0da7944f6bed42962cd894a57706a9940f124de7488a61b9b60 +size 1171 diff --git a/selfdrive/assets/icons_mici/notifications/icons/camera.png b/selfdrive/assets/icons_mici/notifications/icons/camera.png new file mode 100644 index 0000000000..fbe70ee826 --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/icons/camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8667594a61ed4680b55ef981085cb84db7d1c86dd28ed998a7e8441f03b09193 +size 2000 diff --git a/selfdrive/assets/icons_mici/notifications/icons/close.png b/selfdrive/assets/icons_mici/notifications/icons/close.png new file mode 100644 index 0000000000..e0f061a015 --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/icons/close.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:064cf235bfc30a08863a29ec7f94aa1f8cf7b6b68ee5eaad0a0c48740e9a0ce1 +size 2371 diff --git a/selfdrive/assets/icons_mici/notifications/icons/critical.png b/selfdrive/assets/icons_mici/notifications/icons/critical.png new file mode 100644 index 0000000000..8acab1854f --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/icons/critical.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77d7945ba3af1d0ecb4c52600fcfee80f9f8069f34dea4bce95fd0495ac6f80c +size 2596 diff --git a/selfdrive/assets/icons_mici/notifications/icons/eye.png b/selfdrive/assets/icons_mici/notifications/icons/eye.png new file mode 100644 index 0000000000..11197fcf57 --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/icons/eye.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f54bdb8dbb94682ff474174b8e3c605dba867f1b6613a3efcc5fbbedc462a95 +size 1811 diff --git a/selfdrive/assets/icons_mici/notifications/icons/fan.png b/selfdrive/assets/icons_mici/notifications/icons/fan.png new file mode 100644 index 0000000000..017da6c8c9 --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/icons/fan.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40ab18b92dda78353031c5df7b57db64a4a7f99820f696c4a4efe92cfa690109 +size 2465 diff --git a/selfdrive/assets/icons_mici/notifications/icons/temperature.png b/selfdrive/assets/icons_mici/notifications/icons/temperature.png new file mode 100644 index 0000000000..09d0d798d8 --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/icons/temperature.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b76a778921377508da133ad105247b238244fae86f9c0791f3ad05dd69802a6 +size 1975 diff --git a/selfdrive/assets/icons_mici/notifications/icons/wheel.png b/selfdrive/assets/icons_mici/notifications/icons/wheel.png new file mode 100644 index 0000000000..ec4741f7ff --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/icons/wheel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23424ed80a70ebfedb99f14e286a08dcf59167988683b81fa8a56b06e9b2051e +size 2638 diff --git a/selfdrive/assets/icons_mici/notifications/normal_large.png b/selfdrive/assets/icons_mici/notifications/normal_large.png new file mode 100644 index 0000000000..df25984030 --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/normal_large.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6aa200bbb3381eb0d6a72c412227cd99c8bde5e843f705b218c09ee98576804 +size 9796 diff --git a/selfdrive/assets/icons_mici/notifications/normal_small.png b/selfdrive/assets/icons_mici/notifications/normal_small.png new file mode 100644 index 0000000000..f96de2e304 --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/normal_small.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0486257b2e7735ad68c2bde66eb7bc710862c989feef856f06b0bf5d5231e7db +size 7369 diff --git a/selfdrive/assets/icons_mici/notifications/orange_large.png b/selfdrive/assets/icons_mici/notifications/orange_large.png new file mode 100644 index 0000000000..62f6dac634 --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/orange_large.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32e3d36a95215cff4fdbe4af01c86408e647ff57dcc8724b517fda453b4b01e0 +size 15069 diff --git a/selfdrive/assets/icons_mici/notifications/orange_small.png b/selfdrive/assets/icons_mici/notifications/orange_small.png new file mode 100644 index 0000000000..31fcf11a6a --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/orange_small.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f754f98cd3f7104f3567bf02c4972277a8f5740757e245a2b3a60f9d1f61506 +size 11308 diff --git a/selfdrive/assets/icons_mici/notifications/red_large.png b/selfdrive/assets/icons_mici/notifications/red_large.png new file mode 100644 index 0000000000..81cd5566fe --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/red_large.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f820799a20667a265d5ed9e0ebc6a46a6e898935f2fa920b66d001347b88704 +size 13410 diff --git a/selfdrive/assets/icons_mici/notifications/red_small.png b/selfdrive/assets/icons_mici/notifications/red_small.png new file mode 100644 index 0000000000..00b358c81c --- /dev/null +++ b/selfdrive/assets/icons_mici/notifications/red_small.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fb44ce698c02929ca0c4360cc493ad18a99bd8f5d75586a422961e6f17d1371 +size 10169 diff --git a/selfdrive/assets/icons_mici/offroad_alerts/big_alert.png b/selfdrive/assets/icons_mici/offroad_alerts/big_alert.png new file mode 100644 index 0000000000..142367d0e6 --- /dev/null +++ b/selfdrive/assets/icons_mici/offroad_alerts/big_alert.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aeee7f049879caff52320fab5f286cf3fd6a52c820cd8e150ff242e53a14176f +size 14774 diff --git a/selfdrive/assets/icons_mici/offroad_alerts/big_alert_pressed.png b/selfdrive/assets/icons_mici/offroad_alerts/big_alert_pressed.png new file mode 100644 index 0000000000..2ff01024d9 --- /dev/null +++ b/selfdrive/assets/icons_mici/offroad_alerts/big_alert_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b59ddada9c9e0e7972ead27396ebe6c10fd2352687b18ff6b476f61f74d80bf +size 46106 diff --git a/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png b/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png new file mode 100644 index 0000000000..6a8351f6ee --- /dev/null +++ b/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05f3626e790622a4ad90e982c4aacb612d0785a752339352a3187addf763e2e9 +size 13288 diff --git a/selfdrive/assets/icons_mici/offroad_alerts/medium_alert.png b/selfdrive/assets/icons_mici/offroad_alerts/medium_alert.png new file mode 100644 index 0000000000..91a7b43c5a --- /dev/null +++ b/selfdrive/assets/icons_mici/offroad_alerts/medium_alert.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46d08a8a08b42d466ff45d8ad6d2578e345dcbb1c06c126ad361873d9d35eaec +size 12877 diff --git a/selfdrive/assets/icons_mici/offroad_alerts/medium_alert_pressed.png b/selfdrive/assets/icons_mici/offroad_alerts/medium_alert_pressed.png new file mode 100644 index 0000000000..09ca2d08d5 --- /dev/null +++ b/selfdrive/assets/icons_mici/offroad_alerts/medium_alert_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42ea275e5fe0a8a0e2fddb5a4a8487806fb22850115ea3646ee8f213d5fd6bb6 +size 37202 diff --git a/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png b/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png new file mode 100644 index 0000000000..13af475c6d --- /dev/null +++ b/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a877882a8dccb884bd35918f9f9b427a724a59e90a638e54f6fd5d0680ad173c +size 12137 diff --git a/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png b/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png new file mode 100644 index 0000000000..83c3595b29 --- /dev/null +++ b/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba944b208abed9b8b9752adb8017bd29cd2e98c89fb07ee5d0a595185c7564a5 +size 11898 diff --git a/selfdrive/assets/icons_mici/offroad_alerts/small_alert.png b/selfdrive/assets/icons_mici/offroad_alerts/small_alert.png new file mode 100644 index 0000000000..0a50b6a1ca --- /dev/null +++ b/selfdrive/assets/icons_mici/offroad_alerts/small_alert.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cfbf38672a893fd1d8fadf942354d2511e2436814a9af0e5c188cbb427fc6c70 +size 12281 diff --git a/selfdrive/assets/icons_mici/offroad_alerts/small_alert_pressed.png b/selfdrive/assets/icons_mici/offroad_alerts/small_alert_pressed.png new file mode 100644 index 0000000000..865355ef01 --- /dev/null +++ b/selfdrive/assets/icons_mici/offroad_alerts/small_alert_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7102850bcfb075a285041cecb546559374a905403ab3b9814fa6097d2d822dd +size 34680 diff --git a/selfdrive/assets/icons_mici/onroad/blind_spot_left.png b/selfdrive/assets/icons_mici/onroad/blind_spot_left.png new file mode 100644 index 0000000000..5d3b1e5d7b --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/blind_spot_left.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a23743d21bc8160e013625210654a55634e4ed58e60057b70e08761bac1c3680 +size 40406 diff --git a/selfdrive/assets/icons_mici/onroad/blind_spot_right.png b/selfdrive/assets/icons_mici/onroad/blind_spot_right.png new file mode 100644 index 0000000000..67216078d9 --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/blind_spot_right.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:acbfa3e38f0b9f422f5c1335ce20013852df2892b813db176a51918adc83ad58 +size 40979 diff --git a/selfdrive/assets/icons_mici/onroad/bookmark.png b/selfdrive/assets/icons_mici/onroad/bookmark.png new file mode 100644 index 0000000000..207182276e --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/bookmark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0d00d743b01c49c2b739127e9916a229caf8c48346d6d168863b080ddcaa409 +size 11124 diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png new file mode 100644 index 0000000000..4d83ed5cd9 --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f27352a18194a1c819e9eaea89cfc11d2964402df0a28efa3ba60ae2d972fe67 +size 13108 diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png new file mode 100644 index 0000000000..a8a68b372c --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5aee9f6cec03f1967014cd2ea2a23982b262e7d86dadca602ecfa8875b38101 +size 5875 diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_cone.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_cone.png new file mode 100644 index 0000000000..ec2f948998 --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_cone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26b3660dbd1e60b0ba98914afa7cb3a67151bb6990d218f55c901f243e38ff3e +size 3631 diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png new file mode 100644 index 0000000000..7aa7f0542a --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25d66e42a28a3367eb40724d28652889089aa762438b475645269e0319c46009 +size 1431 diff --git a/selfdrive/assets/icons_mici/onroad/eye_fill.png b/selfdrive/assets/icons_mici/onroad/eye_fill.png new file mode 100644 index 0000000000..8f0e8ebfb1 --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/eye_fill.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51af75afbaf30abeaae1c99c7ad3e25cf5d5c90a2d6c799aad353b3302384b0a +size 4829 diff --git a/selfdrive/assets/icons_mici/onroad/eye_orange.png b/selfdrive/assets/icons_mici/onroad/eye_orange.png new file mode 100644 index 0000000000..b61b9b063c --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/eye_orange.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88b2ecf3a9834d2b156bb632ec2090d7dc112e8ab61711ba645c03489d1c457f +size 29157 diff --git a/selfdrive/assets/icons_mici/onroad/glasses.png b/selfdrive/assets/icons_mici/onroad/glasses.png new file mode 100644 index 0000000000..1ac4442f49 --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/glasses.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28c95c8970648d40b35b94724936a9ab7a6f4cbca367a40f01b86f9abedc70e5 +size 1587 diff --git a/selfdrive/assets/icons_mici/onroad/onroad_fade.png b/selfdrive/assets/icons_mici/onroad/onroad_fade.png new file mode 100644 index 0000000000..bc12e57e17 --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/onroad_fade.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2a2cb4db429467783d7f721ffbed7838551e4aabf32771e73759c87b4a67bca +size 28880 diff --git a/selfdrive/assets/icons_mici/onroad/sunglasses.png b/selfdrive/assets/icons_mici/onroad/sunglasses.png new file mode 100644 index 0000000000..15e502d617 --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/sunglasses.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b520b8a00ca245f1dcccca4ddbf1b1b6f8da9fb8b6ac9ea351e735db61641e6 +size 1006 diff --git a/selfdrive/assets/icons_mici/onroad/turn_signal_left.png b/selfdrive/assets/icons_mici/onroad/turn_signal_left.png new file mode 100644 index 0000000000..48f52ff9ce --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/turn_signal_left.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e845a211cf5d03f781efdd6eec4f8106e8dd85799ea59b51834a9099b479141 +size 30348 diff --git a/selfdrive/assets/icons_mici/onroad/turn_signal_right.png b/selfdrive/assets/icons_mici/onroad/turn_signal_right.png new file mode 100644 index 0000000000..87ca979fbe --- /dev/null +++ b/selfdrive/assets/icons_mici/onroad/turn_signal_right.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:009005539f14acc29a4f5510b4e9531d2ba3667133644f6e0069c12b08ba0fd9 +size 35370 diff --git a/selfdrive/assets/icons_mici/settings.png b/selfdrive/assets/icons_mici/settings.png new file mode 100644 index 0000000000..e668ed1fe4 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38a52171bdc6feb3ddfd2d9f9e59db3dabd09fa0aafbc9f81137c59bd03b7c26 +size 2321 diff --git a/selfdrive/assets/icons_mici/settings/comma_icon.png b/selfdrive/assets/icons_mici/settings/comma_icon.png new file mode 100644 index 0000000000..72a7c8c8f9 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/comma_icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10f469a6f5d25d9e2b0b1aae51b4fbd06d2c7b8417613bb321c2a30bb7298dab +size 1392 diff --git a/selfdrive/assets/icons_mici/settings/developer/adb.png b/selfdrive/assets/icons_mici/settings/developer/adb.png new file mode 100644 index 0000000000..b3a7801467 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/developer/adb.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19b304727376ea30126e7aeb10d1193885ffa661ca5bdf4c09098e4412d2ab6a +size 2163 diff --git a/selfdrive/assets/icons_mici/settings/developer/debug_mode.png b/selfdrive/assets/icons_mici/settings/developer/debug_mode.png new file mode 100644 index 0000000000..2849d2733c --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/developer/debug_mode.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bbdcedeb5f6f5844a8f67751f5d47793fe5af5e25ac6e7fd1ffc5857a85d56e +size 2997 diff --git a/selfdrive/assets/icons_mici/settings/developer/ssh.png b/selfdrive/assets/icons_mici/settings/developer/ssh.png new file mode 100644 index 0000000000..cd86937aea --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/developer/ssh.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c655994336b7da4ca986c6f27494bcab66e77f016ec9db8df271de53ed93e517 +size 1328 diff --git a/selfdrive/assets/icons_mici/settings/developer_icon.png b/selfdrive/assets/icons_mici/settings/developer_icon.png new file mode 100644 index 0000000000..af16c02912 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/developer_icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1f058c5640bd763d2f6927432a1daff1587770ea0d06f2e351a28462e9d8335 +size 1743 diff --git a/selfdrive/assets/icons_mici/settings/device/cameras.png b/selfdrive/assets/icons_mici/settings/device/cameras.png new file mode 100644 index 0000000000..c44c511275 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/cameras.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77a1281979f0b50f0e109ead56a88a33b81ef5901dd1a4537eb3fa048e0d90de +size 1345 diff --git a/selfdrive/assets/icons_mici/settings/device/cancel.png b/selfdrive/assets/icons_mici/settings/device/cancel.png new file mode 100644 index 0000000000..6da29ad66f --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/cancel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be9669cff5fc8a0b587dee27d1afb1caa77ceff2f92ca0ce3f01d25659e96596 +size 1332 diff --git a/selfdrive/assets/icons_mici/settings/device/downloading.png b/selfdrive/assets/icons_mici/settings/device/downloading.png new file mode 100644 index 0000000000..2db5856989 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/downloading.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a355a3960aea41b176be66d85a3e7bc83ec843bfb1e5bd4c7978f4ea41c5d1a2 +size 2756 diff --git a/selfdrive/assets/icons_mici/settings/device/fcc_logo.png b/selfdrive/assets/icons_mici/settings/device/fcc_logo.png new file mode 100644 index 0000000000..f29b24fd09 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/fcc_logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3cac8546d19e75a9edcbc0721a887fd74c8a3c41bfe19e36186b2b2bcabdae98 +size 1817 diff --git a/selfdrive/assets/icons_mici/settings/device/info.png b/selfdrive/assets/icons_mici/settings/device/info.png new file mode 100644 index 0000000000..cb16320693 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/info.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2649d36259700d32a0edef878647e76492b1bec2fe34ac8ea806d4e7e4c57855 +size 2668 diff --git a/selfdrive/assets/icons_mici/settings/device/language.png b/selfdrive/assets/icons_mici/settings/device/language.png new file mode 100644 index 0000000000..f6d57b3134 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/language.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b982ac1b78b45487490d1dbbffed1f68735f6a35def502e882f706c30683aff +size 3664 diff --git a/selfdrive/assets/icons_mici/settings/device/lkas.png b/selfdrive/assets/icons_mici/settings/device/lkas.png new file mode 100644 index 0000000000..186ea78fb9 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/lkas.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab6aeb6cba94acf948a0ad64a485db00bf1f3de1360ae4c57212f3f083b2bd24 +size 2554 diff --git a/selfdrive/assets/icons_mici/settings/device/pair.png b/selfdrive/assets/icons_mici/settings/device/pair.png new file mode 100644 index 0000000000..f072b2363f --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/pair.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed671f4ad1523f0e66498af39e6075a0c19842ae05eddd00871a6e48ed3685d7 +size 1594 diff --git a/selfdrive/assets/icons_mici/settings/device/power.png b/selfdrive/assets/icons_mici/settings/device/power.png new file mode 100644 index 0000000000..a2de14a4e8 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/power.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b45645ad9ff27776fdb1caa27827c526cae57f8bd4e23bd1160cb0094121ff2 +size 2338 diff --git a/selfdrive/assets/icons_mici/settings/device/reboot.png b/selfdrive/assets/icons_mici/settings/device/reboot.png new file mode 100644 index 0000000000..6c89cd9fc2 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/reboot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f24039f82d7399d02a155022de65b6dc3b8edcf17059a73a9fd3a9209e3f5575 +size 2360 diff --git a/selfdrive/assets/icons_mici/settings/device/uninstall.png b/selfdrive/assets/icons_mici/settings/device/uninstall.png new file mode 100644 index 0000000000..f9173711eb --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/uninstall.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:558ea538fb258079f9eb05fe048b2806c7635b9f0452af874b00cb8d79b45f9b +size 2421 diff --git a/selfdrive/assets/icons_mici/settings/device/up_to_date.png b/selfdrive/assets/icons_mici/settings/device/up_to_date.png new file mode 100644 index 0000000000..ee925458d3 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/up_to_date.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4510e65775c6001758ebcf4dc13e9fa561cce5159d1fd54fbb506f22d3c7bdf3 +size 3149 diff --git a/selfdrive/assets/icons_mici/settings/device/update.png b/selfdrive/assets/icons_mici/settings/device/update.png new file mode 100644 index 0000000000..cc05931b03 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device/update.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6137349218ea22adba44f46a096afe2efc35536b2251192ed0ea61be443a3c5 +size 2493 diff --git a/selfdrive/assets/icons_mici/settings/device_icon.png b/selfdrive/assets/icons_mici/settings/device_icon.png new file mode 100644 index 0000000000..0caf0d07ce --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/device_icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db20bea98259b204be634ce0d9a23fbfdcfc73a324fc0aac0f9ac54e1c51556d +size 2443 diff --git a/selfdrive/assets/icons_mici/settings/keyboard/back.png b/selfdrive/assets/icons_mici/settings/keyboard/back.png new file mode 100644 index 0000000000..fccc2484df --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/keyboard/back.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de48d508af786b39fb725022b179e31456f32551a49b96ae07b5f55bfb968699 +size 1814 diff --git a/selfdrive/assets/icons_mici/settings/keyboard/backspace.png b/selfdrive/assets/icons_mici/settings/keyboard/backspace.png new file mode 100644 index 0000000000..342f8e28da --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/keyboard/backspace.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:116bbbd1509e6644f7b65b8dacd2402b0918785bd80207504a99ab7e13ab738f +size 2049 diff --git a/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png b/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png new file mode 100644 index 0000000000..d63cc56fbc --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e8c7fec57640de6bfa8d0ede977e40920a8e651b68ed14e3d6c1850e702f3e3 +size 1399 diff --git a/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png b/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png new file mode 100644 index 0000000000..eb38934302 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7dab3af28938e9c3ad7b6c3b60526bb76498b0103c7276d90c4bff3622f07d0 +size 1157 diff --git a/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png b/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png new file mode 100644 index 0000000000..4a2cae6c8a --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c5a88a0e8e810115b6d497d3e230d866bd96a715ddac632f48c78b40e1df702 +size 1059 diff --git a/selfdrive/assets/icons_mici/settings/keyboard/confirm.png b/selfdrive/assets/icons_mici/settings/keyboard/confirm.png new file mode 100644 index 0000000000..09b180e97f --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/keyboard/confirm.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32ce109a9fe4814bb9bed88f67d85292791f4a6d7c162e07561920221ac38b2d +size 1411 diff --git a/selfdrive/assets/icons_mici/settings/keyboard/keyboard_background.png b/selfdrive/assets/icons_mici/settings/keyboard/keyboard_background.png new file mode 100644 index 0000000000..8c2c068d41 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/keyboard/keyboard_background.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:399e7ff9dea6710244827c91014f1a08d8ae989dce922928d6b7f7504b15ba79 +size 11321 diff --git a/selfdrive/assets/icons_mici/settings/keyboard/space.png b/selfdrive/assets/icons_mici/settings/keyboard/space.png new file mode 100644 index 0000000000..778d1847d7 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/keyboard/space.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b04d17f3b0340a94210efa5c9547e0ac340dd6b6dd9ac1f81ba5eb3f89f405d +size 619 diff --git a/selfdrive/assets/icons_mici/settings/manual_icon.png b/selfdrive/assets/icons_mici/settings/manual_icon.png new file mode 100644 index 0000000000..100b29da45 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/manual_icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:957330e9fbc8c03f05dbef8097178a40efc0fc52a6faf7a9917f97046d9a5e99 +size 1559 diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png new file mode 100644 index 0000000000..4bf0cd8726 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a981d5c5558859b283cb6321c84eec947f82fc2dea8dbdd19b66781e4d3f61f +size 1060 diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png new file mode 100644 index 0000000000..df6d009335 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58da16ede432cf89096c11dc0f4ea098735863fb09a1d655cb06de8a112bd263 +size 1205 diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png new file mode 100644 index 0000000000..c3323a9fea --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:031bbd50c34d8fd5e71bdc292ba3e50b28a13c56a48dc84117723f1b35b42f51 +size 1224 diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png new file mode 100644 index 0000000000..64ab947c53 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ccb5f2227c72dd28e40c9f19965abe007cbd7b47cdca924907dc9fad906f5c81 +size 1219 diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png new file mode 100644 index 0000000000..6cdef706bd --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92c195721fe2b4ca42176077bf4ca3484cdfc314e961f1431b2296476bcae891 +size 1178 diff --git a/selfdrive/assets/icons_mici/settings/network/connect.png b/selfdrive/assets/icons_mici/settings/network/connect.png new file mode 100644 index 0000000000..f7beaf3923 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/connect.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e47a5cb8b9ec784b6b893463622b7967a3692979b8a5e46e3334a09f745f1f71 +size 5413 diff --git a/selfdrive/assets/icons_mici/settings/network/connect_disabled.png b/selfdrive/assets/icons_mici/settings/network/connect_disabled.png new file mode 100644 index 0000000000..0563668bed --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/connect_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05ddc9456627c0773dddcb10469e78124f9788381e63cab163ce4c6407a000f4 +size 3201 diff --git a/selfdrive/assets/icons_mici/settings/network/connect_pressed.png b/selfdrive/assets/icons_mici/settings/network/connect_pressed.png new file mode 100644 index 0000000000..aef242b6d8 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/connect_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cab1783614421ab86467c3686f4d11f439d6de2cce3a84801dec7e044c08c880 +size 9075 diff --git a/selfdrive/assets/icons_mici/settings/network/forget_pill.png b/selfdrive/assets/icons_mici/settings/network/forget_pill.png new file mode 100644 index 0000000000..a80de07634 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/forget_pill.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2645cc56688a6225ba7ba0b4021af6543657942f31892a4986061a2959511054 +size 12106 diff --git a/selfdrive/assets/icons_mici/settings/network/forget_pill_pressed.png b/selfdrive/assets/icons_mici/settings/network/forget_pill_pressed.png new file mode 100644 index 0000000000..a1240481e5 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/forget_pill_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42cea7a190be02027a1cc8d6daca435f4edfb5b9484c26a06e667a2346c91f0f +size 38471 diff --git a/selfdrive/assets/icons_mici/settings/network/new/connect_button.png b/selfdrive/assets/icons_mici/settings/network/new/connect_button.png new file mode 100644 index 0000000000..eae5af77f0 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/new/connect_button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04236fa0f2759a01c6e321ac7b1c86c7a039215a7953b1a23d250ecf2ef1fa87 +size 8563 diff --git a/selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png b/selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png new file mode 100644 index 0000000000..0da6c384d9 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4337098554af30c98ebd512e17ab08207db868ff34acca5f865fcbfc940286d3 +size 21123 diff --git a/selfdrive/assets/icons_mici/settings/network/new/forget_button.png b/selfdrive/assets/icons_mici/settings/network/new/forget_button.png new file mode 100644 index 0000000000..541433be76 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/new/forget_button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ccb5f2298389ae36df87de84d85440ee5a82c50e803c9bd362c9b89ea45aa69 +size 6611 diff --git a/selfdrive/assets/icons_mici/settings/network/new/forget_button_pressed.png b/selfdrive/assets/icons_mici/settings/network/new/forget_button_pressed.png new file mode 100644 index 0000000000..26cc8b4fca --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/new/forget_button_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9f17c82b2f349d107d27c69418f054be1f1753f970c7d3d3520c1e65de00511 +size 12894 diff --git a/selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png new file mode 100644 index 0000000000..905170fd10 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ffd37d5e5d5980efa98fee1cd0e8ebbf4139149b41c099e7dc3d5bd402cffb92 +size 9072 diff --git a/selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png new file mode 100644 index 0000000000..88eb4ac2a3 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b1d58704f8808dcb5a7ce9d86bc4212477759e96ac2419475f16f9184ee6a42 +size 21892 diff --git a/selfdrive/assets/icons_mici/settings/network/new/lock.png b/selfdrive/assets/icons_mici/settings/network/new/lock.png new file mode 100644 index 0000000000..0a0b18c7a9 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/new/lock.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40dbbb3000e1137ec11fe658fbfebae7cadfc91356953317335f9bb70fcb40d3 +size 1235 diff --git a/selfdrive/assets/icons_mici/settings/network/new/trash.png b/selfdrive/assets/icons_mici/settings/network/new/trash.png new file mode 100644 index 0000000000..99e1a2e246 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/new/trash.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:efabf98ed66fe4447c0f13c74aec681b084de780c551ce18258c79636d4123c5 +size 1524 diff --git a/selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png b/selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png new file mode 100644 index 0000000000..2a3e837138 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:160f67162e075436200d6719e614ddf96caaa2b7c0a3943f728c2afef10aa4ad +size 2489 diff --git a/selfdrive/assets/icons_mici/settings/network/tethering.png b/selfdrive/assets/icons_mici/settings/network/tethering.png new file mode 100644 index 0000000000..9e7b90be41 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/tethering.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2907ce46d1b6e676402f390c530955b65e76baf0b77fafc0616c50b988b3994c +size 1609 diff --git a/selfdrive/assets/icons_mici/settings/network/trash.png b/selfdrive/assets/icons_mici/settings/network/trash.png new file mode 100644 index 0000000000..99e1a2e246 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/trash.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:efabf98ed66fe4447c0f13c74aec681b084de780c551ce18258c79636d4123c5 +size 1524 diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png new file mode 100644 index 0000000000..1a1655fddc --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f2715ea698eccb3648ab96cbddf897ea1842acbc1eb9667bc6f34aba82d0896b +size 1976 diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png new file mode 100644 index 0000000000..4d64d8062f --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58d839402c6f002ba8d2217888190b338fc3ac13d372df0988fac7bf95b89302 +size 2111 diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png new file mode 100644 index 0000000000..2d53a20cef --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a9918724409dbfa1973a097a692c2f57e45cc2bc0ce71c498ef3e02aa82559d3 +size 2128 diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png new file mode 100644 index 0000000000..482a0e1042 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fcef95eb18e2db566b907ae99b8d8f450424b3b7823fdc24cdfe066ccf64378 +size 2141 diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png new file mode 100644 index 0000000000..38ddff84b7 --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73e4ae4741a039f41d79827c40be6da83f8c6eb79e9103db2dfec718ca96efb7 +size 2512 diff --git a/selfdrive/assets/icons_mici/settings/toggles_icon.png b/selfdrive/assets/icons_mici/settings/toggles_icon.png new file mode 100644 index 0000000000..ccb343e8ed --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/toggles_icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0297535eb73bea71e87c363dc12385bb9163b81403797e50966b20259f725542 +size 2528 diff --git a/selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png b/selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png new file mode 100644 index 0000000000..77d9a77d6f --- /dev/null +++ b/selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88e6c50358f627fc714c1e9883143aeed00baabeab16132e16001aa1051e5eb8 +size 1272 diff --git a/selfdrive/assets/icons_mici/setup/arrow.png b/selfdrive/assets/icons_mici/setup/arrow.png new file mode 100644 index 0000000000..403aaedfb2 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/arrow.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7aee85c239edcb7be41f03a4982b136d9a46908b027f37c5d36c80bd20372b22 +size 847 diff --git a/selfdrive/assets/icons_mici/setup/back.png b/selfdrive/assets/icons_mici/setup/back.png new file mode 100644 index 0000000000..554c63e099 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/back.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f88a958dd51eaf2c3f39df72165f538110c1d1eb4dbcac1f7016563db126c762 +size 1693 diff --git a/selfdrive/assets/icons_mici/setup/back_new.png b/selfdrive/assets/icons_mici/setup/back_new.png new file mode 100644 index 0000000000..c4834a5649 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/back_new.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7198352d23952d0f2fbc128f20523ea6f2f2b7e378aa495da748a0e34f192806 +size 1641 diff --git a/selfdrive/assets/icons_mici/setup/green_button.png b/selfdrive/assets/icons_mici/setup/green_button.png new file mode 100644 index 0000000000..9708cfe284 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/green_button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:163ac31cb990bdddfe552efef9a68870404caadb1c40fa8a5042b5ae956e6b4c +size 24687 diff --git a/selfdrive/assets/icons_mici/setup/green_button_pressed.png b/selfdrive/assets/icons_mici/setup/green_button_pressed.png new file mode 100644 index 0000000000..030ce61d5b --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/green_button_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e4614adb2d3d0e44c64a855c221ec462a7aee22fff26132ad551035141c1a53 +size 62056 diff --git a/selfdrive/assets/icons_mici/setup/green_car.png b/selfdrive/assets/icons_mici/setup/green_car.png new file mode 100644 index 0000000000..867cadbbd6 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/green_car.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce8a34777e0b185f457b98845aa17fe6b5192ca46101463aecd21a9e04c0f0f0 +size 13281 diff --git a/selfdrive/assets/icons_mici/setup/green_dm.png b/selfdrive/assets/icons_mici/setup/green_dm.png new file mode 100644 index 0000000000..d41edd4c2a --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/green_dm.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78795eaa5e0be5fa369e172c02f5bd4b06d20f44363ccb8cbd02cb181b13e529 +size 14289 diff --git a/selfdrive/assets/icons_mici/setup/green_info.png b/selfdrive/assets/icons_mici/setup/green_info.png new file mode 100644 index 0000000000..309e56e6ee --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/green_info.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b0b1777d5bed7149982af9f2abab3fab7b6c576e3d53cf2c459804c6ec9ca1e +size 3957 diff --git a/selfdrive/assets/icons_mici/setup/green_pedal.png b/selfdrive/assets/icons_mici/setup/green_pedal.png new file mode 100644 index 0000000000..2dd18f489a --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/green_pedal.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6cadcda59bc861a1e710e0a8ac67024bdcc44b5f9261abbf098ff11cefb1da51 +size 12209 diff --git a/selfdrive/assets/icons_mici/setup/medium_button_bg.png b/selfdrive/assets/icons_mici/setup/medium_button_bg.png new file mode 100644 index 0000000000..e79dc2eb58 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/medium_button_bg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e363a79dc35ca4c4e9efaa6a843d37ad219efa5299d3e538d8249affa230096 +size 7935 diff --git a/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png b/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png new file mode 100644 index 0000000000..e52fb0c17d --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc6fb48520143b6fa1f060d8212e6d929917ab616ce943b5fab5a60665f00da5 +size 18225 diff --git a/selfdrive/assets/icons_mici/setup/reboot.png b/selfdrive/assets/icons_mici/setup/reboot.png new file mode 100644 index 0000000000..5633f2b499 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/reboot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:345bea231013aab33b98a134a05af22c2df7c166b60a1a81b6b4526da13209a5 +size 2293 diff --git a/selfdrive/assets/icons_mici/setup/red_warning.png b/selfdrive/assets/icons_mici/setup/red_warning.png new file mode 100644 index 0000000000..ed0634079b --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/red_warning.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:448d3e7214a77b02b32020ddb440ccd8fe72e110493a51cc10901c8242e72ca8 +size 3185 diff --git a/selfdrive/assets/icons_mici/setup/reset/small_button.png b/selfdrive/assets/icons_mici/setup/reset/small_button.png new file mode 100644 index 0000000000..e3f58b1078 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/reset/small_button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a198f13f30b3dbc09f30d7fd8033a0bc07a0da9b010b7ca6ed2678430c9e5b4 +size 6949 diff --git a/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png b/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png new file mode 100644 index 0000000000..5b502e00aa --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75289d004709def2a2d6101a0330ec867895068ec3807aefc2a26d423d907a13 +size 13437 diff --git a/selfdrive/assets/icons_mici/setup/reset/wide_button.png b/selfdrive/assets/icons_mici/setup/reset/wide_button.png new file mode 100644 index 0000000000..3892f6eb8c --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/reset/wide_button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2452aaf59da18be1b74b475851d66e5c73c50aa49820419a288b1fdb7b42dee1 +size 9071 diff --git a/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png b/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png new file mode 100644 index 0000000000..3a34af8846 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6478f7c1c5ef2013e94fc4218ab370889883c5c12231ba3e0975874cb0b6fec9 +size 21893 diff --git a/selfdrive/assets/icons_mici/setup/restore.png b/selfdrive/assets/icons_mici/setup/restore.png new file mode 100644 index 0000000000..6aa6c6b851 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/restore.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d6b99696163cac1867d46998af9e53e212b82641b33c93b51276671f400a5ac +size 2962 diff --git a/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png b/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png new file mode 100644 index 0000000000..4d74d86075 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52535e34e27b0341f7690a72dc16555eeb6e032bc2c2cde0786469852fdf5987 +size 1267 diff --git a/selfdrive/assets/icons_mici/setup/small_button.png b/selfdrive/assets/icons_mici/setup/small_button.png new file mode 100644 index 0000000000..1ee01aeac2 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/small_button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13919cf5df3137fdffdb8cc53a1215f13bf478a780ca8614234b7af0cdc0e766 +size 5409 diff --git a/selfdrive/assets/icons_mici/setup/small_button_disabled.png b/selfdrive/assets/icons_mici/setup/small_button_disabled.png new file mode 100644 index 0000000000..5028a8cd21 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/small_button_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9728423bd5e3197ef02d62e4bae415e6694aab875ca8630ffc9f188c38e18e5f +size 4141 diff --git a/selfdrive/assets/icons_mici/setup/small_button_pressed.png b/selfdrive/assets/icons_mici/setup/small_button_pressed.png new file mode 100644 index 0000000000..6e30f47fba --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/small_button_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c032fd71ccfebe161827de420771e7927fe1ed799e615e24d458cfd79fead7f7 +size 7875 diff --git a/selfdrive/assets/icons_mici/setup/small_red_pill.png b/selfdrive/assets/icons_mici/setup/small_red_pill.png new file mode 100644 index 0000000000..4a7db930a0 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/small_red_pill.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3a336afddad80dc91caca91d54bd29897ce491f180374edf9a5ba517cbc00e9 +size 8765 diff --git a/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png b/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png new file mode 100644 index 0000000000..a8d51960c4 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8eee9f10ca80a4e6100c00c02bb46aa5f253b14b086ab9982cfa85ee94eec162 +size 22512 diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png new file mode 100644 index 0000000000..bbf1d96254 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8425c56cb413ba757c94febe0332ce472dbf1472236b03cc4e627746fb86d701 +size 1149 diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow_outline.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow_outline.png new file mode 100644 index 0000000000..1515867a40 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow_outline.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6394fa9dc03f83ac8599cf8179cdcc221c1b80b2c00d396b04bf2e3a7bfdca4 +size 1673 diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png new file mode 100644 index 0000000000..43c10a54ad --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:94a86fac6ffe8a8179812cf55350ab9ca6935f36244c6f679c1cf521a842316b +size 5723 diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_bg_larger.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg_larger.png new file mode 100644 index 0000000000..11c3ae2d3f --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg_larger.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:950d55fd7294fb05c10ba9944537c02637776497c159e1b7d145c73f0f9d3253 +size 7119 diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle.png new file mode 100644 index 0000000000..683587a060 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61281d3e3ef5ac5a8fe75405a93c2096bf235f090b27832e986444e3fb85715e +size 7427 diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png new file mode 100644 index 0000000000..9ebff76b50 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcd08444c77b3e559876eeb88d17808f72496adc26e27c3c21c00ff410879447 +size 10966 diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png new file mode 100644 index 0000000000..541433be76 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ccb5f2298389ae36df87de84d85440ee5a82c50e803c9bd362c9b89ea45aa69 +size 6611 diff --git a/selfdrive/assets/icons_mici/setup/smaller_button.png b/selfdrive/assets/icons_mici/setup/smaller_button.png new file mode 100644 index 0000000000..9b4851c568 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/smaller_button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89ca7e6bb01dfa78300126ce828cb2a64e7a2e68e1e9152de242f57a36d0e57a +size 8604 diff --git a/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png b/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png new file mode 100644 index 0000000000..6514791de7 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3242a411b559f1d0308f189fe0d25b81d6c7d964ca418a0c599a1bab4bffcbb +size 5341 diff --git a/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png b/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png new file mode 100644 index 0000000000..64235b3a2f --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d354651c0c8107dcc5f599777d260f53ef1901123315785ed8190466166cdce8 +size 17554 diff --git a/selfdrive/assets/icons_mici/setup/warning.png b/selfdrive/assets/icons_mici/setup/warning.png new file mode 100644 index 0000000000..806eea28b7 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/warning.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3bc7a85a0672183d80817f337084060465e143362037955025c11bc8ac531076 +size 3247 diff --git a/selfdrive/assets/icons_mici/setup/widish_button.png b/selfdrive/assets/icons_mici/setup/widish_button.png new file mode 100644 index 0000000000..529b7c80cc --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/widish_button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74fc21132b1e761ea54ce64617730c6ee79d01668244ab555b3b89870cfea181 +size 7112 diff --git a/selfdrive/assets/icons_mici/setup/widish_button_disabled.png b/selfdrive/assets/icons_mici/setup/widish_button_disabled.png new file mode 100644 index 0000000000..5028a8cd21 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/widish_button_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9728423bd5e3197ef02d62e4bae415e6694aab875ca8630ffc9f188c38e18e5f +size 4141 diff --git a/selfdrive/assets/icons_mici/setup/widish_button_pressed.png b/selfdrive/assets/icons_mici/setup/widish_button_pressed.png new file mode 100644 index 0000000000..1095d4fc23 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/widish_button_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ff179f93f421edcb503ca5c22a12b37e3a2aaabc414bf90f57e20ff5255dd75 +size 15572 diff --git a/selfdrive/assets/icons_mici/turn_intent_left.png b/selfdrive/assets/icons_mici/turn_intent_left.png new file mode 100644 index 0000000000..6c2c47e882 --- /dev/null +++ b/selfdrive/assets/icons_mici/turn_intent_left.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ead8287b7041c32456e13721c238a71933256ca3d2b7e649c8f8731585eb5de8 +size 906 diff --git a/selfdrive/assets/icons_mici/turn_intent_right.png b/selfdrive/assets/icons_mici/turn_intent_right.png new file mode 100644 index 0000000000..03a7245e76 --- /dev/null +++ b/selfdrive/assets/icons_mici/turn_intent_right.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fe0532f7040aae78baa85c4cca44f5c939adb6a6f15889e2ca036f4a493f848 +size 935 diff --git a/selfdrive/assets/icons_mici/wheel.png b/selfdrive/assets/icons_mici/wheel.png new file mode 100644 index 0000000000..f122349b82 --- /dev/null +++ b/selfdrive/assets/icons_mici/wheel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc3ef0c8c3038d75f99df2c565a361107bc903944d1afe91de0cbed9f6ca062a +size 2725 diff --git a/selfdrive/assets/icons_mici/wheel_critical.png b/selfdrive/assets/icons_mici/wheel_critical.png new file mode 100644 index 0000000000..c0e5e8619e --- /dev/null +++ b/selfdrive/assets/icons_mici/wheel_critical.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12783dc05ea6dae2647ac3a3a7c8391d520c3f0cf2f458333a357ee9633eb6c4 +size 10909 diff --git a/selfdrive/assets/offroad/mici_fcc.html b/selfdrive/assets/offroad/mici_fcc.html new file mode 100644 index 0000000000..e6e4189128 --- /dev/null +++ b/selfdrive/assets/offroad/mici_fcc.html @@ -0,0 +1,16 @@ +

HVIN: comma four

+

FCC ID: 2BFC6-MICI

+

IC: 32232-MICI

+

Contains FCC ID: XMR2023EG916QGL

+

Contains IC: 10224A-023EG916QGL

+

+This device contains licence-exempt transmitter(s)/receiver(s) that comply with Innovation, Science and Economic Development +Canada's licence-exempt RSS(s) and complies with part 15 of the FCC Rules. Operation is subject to the following two conditions:
+1. This device may not cause harmful interference.
+2. This device must accept any interference received, including interference that may cause undesired operation of the device.
+

+L'émetteur/récepteur exempt de licence contenu dans le présent appareil est conforme aux CNR d'Innovation, Sciences +et Développement économique Canada applicables aux appareils radio exempts de licence. L'exploitation est autorisée +aux deux conditions suivantes :
+1. L'appareil ne doit pas produire de brouillage.
+2. L'appareil doit accepter tout brouillage radioélectrique subi, même si le brouillage est susceptible d'en compromettre
diff --git a/selfdrive/assets/sounds/disengage.wav b/selfdrive/assets/sounds/disengage.wav index f3b5f21a27..8983884b25 100644 --- a/selfdrive/assets/sounds/disengage.wav +++ b/selfdrive/assets/sounds/disengage.wav @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f061777d66d8d856a5fcb17378416f959088885ed770181355195ad15de881b -size 68628 +oid sha256:c94582be9d921146b3c356e08a7352700c309cb407877c1180542811b2d637fa +size 48078 diff --git a/selfdrive/assets/sounds/disengage_tizi.wav b/selfdrive/assets/sounds/disengage_tizi.wav new file mode 100644 index 0000000000..f3b5f21a27 --- /dev/null +++ b/selfdrive/assets/sounds/disengage_tizi.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f061777d66d8d856a5fcb17378416f959088885ed770181355195ad15de881b +size 68628 diff --git a/selfdrive/assets/sounds/engage.wav b/selfdrive/assets/sounds/engage.wav index fc24a23c2f..39d4c749c8 100644 --- a/selfdrive/assets/sounds/engage.wav +++ b/selfdrive/assets/sounds/engage.wav @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b54a85cc09b8ee79fce23d48209205d2e70510eed4c5a86fa400fe3f7caebfd -size 63120 +oid sha256:bc2b12bfe816a79307660b6b3d2de87a7643c6ccbfc9d1b33804645ad717682a +size 48078 diff --git a/selfdrive/assets/sounds/engage_tizi.wav b/selfdrive/assets/sounds/engage_tizi.wav new file mode 100644 index 0000000000..fc24a23c2f --- /dev/null +++ b/selfdrive/assets/sounds/engage_tizi.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b54a85cc09b8ee79fce23d48209205d2e70510eed4c5a86fa400fe3f7caebfd +size 63120 diff --git a/selfdrive/ui/SConscript b/selfdrive/ui/SConscript index 8a5f5793a5..0de3e13c01 100644 --- a/selfdrive/ui/SConscript +++ b/selfdrive/ui/SConscript @@ -56,12 +56,16 @@ if GetOption('extras'): "ld -r -b binary -o $TARGET $SOURCE") inter = raylib_env.Command("installer/inter_ttf.o", "installer/inter-ascii.ttf", "ld -r -b binary -o $TARGET $SOURCE") + inter_bold = raylib_env.Command("installer/inter_bold.o", "../assets/fonts/Inter-Bold.ttf", + "ld -r -b binary -o $TARGET $SOURCE") + inter_light = raylib_env.Command("installer/inter_light.o", "../assets/fonts/Inter-Light.ttf", + "ld -r -b binary -o $TARGET $SOURCE") for name, branch in installers: d = {'BRANCH': f"'\"{branch}\"'"} if "internal" in name: d['INTERNAL'] = "1" obj = raylib_env.Object(f"installer/installers/installer_{name}.o", ["installer/installer.cc"], CPPDEFINES=d) - f = raylib_env.Program(f"installer/installers/installer_{name}", [obj, cont, inter], LIBS=raylib_libs) + f = raylib_env.Program(f"installer/installers/installer_{name}", [obj, cont, inter, inter_bold, inter_light], LIBS=raylib_libs) # keep installers small assert f[0].get_size() < 1900*1e3, f[0].get_size() diff --git a/selfdrive/ui/installer/installer.cc b/selfdrive/ui/installer/installer.cc index 5cb0a38e0b..38dd1ce25c 100644 --- a/selfdrive/ui/installer/installer.cc +++ b/selfdrive/ui/installer/installer.cc @@ -36,8 +36,17 @@ extern const uint8_t str_continue[] asm("_binary_selfdrive_ui_installer_continue extern const uint8_t str_continue_end[] asm("_binary_selfdrive_ui_installer_continue_openpilot_sh_end"); extern const uint8_t inter_ttf[] asm("_binary_selfdrive_ui_installer_inter_ascii_ttf_start"); extern const uint8_t inter_ttf_end[] asm("_binary_selfdrive_ui_installer_inter_ascii_ttf_end"); +extern const uint8_t inter_light_ttf[] asm("_binary_selfdrive_assets_fonts_Inter_Light_ttf_start"); +extern const uint8_t inter_light_ttf_end[] asm("_binary_selfdrive_assets_fonts_Inter_Light_ttf_end"); +extern const uint8_t inter_bold_ttf[] asm("_binary_selfdrive_assets_fonts_Inter_Bold_ttf_start"); +extern const uint8_t inter_bold_ttf_end[] asm("_binary_selfdrive_assets_fonts_Inter_Bold_ttf_end"); -Font font; +Font font_inter; +Font font_roman; +Font font_display; + +const bool tici_device = Hardware::get_device_type() == cereal::InitData::DeviceType::TICI || + Hardware::get_device_type() == cereal::InitData::DeviceType::TIZI; std::vector tici_prebuilt_branches = {"release3", "release-tizi", "release3-staging", "nightly", "nightly-dev"}; std::string migrated_branch; @@ -68,9 +77,13 @@ void run(const char* cmd) { void finishInstall() { BeginDrawing(); ClearBackground(BLACK); - const char *m = "Finishing install..."; - int text_width = MeasureText(m, FONT_SIZE); - DrawTextEx(font, m, (Vector2){(float)(GetScreenWidth() - text_width)/2 + FONT_SIZE, (float)(GetScreenHeight() - FONT_SIZE)/2}, FONT_SIZE, 0, WHITE); + if (tici_device) { + const char *m = "Finishing install..."; + int text_width = MeasureText(m, FONT_SIZE); + DrawTextEx(font_display, m, (Vector2){(float)(GetScreenWidth() - text_width)/2 + FONT_SIZE, (float)(GetScreenHeight() - FONT_SIZE)/2}, FONT_SIZE, 0, WHITE); + } else { + DrawTextEx(font_display, "finishing setup", (Vector2){8, 10}, 82, 0, WHITE); + } EndDrawing(); util::sleep_for(60 * 1000); } @@ -78,13 +91,21 @@ void finishInstall() { void renderProgress(int progress) { BeginDrawing(); ClearBackground(BLACK); - DrawTextEx(font, "Installing...", (Vector2){150, 290}, 110, 0, WHITE); - Rectangle bar = {150, 570, (float)GetScreenWidth() - 300, 72}; - DrawRectangleRec(bar, (Color){41, 41, 41, 255}); - progress = std::clamp(progress, 0, 100); - bar.width *= progress / 100.0f; - DrawRectangleRec(bar, (Color){70, 91, 234, 255}); - DrawTextEx(font, (std::to_string(progress) + "%").c_str(), (Vector2){150, 670}, 85, 0, WHITE); + if (tici_device) { + DrawTextEx(font_inter, "Installing...", (Vector2){150, 290}, 110, 0, WHITE); + Rectangle bar = {150, 570, (float)GetScreenWidth() - 300, 72}; + DrawRectangleRec(bar, (Color){41, 41, 41, 255}); + progress = std::clamp(progress, 0, 100); + bar.width *= progress / 100.0f; + DrawRectangleRec(bar, (Color){70, 91, 234, 255}); + DrawTextEx(font_inter, (std::to_string(progress) + "%").c_str(), (Vector2){150, 670}, 85, 0, WHITE); + } else { + DrawTextEx(font_display, "installing", (Vector2){8, 10}, 82, 0, WHITE); + const std::string percent_str = std::to_string(progress) + "%"; + DrawTextEx(font_roman, percent_str.c_str(), (Vector2){6, (float)(GetScreenHeight() - 128 + 18)}, 128, 0, + (Color){255, 255, 255, (unsigned char)(255 * 0.9 * 0.35)}); + } + EndDrawing(); } @@ -211,9 +232,18 @@ void cloneFinished(int exitCode) { } int main(int argc, char *argv[]) { - InitWindow(2160, 1080, "Installer"); - font = LoadFontFromMemory(".ttf", inter_ttf, inter_ttf_end - inter_ttf, FONT_SIZE, NULL, 0); - SetTextureFilter(font.texture, TEXTURE_FILTER_BILINEAR); + if (tici_device) { + InitWindow(2160, 1080, "Installer"); + } else { + InitWindow(536, 240, "Installer"); + } + + font_inter = LoadFontFromMemory(".ttf", inter_ttf, inter_ttf_end - inter_ttf, FONT_SIZE, NULL, 0); + font_roman = LoadFontFromMemory(".ttf", inter_light_ttf, inter_light_ttf_end - inter_light_ttf, FONT_SIZE, NULL, 0); + font_display = LoadFontFromMemory(".ttf", inter_bold_ttf, inter_bold_ttf_end - inter_bold_ttf, FONT_SIZE, NULL, 0); + SetTextureFilter(font_inter.texture, TEXTURE_FILTER_BILINEAR); + SetTextureFilter(font_roman.texture, TEXTURE_FILTER_BILINEAR); + SetTextureFilter(font_display.texture, TEXTURE_FILTER_BILINEAR); branchMigration(); @@ -226,6 +256,8 @@ int main(int argc, char *argv[]) { } CloseWindow(); - UnloadFont(font); + UnloadFont(font_inter); + UnloadFont(font_roman); + UnloadFont(font_display); return 0; } diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py index 7f477d4241..cd6ae600ef 100644 --- a/selfdrive/ui/layouts/home.py +++ b/selfdrive/ui/layouts/home.py @@ -20,8 +20,6 @@ SPACING = 25 RIGHT_COLUMN_WIDTH = 750 REFRESH_INTERVAL = 10.0 -PRIME_BG_COLOR = rl.Color(51, 51, 51, 255) - class HomeLayoutState(IntEnum): HOME = 0 diff --git a/selfdrive/ui/layouts/settings/common.py b/selfdrive/ui/layouts/settings/common.py new file mode 100644 index 0000000000..5e87a6447a --- /dev/null +++ b/selfdrive/ui/layouts/settings/common.py @@ -0,0 +1,5 @@ +from openpilot.selfdrive.ui.ui_state import ui_state + + +def restart_needed_callback(_): + ui_state.params.put_bool("OnroadCycleRequested", True) diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py index 9ea1019f54..646c817508 100644 --- a/selfdrive/ui/layouts/settings/developer.py +++ b/selfdrive/ui/layouts/settings/developer.py @@ -3,7 +3,7 @@ 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.scroller_tici 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, tr_noop diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index f5f37fbd3c..00ae6a188e 100644 --- a/selfdrive/ui/layouts/settings/device.py +++ b/selfdrive/ui/layouts/settings/device.py @@ -17,7 +17,7 @@ from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dial 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 +from openpilot.system.ui.widgets.scroller_tici import Scroller # Description constants DESCRIPTIONS = { diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index 8166a8a9e4..4b8b7015f8 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -9,7 +9,7 @@ from openpilot.system.ui.widgets import Widget, DialogResult 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.option_dialog import MultiOptionDialog -from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.scroller_tici 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 diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 3a1265e0fe..7fae2dfd24 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -2,7 +2,7 @@ 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.scroller_tici 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, tr_noop diff --git a/selfdrive/ui/mici/layouts/__init__.py b/selfdrive/ui/mici/layouts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py new file mode 100644 index 0000000000..014fc0c45f --- /dev/null +++ b/selfdrive/ui/mici/layouts/home.py @@ -0,0 +1,272 @@ +import time + +from cereal import log +import pyray as rl +from collections.abc import Callable +from openpilot.system.ui.widgets.label import gui_label, MiciLabel +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_COLOR, MousePos +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.text import wrap_text +from openpilot.system.version import training_version + +HEAD_BUTTON_FONT_SIZE = 40 +HOME_PADDING = 8 + +RELEASE_BRANCH = "release3" + +NetworkType = log.DeviceState.NetworkType + +NETWORK_TYPES = { + NetworkType.none: "Offline", + NetworkType.wifi: "WiFi", + NetworkType.cell2G: "2G", + NetworkType.cell3G: "3G", + NetworkType.cell4G: "LTE", + NetworkType.cell5G: "5G", + NetworkType.ethernet: "Ethernet", +} + + +class DeviceStatus(Widget): + def __init__(self): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, 300, 175)) + self._update_state() + self._version_text = self._get_version_text() + + self._do_welcome() + + def _do_welcome(self): + ui_state.params.put("CompletedTrainingVersion", training_version) + + def refresh(self): + self._update_state() + self._version_text = self._get_version_text() + + def _get_version_text(self) -> str: + brand = "openpilot" + description = ui_state.params.get("UpdaterCurrentDescription") + return f"{brand} {description}" if description else brand + + def _update_state(self): + # TODO: refresh function that can be called periodically, not at 60 fps, so we can update version + # update system status + self._system_status = "SYSTEM READY ✓" if ui_state.panda_type != log.PandaState.PandaType.unknown else "BOOTING UP..." + + # update network status + strength = ui_state.sm['deviceState'].networkStrength.raw + strength_text = "● " * strength + "○ " * (4 - strength) # ◌ also works + network_type = NETWORK_TYPES[ui_state.sm['deviceState'].networkType.raw] + self._network_status = f"{network_type} {strength_text}" + + def _render(self, _): + # draw status + status_rect = rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, 40) + gui_label(status_rect, self._system_status, font_size=HEAD_BUTTON_FONT_SIZE, color=DEFAULT_TEXT_COLOR, + font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + + # draw network status + network_rect = rl.Rectangle(self._rect.x, self._rect.y + 60, self._rect.width, 40) + gui_label(network_rect, self._network_status, font_size=40, color=DEFAULT_TEXT_COLOR, + font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + + # draw version + version_font_size = 30 + version_rect = rl.Rectangle(self._rect.x, self._rect.y + 140, self._rect.width + 20, 40) + wrapped_text = '\n'.join(wrap_text(self._version_text, version_font_size, version_rect.width)) + gui_label(version_rect, wrapped_text, font_size=version_font_size, color=DEFAULT_TEXT_COLOR, + font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + + +class MiciHomeLayout(Widget): + def __init__(self): + super().__init__() + self._on_settings_click: Callable | None = None + + self._last_refresh = 0 + self._mouse_down_t: None | float = None + self._did_long_press = False + self._is_pressed_prev = False + + self._version_text = None + self._experimental_mode = False + + self._settings_txt = gui_app.texture("icons_mici/settings.png", 48, 48) + self._experimental_txt = gui_app.texture("icons_mici/experimental_mode.png", 48, 48) + self._mic_txt = gui_app.texture("icons_mici/microphone.png", 48, 48) + + self._net_type = NETWORK_TYPES.get(NetworkType.none) + self._net_strength = 0 + + self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 50, 44) + self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 44) + self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 44) + self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 44) + self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 44) + + self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 55, 35) + self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 55, 35) + self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 55, 35) + self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 55, 35) + self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 55, 35) + + self._openpilot_label = MiciLabel("openpilot", font_size=96, color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.DISPLAY) + self._version_label = MiciLabel("", font_size=36, font_weight=FontWeight.ROMAN) + self._large_version_label = MiciLabel("", font_size=64, color=rl.GRAY, font_weight=FontWeight.ROMAN) + self._date_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN) + self._branch_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN, elide_right=False, scroll=True) + self._version_commit_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN) + + def show_event(self): + self._version_text = self._get_version_text() + self._update_network_status(ui_state.sm['deviceState']) + self._update_params() + + def _update_params(self): + self._experimental_mode = ui_state.params.get_bool("ExperimentalMode") + + def _update_state(self): + if self.is_pressed and not self._is_pressed_prev: + self._mouse_down_t = time.monotonic() + elif not self.is_pressed and self._is_pressed_prev: + self._mouse_down_t = None + self._did_long_press = False + self._is_pressed_prev = self.is_pressed + + if self._mouse_down_t is not None: + if time.monotonic() - self._mouse_down_t > 0.5: + # long gating for experimental mode - only allow toggle if longitudinal control is available + if ui_state.has_longitudinal_control: + self._experimental_mode = not self._experimental_mode + ui_state.params.put("ExperimentalMode", self._experimental_mode) + self._mouse_down_t = None + self._did_long_press = True + + if rl.get_time() - self._last_refresh > 5.0: + device_state = ui_state.sm['deviceState'] + self._update_network_status(device_state) + + # Update version text + self._version_text = self._get_version_text() + self._last_refresh = rl.get_time() + self._update_params() + + def _update_network_status(self, device_state): + self._net_type = device_state.networkType + strength = device_state.networkStrength + self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0 + + def set_callbacks(self, on_settings: Callable | None = None): + self._on_settings_click = on_settings + + def _handle_mouse_release(self, mouse_pos: MousePos): + if not self._did_long_press: + if self._on_settings_click: + self._on_settings_click() + self._did_long_press = False + + def _get_version_text(self) -> tuple[str, str, str, str] | None: + description = ui_state.params.get("UpdaterCurrentDescription") + + if description is not None and len(description) > 0: + # Expect "version / branch / commit / date"; be tolerant of other formats + try: + version, branch, commit, date = description.split(" / ") + return version, branch, commit, date + except Exception: + return None + + return None + + def _render(self, _): + # TODO: why is there extra space here to get it to be flush? + text_pos = rl.Vector2(self.rect.x - 2 + HOME_PADDING, self.rect.y - 16) + self._openpilot_label.set_position(text_pos.x, text_pos.y) + self._openpilot_label.render() + + if self._version_text is not None: + # release branch + if self._version_text[0] == RELEASE_BRANCH: + version_pos = rl.Vector2(text_pos.x, text_pos.y + self._openpilot_label.font_size + 16) + self._large_version_label.set_text(self._version_text[0]) + self._large_version_label.set_position(version_pos.x, version_pos.y) + self._large_version_label.render() + + else: + version_pos = rl.Rectangle(text_pos.x, text_pos.y + self._openpilot_label.font_size + 16, 100, 44) + self._version_label.set_text(self._version_text[0]) + self._version_label.set_position(version_pos.x, version_pos.y) + self._version_label.render() + + self._date_label.set_text(" " + self._version_text[3]) + self._date_label.set_position(version_pos.x + self._version_label.rect.width + 10, version_pos.y) + self._date_label.render() + + self._branch_label.set_width(gui_app.width - self._version_label.rect.width - self._date_label.rect.width - 32) + self._branch_label.set_text(" " + self._version_text[1]) + self._branch_label.set_position(version_pos.x + self._version_label.rect.width + self._date_label.rect.width + 20, version_pos.y) + self._branch_label.render() + + # 2nd line + self._version_commit_label.set_text(self._version_text[2]) + self._version_commit_label.set_position(version_pos.x, version_pos.y + self._date_label.font_size + 7) + self._version_commit_label.render() + + self._render_bottom_status_bar() + + def _render_bottom_status_bar(self): + # ***** Center-aligned bottom section icons ***** + + # TODO: refactor repeated icon drawing into a small loop + ITEM_SPACING = 18 + Y_CENTER = 24 + + last_x = self.rect.x + HOME_PADDING + + # Draw settings icon in bottom left corner + rl.draw_texture(self._settings_txt, int(last_x), int(self._rect.y + self.rect.height - self._settings_txt.height / 2 - Y_CENTER), + rl.Color(255, 255, 255, int(255 * 0.9))) + last_x = last_x + self._settings_txt.width + ITEM_SPACING + + # draw network + if self._net_type == NetworkType.wifi: + # There is no 1 + draw_net_txt = {0: self._wifi_none_txt, + 2: self._wifi_low_txt, + 3: self._wifi_medium_txt, + 4: self._wifi_full_txt, + 5: self._wifi_full_txt}.get(self._net_strength, self._wifi_low_txt) + rl.draw_texture(draw_net_txt, int(last_x), + int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, int(255 * 0.9))) + last_x += draw_net_txt.width + ITEM_SPACING + + elif self._net_type in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G): + draw_net_txt = {0: self._cell_none_txt, + 2: self._cell_low_txt, + 3: self._cell_medium_txt, + 4: self._cell_high_txt, + 5: self._cell_full_txt}.get(self._net_strength, self._cell_none_txt) + rl.draw_texture(draw_net_txt, int(last_x), + int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, int(255 * 0.9))) + last_x += draw_net_txt.width + ITEM_SPACING + + else: + # No network + # Offset by difference in height between slashless and slash icons to make center align match + rl.draw_texture(self._wifi_slash_txt, int(last_x), int(self._rect.y + self.rect.height - self._wifi_slash_txt.height / 2 - + (self._wifi_slash_txt.height - self._wifi_none_txt.height) / 2 - Y_CENTER), + rl.Color(255, 255, 255, 255)) + last_x += self._wifi_slash_txt.width + ITEM_SPACING + + # draw experimental icon + if self._experimental_mode: + rl.draw_texture(self._experimental_txt, int(last_x), + int(self._rect.y + self.rect.height - self._experimental_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255)) + last_x += self._experimental_txt.width + ITEM_SPACING + + # draw microphone icon when recording audio is enabled + if ui_state.recording_audio: + rl.draw_texture(self._mic_txt, int(last_x), + int(self._rect.y + self.rect.height - self._mic_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255)) + last_x += self._mic_txt.width + ITEM_SPACING diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py new file mode 100644 index 0000000000..b52f9ed39a --- /dev/null +++ b/selfdrive/ui/mici/layouts/main.py @@ -0,0 +1,149 @@ +import pyray as rl +from enum import IntEnum +import cereal.messaging as messaging +from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout +from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout +from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts +from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView +from openpilot.selfdrive.ui.ui_state import device, ui_state +from openpilot.selfdrive.ui.mici.layouts.onboarding import OnboardingWindow +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.lib.application import gui_app + + +ONROAD_DELAY = 2.5 # seconds + + +class MainState(IntEnum): + MAIN = 0 + SETTINGS = 1 + + +class MiciMainLayout(Widget): + def __init__(self): + super().__init__() + + self._pm = messaging.PubMaster(['bookmarkButton']) + + self._current_mode: MainState | None = None + self._prev_onroad = False + self._prev_standstill = False + self._onroad_time_delay: float | None = None + self._setup = False + + # Initialize widgets + self._home_layout = MiciHomeLayout() + self._alerts_layout = MiciOffroadAlerts() + self._settings_layout = SettingsLayout() + self._onroad_layout = AugmentedRoadView(bookmark_callback=self._on_bookmark_clicked) + + # Initialize widget rects + for widget in (self._home_layout, self._settings_layout, self._alerts_layout, self._onroad_layout): + # TODO: set parent rect and use it if never passed rect from render (like in Scroller) + widget.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + + self._scroller = Scroller([ + self._alerts_layout, + self._home_layout, + self._onroad_layout, + ], spacing=0, pad_start=0, pad_end=0) + self._scroller.set_reset_scroll_at_show(False) + + # Disable scrolling when onroad is interacting with bookmark + self._scroller.set_scrolling_enabled(lambda: not self._onroad_layout.is_swiping_left()) + + self._layouts = { + MainState.MAIN: self._scroller, + MainState.SETTINGS: self._settings_layout, + } + + # 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 _setup_callbacks(self): + self._home_layout.set_callbacks(on_settings=self._on_settings_clicked) + self._settings_layout.set_callbacks(on_close=self._on_settings_closed) + self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) + device.add_interactive_timeout_callback(self._set_mode_for_started) + + def _scroll_to(self, layout: Widget): + layout_x = int(layout.rect.x) + self._scroller.scroll_to(layout_x, smooth=True) + + def _render(self, _): + # Initial show event + if self._current_mode is None: + self._set_mode(MainState.MAIN) + + if not self._setup: + if self._alerts_layout.active_alerts() > 0: + self._scroller.scroll_to(self._alerts_layout.rect.x) + else: + self._scroller.scroll_to(self._rect.width) + self._setup = True + + # Render + if self._current_mode == MainState.MAIN: + self._scroller.render(self._rect) + + elif self._current_mode == MainState.SETTINGS: + self._settings_layout.render(self._rect) + + self._handle_transitions() + + def _set_mode(self, mode: MainState): + if mode != self._current_mode: + if self._current_mode is not None: + self._layouts[self._current_mode].hide_event() + self._layouts[mode].show_event() + self._current_mode = mode + + def _handle_transitions(self): + if ui_state.started != self._prev_onroad: + self._prev_onroad = ui_state.started + + if ui_state.started: + self._onroad_time_delay = rl.get_time() + else: + self._set_mode_for_started(True) + + # delay so we show home for a bit after starting + if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY: + self._set_mode_for_started(True) + self._onroad_time_delay = None + + CS = ui_state.sm["carState"] + if not CS.standstill and self._prev_standstill: + self._set_mode(MainState.MAIN) + self._scroll_to(self._onroad_layout) + self._prev_standstill = CS.standstill + + def _set_mode_for_started(self, onroad_transition: bool = False): + if ui_state.started: + CS = ui_state.sm["carState"] + # Only go onroad if car starts or is not at a standstill + if not CS.standstill or onroad_transition: + self._set_mode(MainState.MAIN) + self._scroll_to(self._onroad_layout) + else: + # Stay in settings if car turns off while in settings + if not onroad_transition or self._current_mode != MainState.SETTINGS: + self._set_mode(MainState.MAIN) + self._scroll_to(self._home_layout) + + def _on_settings_clicked(self): + self._set_mode(MainState.SETTINGS) + + def _on_settings_closed(self): + self._set_mode(MainState.MAIN) + + def _on_bookmark_clicked(self): + user_bookmark = messaging.new_message('bookmarkButton') + user_bookmark.valid = True + self._pm.send('bookmarkButton', user_bookmark) diff --git a/selfdrive/ui/mici/layouts/offroad_alerts.py b/selfdrive/ui/mici/layouts/offroad_alerts.py new file mode 100644 index 0000000000..2e9a8bee3c --- /dev/null +++ b/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -0,0 +1,307 @@ +import pyray as rl +import re +import time +from dataclasses import dataclass +from enum import IntEnum +from openpilot.common.params import Params +from openpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr + +REFRESH_INTERVAL = 5.0 # seconds + + +class AlertSize(IntEnum): + SMALL = 0 + MEDIUM = 1 + BIG = 2 + + +@dataclass +class AlertData: + key: str + text: str + severity: int + visible: bool = False + + +class AlertItem(Widget): + # TODO: click should always go somewhere: home or specific settings pane + """Individual alert item widget with background image and text.""" + ALERT_WIDTH = 520 + ALERT_HEIGHT_SMALL = 212 + ALERT_HEIGHT_MED = 240 + ALERT_HEIGHT_BIG = 324 + ALERT_PADDING = 28 + ICON_SIZE = 64 + ICON_MARGIN = 12 + TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9)) + TITLE_BODY_SPACING = 24 + + def __init__(self, alert_data: AlertData): + super().__init__() + self.alert_data = alert_data + + # Load background textures + self._bg_small = gui_app.texture("icons_mici/offroad_alerts/small_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL) + self._bg_small_pressed = gui_app.texture("icons_mici/offroad_alerts/small_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL) + self._bg_medium = gui_app.texture("icons_mici/offroad_alerts/medium_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED) + self._bg_medium_pressed = gui_app.texture("icons_mici/offroad_alerts/medium_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED) + self._bg_big = gui_app.texture("icons_mici/offroad_alerts/big_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG) + self._bg_big_pressed = gui_app.texture("icons_mici/offroad_alerts/big_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG) + + # Load warning icons + self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", self.ICON_SIZE, self.ICON_SIZE) + self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", self.ICON_SIZE, self.ICON_SIZE) + self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE) + + self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, line_height=0.95) + + self._body_label = UnifiedLabel(text="", font_size=28, font_weight=FontWeight.ROMAN, text_color=self.TEXT_COLOR, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, line_height=0.95) + + self._title_text = "" + self._body_text = "" + self._alert_size = AlertSize.SMALL + + self._update_content() + + def _split_text(self, text: str) -> tuple[str, str]: + """Split text into title (first sentence) and body (remaining text).""" + # Find the end of the first sentence (period, exclamation, or question mark followed by space or end) + match = re.search(r'[.!?](?:\s+|$)', text) + if match: + # Found a sentence boundary - split at the end of the sentence + title = text[:match.start()].strip() + body = text[match.end():].strip() + return title, body + else: + # No sentence boundary found, return full text as title + return "", text + + def _update_content(self): + """Update text and calculate height.""" + if not self.alert_data.visible or not self.alert_data.text: + self.set_visible(False) + return + + self.set_visible(True) + + # Split text into title and body + self._title_text, self._body_text = self._split_text(self.alert_data.text) + + # Calculate text width (alert width minus padding and icon space on right) + title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN + body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) + + # Update labels + self._title_label.set_text(self._title_text) + self._body_label.set_text(self._body_text) + + # Calculate content height + title_height = self._title_label.get_content_height(title_width) if self._title_text else 0 + body_height = self._body_label.get_content_height(body_width) if self._body_text else 0 + spacing = self.TITLE_BODY_SPACING if (self._title_text and self._body_text) else 0 + total_text_height = title_height + spacing + body_height + + # Determine which background size to use based on content height + min_height_with_padding = total_text_height + (self.ALERT_PADDING * 2) + if min_height_with_padding > self.ALERT_HEIGHT_MED: + self._alert_size = AlertSize.BIG + height = self.ALERT_HEIGHT_BIG + elif min_height_with_padding > self.ALERT_HEIGHT_SMALL: + self._alert_size = AlertSize.MEDIUM + height = self.ALERT_HEIGHT_MED + else: + self._alert_size = AlertSize.SMALL + height = self.ALERT_HEIGHT_SMALL + + # Set rect size + self.set_rect(rl.Rectangle(0, 0, self.ALERT_WIDTH, height)) + + def update_alert_data(self, alert_data: AlertData): + """Update alert data and refresh display.""" + self.alert_data = alert_data + self._update_content() + + def _render(self, _): + if not self.alert_data.visible or not self.alert_data.text: + return + + # Choose background based on size + if self._alert_size == AlertSize.BIG: + bg_texture = self._bg_big_pressed if self.is_pressed else self._bg_big + elif self._alert_size == AlertSize.MEDIUM: + bg_texture = self._bg_medium_pressed if self.is_pressed else self._bg_medium + else: # AlertSize.SMALL + bg_texture = self._bg_small_pressed if self.is_pressed else self._bg_small + + # Draw background + rl.draw_texture(bg_texture, int(self._rect.x), int(self._rect.y), rl.WHITE) + + # Calculate text area (left side, avoiding icon on right) + title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN + body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) + text_x = self._rect.x + self.ALERT_PADDING + text_y = self._rect.y + self.ALERT_PADDING + + # Draw title label + if self._title_text: + title_rect = rl.Rectangle( + text_x, + text_y, + title_width, + self._title_label.get_content_height(title_width), + ) + self._title_label.render(title_rect) + text_y += title_rect.height + self.TITLE_BODY_SPACING + + # Draw body label + if self._body_text: + body_rect = rl.Rectangle( + text_x, + text_y, + body_width, + self._rect.height - text_y + self._rect.y - self.ALERT_PADDING, + ) + self._body_label.render(body_rect) + + # Draw warning icon on the right side + # Use green icon for update alerts (severity = -1), red for high severity, orange for low severity + if self.alert_data.severity == -1: + icon_texture = self._icon_green + elif self.alert_data.severity > 0: + icon_texture = self._icon_red + else: + icon_texture = self._icon_orange + icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE + icon_y = self._rect.y + self.ALERT_PADDING + rl.draw_texture(icon_texture, int(icon_x), int(icon_y), rl.WHITE) + + +class MiciOffroadAlerts(Widget): + """Offroad alerts layout with vertical scrolling.""" + + def __init__(self): + super().__init__() + self.params = Params() + self.sorted_alerts: list[AlertData] = [] + self.alert_items: list[AlertItem] = [] + self._last_refresh = 0.0 + + # Create vertical scroller + self._scroller = Scroller([], horizontal=False, spacing=12, pad_start=0, pad_end=0, snap_items=False) + + # Create empty state label + self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + # Build initial alert list + self._build_alerts() + + def active_alerts(self) -> int: + return sum(alert.visible for alert in self.sorted_alerts) + + def scrolling(self): + return self._scroller.scroll_panel.is_touch_valid() + + def _build_alerts(self): + """Build sorted list of alerts from OFFROAD_ALERTS.""" + self.sorted_alerts = [] + + # Add UpdateAvailable alert at the top (severity = -1 to indicate special handling) + update_alert_data = AlertData(key="UpdateAvailable", text="", severity=-1) + self.sorted_alerts.append(update_alert_data) + update_alert_item = AlertItem(update_alert_data) + self.alert_items.append(update_alert_item) + self._scroller.add_widget(update_alert_item) + + # Add regular alerts sorted by severity + for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True): + severity = config.get("severity", 0) + alert_data = AlertData(key=key, text="", severity=severity) + self.sorted_alerts.append(alert_data) + + # Create alert item widget + alert_item = AlertItem(alert_data) + self.alert_items.append(alert_item) + self._scroller.add_widget(alert_item) + + def refresh(self) -> int: + """Refresh alerts from params and return active count.""" + active_count = 0 + + # Handle UpdateAvailable alert specially + update_available = self.params.get_bool("UpdateAvailable") + update_alert_data = next((alert_data for alert_data in self.sorted_alerts if alert_data.key == "UpdateAvailable"), None) + + if update_alert_data: + if update_available: + # Default text + update_alert_data.text = "update available. go to comma.ai/blog to read the release notes." + + # Get new version description and parse version and date + new_desc = self.params.get("UpdaterNewDescription") or "" + if new_desc: + # Parse description (format: "version / branch / commit / date") + parts = new_desc.split(" / ") + if len(parts) > 3: + version, date = parts[0], parts[3] + update_alert_data.text = f"update available\n openpilot {version}, {date}. go to comma.ai/blog to read the release notes." + + update_alert_data.visible = True + active_count += 1 + else: + update_alert_data.text = "" + update_alert_data.visible = False + + # Handle regular alerts + for alert_data in self.sorted_alerts: + if alert_data.key == "UpdateAvailable": + continue # Skip, already handled above + + text = "" + alert_json = self.params.get(alert_data.key) + + if alert_json: + text = alert_json.get("text", "").replace("%1", alert_json.get("extra", "")) + + alert_data.text = text + alert_data.visible = bool(text) + + if alert_data.visible: + active_count += 1 + + # Update alert items (they reference the same alert_data objects) + for alert_item in self.alert_items: + alert_item.update_alert_data(alert_item.alert_data) + + return active_count + + def show_event(self): + """Reset scroll position when shown and refresh alerts.""" + self._scroller.show_event() + self._last_refresh = time.monotonic() + self.refresh() + + def _update_state(self): + """Periodically refresh alerts.""" + # Refresh alerts periodically, not every frame + current_time = time.monotonic() + if current_time - self._last_refresh >= REFRESH_INTERVAL: + self.refresh() + self._last_refresh = current_time + + def _render(self, rect: rl.Rectangle): + """Render the alerts scroller or empty state.""" + if self.active_alerts() == 0: + self._empty_label.render(rect) + else: + self._scroller.render(rect) diff --git a/selfdrive/ui/mici/layouts/onboarding.py b/selfdrive/ui/mici/layouts/onboarding.py new file mode 100644 index 0000000000..6036393aa5 --- /dev/null +++ b/selfdrive/ui/mici/layouts/onboarding.py @@ -0,0 +1,552 @@ +from enum import IntEnum +from collections.abc import Callable + +import pyray as rl +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.button import SmallButton +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.widgets.slider import SmallSlider +from openpilot.system.ui.mici_setup import TermsHeader, TermsPage as SetupTermsPage +from openpilot.selfdrive.ui.ui_state import ui_state, device +from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall +from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar +from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer +from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog +from openpilot.system.ui.widgets.label import gui_label +from openpilot.system.ui.lib.multilang import tr + + +class OnboardingState(IntEnum): + TERMS = 0 + ONBOARDING = 1 + DECLINE = 2 + + +class DriverCameraSetupDialog(DriverCameraDialog): + def __init__(self, confirm_callback: Callable): + super().__init__(no_escape=True) + self.driver_state_renderer = DriverStateRenderer(confirm_mode=True, confirm_callback=confirm_callback) + self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200)) + self.driver_state_renderer.load_icons() + + def _render(self, rect): + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + self._camera_view._render(rect) + + if not self._camera_view.frame: + gui_label(rect, tr("camera starting"), font_size=64, font_weight=FontWeight.BOLD, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + rl.end_scissor_mode() + return -1 + + # Position dmoji on opposite side from driver + # TODO: we don't have design for RHD yet + is_rhd = False + driver_state_rect = ( + rect.x if is_rhd else rect.x + rect.width - self.driver_state_renderer.rect.width, + rect.y + (rect.height - self.driver_state_renderer.rect.height) / 2, + ) + self.driver_state_renderer.set_position(*driver_state_rect) + self.driver_state_renderer.render() + + rl.end_scissor_mode() + return -1 + + +class TrainingGuideIntro(SetupTermsPage): + def __init__(self, continue_callback): + super().__init__(continue_callback, continue_text="continue") + self._title_header = TermsHeader("welcome to openpilot", gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 60, 60)) + + self._dm_label = UnifiedLabel("Before we get on the road, let's review the " + + "functionality and limitations of openpilot.", 36, + FontWeight.ROMAN) + + @property + def _content_height(self): + return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset() + + def _render_content(self, scroll_offset): + self._title_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16 + scroll_offset, + self._title_header.rect.width, + self._title_header.rect.height, + )) + + self._dm_label.render(rl.Rectangle( + self._rect.x + 16, + self._title_header.rect.y + self._title_header.rect.height + 16, + self._rect.width - 32, + self._dm_label.get_content_height(int(self._rect.width - 32)), + )) + + +class TrainingGuidePreDMTutorial(SetupTermsPage): + def __init__(self, continue_callback): + super().__init__(continue_callback, continue_text="continue") + self._title_header = TermsHeader("driver monitoring setup", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60)) + + self._dm_label = UnifiedLabel("Next, we'll ensure comma four is mounted properly.\n\nIf it does not have a clear view of the driver, " + + "simply unplug and remount before continuing.\n\n" + + "NOTE: the driver camera will have a purple tint due to the IR illumination used for seeing at night.", 36, + FontWeight.ROMAN) + + def show_event(self): + super().show_event() + # Get driver monitoring model ready for next step + ui_state.params.put_bool("IsDriverViewEnabled", True) + + @property + def _content_height(self): + return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset() + + def _render_content(self, scroll_offset): + self._title_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16 + scroll_offset, + self._title_header.rect.width, + self._title_header.rect.height, + )) + + self._dm_label.render(rl.Rectangle( + self._rect.x + 16, + self._title_header.rect.y + self._title_header.rect.height + 16, + self._rect.width - 32, + self._dm_label.get_content_height(int(self._rect.width - 32)), + )) + + +class TrainingGuideDMTutorial(Widget): + def __init__(self, continue_callback): + super().__init__() + self._title_header = TermsHeader("fill the circle to continue", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60)) + + self._dialog = DriverCameraSetupDialog(continue_callback) + + # Disable driver monitoring model when device times out for inactivity + def inactivity_callback(): + ui_state.params.put_bool("IsDriverViewEnabled", False) + + device.add_interactive_timeout_callback(inactivity_callback) + + def show_event(self): + super().show_event() + self._dialog.show_event() + + def _update_state(self): + super()._update_state() + if device.awake: + ui_state.params.put_bool("IsDriverViewEnabled", True) + + def _render(self, _): + self._dialog.render(self._rect) + + rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - self._title_header.rect.height * 1.5 - 32), + int(self._rect.width), int(self._title_header.rect.height * 1.5 + 32), + rl.BLANK, rl.Color(0, 0, 0, 150)) + self._title_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + self._rect.height - self._title_header.rect.height - 16, + self._title_header.rect.width, + self._title_header.rect.height, + )) + + +class TrainingGuideRecordFront(SetupTermsPage): + def __init__(self, continue_callback): + def on_back(): + ui_state.params.put_bool("RecordFront", False) + continue_callback() + + def on_continue(): + ui_state.params.put_bool("RecordFront", True) + continue_callback() + + super().__init__(on_continue, back_callback=on_back, back_text="no", continue_text="yes") + self._title_header = TermsHeader("improve driver monitoring", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60)) + + self._dm_label = UnifiedLabel("Help improve driver monitoring by including your driving data in the training data set. " + + "Your preference can be changed at any time in Settings. Would you like to share your data?", 36, + FontWeight.ROMAN) + + def show_event(self): + super().show_event() + # Disable driver monitoring model after last step + ui_state.params.put_bool("IsDriverViewEnabled", False) + + @property + def _content_height(self): + return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset() + + def _render_content(self, scroll_offset): + self._title_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16 + scroll_offset, + self._title_header.rect.width, + self._title_header.rect.height, + )) + + self._dm_label.render(rl.Rectangle( + self._rect.x + 16, + self._title_header.rect.y + self._title_header.rect.height + 16, + self._rect.width - 32, + self._dm_label.get_content_height(int(self._rect.width - 32)), + )) + + +class TrainingGuideAttentionNotice1(SetupTermsPage): + def __init__(self, continue_callback): + super().__init__(continue_callback, continue_text="continue") + self._title_header = TermsHeader("not a self driving car", gui_app.texture("icons_mici/setup/warning.png", 60, 60)) + self._warning_label = UnifiedLabel("THIS IS A DRIVER ASSISTANCE SYSTEM. A DRIVER ASSISTANCE SYSTEM IS NOT A SELF DRIVING CAR.", 36, + FontWeight.ROMAN) + + @property + def _content_height(self): + return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() + + def _render_content(self, scroll_offset): + self._title_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16 + scroll_offset, + self._title_header.rect.width, + self._title_header.rect.height, + )) + + self._warning_label.render(rl.Rectangle( + self._rect.x + 16, + self._title_header.rect.y + self._title_header.rect.height + 16, + self._rect.width - 32, + self._warning_label.get_content_height(int(self._rect.width - 32)), + )) + + +class TrainingGuideAttentionNotice2(SetupTermsPage): + def __init__(self, continue_callback): + super().__init__(continue_callback, continue_text="continue") + self._title_header = TermsHeader("attention is required", gui_app.texture("icons_mici/setup/warning.png", 60, 60)) + self._warning_label = UnifiedLabel("YOU MUST PAY ATTENTION AT ALL TIMES. YOU ARE FULLY RESPONSIBLE FOR DRIVING THE CAR.", 36, + FontWeight.ROMAN) + + @property + def _content_height(self): + return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() + + def _render_content(self, scroll_offset): + self._title_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16 + scroll_offset, + self._title_header.rect.width, + self._title_header.rect.height, + )) + + self._warning_label.render(rl.Rectangle( + self._rect.x + 16, + self._title_header.rect.y + self._title_header.rect.height + 16, + self._rect.width - 32, + self._warning_label.get_content_height(int(self._rect.width - 32)), + )) + + +class TrainingGuideDisengaging(SetupTermsPage): + def __init__(self, continue_callback): + super().__init__(continue_callback, continue_text="continue") + self._title_header = TermsHeader("disengaging openpilot", gui_app.texture("icons_mici/setup/green_pedal.png", 60, 60)) + self._warning_label = UnifiedLabel("You can disengage openpilot by either pressing the brake pedal or " + + "the cancel button on your steering wheel.", 36, + FontWeight.ROMAN) + + @property + def _content_height(self): + return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() + + def _render_content(self, scroll_offset): + self._title_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16 + scroll_offset, + self._title_header.rect.width, + self._title_header.rect.height, + )) + + self._warning_label.render(rl.Rectangle( + self._rect.x + 16, + self._title_header.rect.y + self._title_header.rect.height + 16, + self._rect.width - 32, + self._warning_label.get_content_height(int(self._rect.width - 32)), + )) + + +class TrainingGuideConfidenceBall(SetupTermsPage): + ANIMATION_PAUSE = 3.5 + + def __init__(self, continue_callback): + super().__init__(continue_callback, continue_text="continue") + self._confidence_ball = ConfidenceBall(demo=True) + self._start_time = 0.0 + + self._title_header = TermsHeader("confidence ball", gui_app.texture("icons_mici/setup/green_car.png", 60, 60)) + self._warning_label = UnifiedLabel("The ball on the right communicates how confident the driving " + + "model is about the road scene at any given time.", 36, + FontWeight.ROMAN) + + def show_event(self): + super().show_event() + self._start_time = rl.get_time() + + @property + def _content_height(self): + return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() + + def _render_content(self, scroll_offset): + self._title_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16 + scroll_offset, + self._title_header.rect.width, + self._title_header.rect.height, + )) + + # room for confidence ball + label_width = self._rect.width - 32 - 60 + self._warning_label.render(rl.Rectangle( + self._rect.x + 16, + self._title_header.rect.y + self._title_header.rect.height + 16, + label_width, + self._warning_label.get_content_height(int(label_width)), + )) + + duration = rl.get_time() - self._start_time + if duration > 5 + self.ANIMATION_PAUSE * 2: + # reset animation + self._start_time = rl.get_time() + if duration > 5 + self.ANIMATION_PAUSE: + self._confidence_ball.update_filter(0.1) + elif duration > 5: + self._confidence_ball.update_filter(0.4) + elif duration > 0.5: + self._confidence_ball.update_filter(0.9) + + self._confidence_ball.render(self._rect) + self._rect.width -= 60 + + +class TrainingGuideSteeringArc(SetupTermsPage): + ANIMATION_PAUSE = 2 + TORQUE_BAR_HEIGHT = 100 + + def __init__(self, continue_callback): + super().__init__(continue_callback, continue_text="finish") + self._torque_bar = TorqueBar(demo=True) + self._start_time = 0.0 + + self._title_header = TermsHeader("steering arc", gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 60, 60)) + self._warning_label = UnifiedLabel("All cars limit the amount of steering that openpilot is able to apply. While driving, the " + + "steering arc shows the current amount of force being applied in relation to the maximum available to openpilot. " + + "You may need to assist if you see the arc nearing its orange state.", 36, + FontWeight.ROMAN) + + def show_event(self): + super().show_event() + self._start_time = rl.get_time() + + @property + def _content_height(self): + return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() + self.TORQUE_BAR_HEIGHT + + def _render_content(self, scroll_offset): + self._title_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16 + scroll_offset, + self._title_header.rect.width, + self._title_header.rect.height, + )) + + self._warning_label.render(rl.Rectangle( + self._rect.x + 16, + self._title_header.rect.y + self._title_header.rect.height + 16, + self._rect.width - 32, + self._warning_label.get_content_height(int(self._rect.width - 32)), + )) + + duration = rl.get_time() - self._start_time + if duration > self.ANIMATION_PAUSE * 5: + # reset animation + self._start_time = rl.get_time() + elif duration > self.ANIMATION_PAUSE * 4: + self._torque_bar.update_filter(-1.0) + elif duration > self.ANIMATION_PAUSE * 3: + self._torque_bar.update_filter(-0.2) + elif duration > self.ANIMATION_PAUSE * 2: + self._torque_bar.update_filter(1.0) + elif duration > self.ANIMATION_PAUSE: + self._torque_bar.update_filter(0.7) + else: + self._torque_bar.update_filter(0.0) + + # background gradient for torque bar legibility + rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height * 0.6), + int(self._rect.width), int(self._rect.height * 0.2), + rl.BLANK, rl.Color(0, 0, 0, int(255 * 0.9))) + rl.draw_rectangle(int(self._rect.x), int(self._rect.y + self._rect.height * 0.8), + int(self._rect.width), int(self._rect.height * 0.2), + rl.Color(0, 0, 0, int(255 * 0.9))) + + # scroll torque bar once we get to the bottom of content + torque_y_offset = min(0.0, self._warning_label.rect.y + self._warning_label.rect.height - + self._rect.height + self.TORQUE_BAR_HEIGHT) + torque_rect = rl.Rectangle( + self._rect.x, + self._rect.y + torque_y_offset, + self._rect.width, + self._rect.height, + ) + self._torque_bar.render(torque_rect) + + +class TrainingGuide(Widget): + def __init__(self, completed_callback=None): + super().__init__() + self._completed_callback = completed_callback + self._step = 0 + + self._steps = [ + TrainingGuideIntro(continue_callback=self._advance_step), + TrainingGuideAttentionNotice1(continue_callback=self._advance_step), + TrainingGuideAttentionNotice2(continue_callback=self._advance_step), + TrainingGuidePreDMTutorial(continue_callback=self._advance_step), + TrainingGuideDMTutorial(continue_callback=self._advance_step), + TrainingGuideRecordFront(continue_callback=self._advance_step), + TrainingGuideDisengaging(continue_callback=self._advance_step), + TrainingGuideConfidenceBall(continue_callback=self._advance_step), + TrainingGuideSteeringArc(continue_callback=self._advance_step), + ] + + def _advance_step(self): + if self._step < len(self._steps) - 1: + self._step += 1 + self._steps[self._step].show_event() + else: + self._step = 0 + if self._completed_callback: + self._completed_callback() + + def _render(self, _): + if self._step < len(self._steps): + self._steps[self._step].render(self._rect) + return -1 + + +class DeclinePage(Widget): + def __init__(self, back_callback=None): + super().__init__() + self._uninstall_slider = SmallSlider("uninstall openpilot", self._on_uninstall) + + self._back_button = SmallButton("back") + self._back_button.set_click_callback(back_callback) + + self._warning_header = TermsHeader("you must accept the\nterms to use openpilot", + gui_app.texture("icons_mici/setup/red_warning.png", 66, 60)) + + def _on_uninstall(self): + ui_state.params.put_bool("DoUninstall", True) + gui_app.request_close() + + def _render(self, _): + self._warning_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16, + self._warning_header.rect.width, + self._warning_header.rect.height, + )) + + self._back_button.set_opacity(1 - self._uninstall_slider.slider_percentage) + self._back_button.render(rl.Rectangle( + self._rect.x + 8, + self._rect.y + self._rect.height - self._back_button.rect.height, + self._back_button.rect.width, + self._back_button.rect.height, + )) + + self._uninstall_slider.render(rl.Rectangle( + self._rect.x + self._rect.width - self._uninstall_slider.rect.width, + self._rect.y + self._rect.height - self._uninstall_slider.rect.height, + self._uninstall_slider.rect.width, + self._uninstall_slider.rect.height, + )) + + +class TermsPage(SetupTermsPage): + def __init__(self, on_accept=None, on_decline=None): + super().__init__(on_accept, on_decline, "decline") + + info_txt = gui_app.texture("icons_mici/setup/green_info.png", 60, 60) + self._title_header = TermsHeader("scroll down to read &\n accept terms", info_txt) + + self._terms_label = UnifiedLabel("You must accept the Terms and Conditions to use openpilot. " + + "Read the latest terms at https://comma.ai/terms before continuing.", 36, + FontWeight.ROMAN) + + @property + def _content_height(self): + return self._terms_label.rect.y + self._terms_label.rect.height - self._scroll_panel.get_offset() + + def _render_content(self, scroll_offset): + self._title_header.set_position(self._rect.x + 16, self._rect.y + 12 + scroll_offset) + self._title_header.render() + + self._terms_label.render(rl.Rectangle( + self._rect.x + 16, + self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING, + self._rect.width - 100, + self._terms_label.get_content_height(int(self._rect.width - 100)), + )) + + +class 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 + + self.set_rect(rl.Rectangle(0, 0, 458, gui_app.height)) + + # Windows + self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined) + self._training_guide = TrainingGuide(completed_callback=self._on_completed_training) + 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 close(self): + ui_state.params.put_bool("IsDriverViewEnabled", False) + gui_app.set_modal_overlay(None) + + def _on_terms_accepted(self): + ui_state.params.put("HasAcceptedTerms", self._current_terms_version) + self._state = OnboardingState.ONBOARDING + + def _on_completed_training(self): + ui_state.params.put("CompletedTrainingVersion", self._current_training_version) + self.close() + + def _render(self, _): + if self._state == OnboardingState.TERMS: + self._terms.render(self._rect) + elif 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/mici/layouts/settings/developer.py b/selfdrive/ui/mici/layouts/settings/developer.py new file mode 100644 index 0000000000..8fc63e8963 --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/developer.py @@ -0,0 +1,151 @@ +import pyray as rl +from collections.abc import Callable + +from openpilot.common.time_helpers import system_time_valid +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle, BigParamControl +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import NavWidget +from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyAction + + +class DeveloperLayoutMici(NavWidget): + def __init__(self, back_callback: Callable): + super().__init__() + self.set_back_callback(back_callback) + + def github_username_callback(username: str): + if username: + ssh_keys = SshKeyAction() + ssh_keys._fetch_ssh_key(username) + if not ssh_keys._error_message: + self._ssh_keys_btn.set_value(username) + else: + dlg = BigDialog("", ssh_keys._error_message) + gui_app.set_modal_overlay(dlg) + + def ssh_keys_callback(): + github_username = ui_state.params.get("GithubUsername") or "" + dlg = BigInputDialog("enter GitHub username", github_username, confirm_callback=github_username_callback) + if not system_time_valid(): + dlg = BigDialog("Please connect to Wi-Fi to fetch your key", "") + gui_app.set_modal_overlay(dlg) + return + gui_app.set_modal_overlay(dlg) + + txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 77, 44) + github_username = ui_state.params.get("GithubUsername") or "" + self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh) + self._ssh_keys_btn.set_click_callback(ssh_keys_callback) + + # adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address + # ******** Main Scroller ******** + self._adb_toggle = BigParamControl("enable ADB", "AdbEnabled") + self._ssh_toggle = BigParamControl("enable SSH", "SshEnabled") + self._joystick_toggle = BigToggle("joystick debug mode", + initial_state=ui_state.params.get_bool("JoystickDebugMode"), + toggle_callback=self._on_joystick_debug_mode) + self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", + initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), + toggle_callback=self._on_long_maneuver_mode) + self._alpha_long_toggle = BigToggle("alpha longitudinal", + initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), + toggle_callback=self._on_alpha_long_enabled) + self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo", + toggle_callback=lambda checked: (gui_app.set_show_touches(checked), + gui_app.set_show_fps(checked))) + + self._scroller = Scroller([ + self._adb_toggle, + self._ssh_toggle, + self._ssh_keys_btn, + self._joystick_toggle, + self._long_maneuver_toggle, + self._alpha_long_toggle, + self._debug_mode_toggle, + ], snap_items=False) + + # Toggle lists + self._refresh_toggles = ( + ("AdbEnabled", self._adb_toggle), + ("SshEnabled", self._ssh_toggle), + ("JoystickDebugMode", self._joystick_toggle), + ("LongitudinalManeuverMode", self._long_maneuver_toggle), + ("AlphaLongitudinalEnabled", self._alpha_long_toggle), + ("ShowDebugInfo", self._debug_mode_toggle), + ) + onroad_blocked_toggles = (self._adb_toggle, self._joystick_toggle) + release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._alpha_long_toggle) + engaged_blocked_toggles = (self._long_maneuver_toggle, self._alpha_long_toggle) + + # Hide non-release toggles on release builds + for item in release_blocked_toggles: + item.set_visible(not ui_state.is_release) + + # Disable toggles that require offroad + for item in onroad_blocked_toggles: + item.set_enabled(lambda: ui_state.is_offroad()) + + # Disable toggles that require not engaged + for item in engaged_blocked_toggles: + item.set_enabled(lambda: not ui_state.engaged) + + # Set initial state + if ui_state.params.get_bool("ShowDebugInfo"): + gui_app.set_show_touches(True) + gui_app.set_show_fps(True) + + ui_state.add_offroad_transition_callback(self._update_toggles) + + def show_event(self): + super().show_event() + self._scroller.show_event() + self._update_toggles() + + def _render(self, rect: rl.Rectangle): + self._scroller.render(rect) + + def _update_toggles(self): + ui_state.update_params() + + # CP gating + if ui_state.CP is not None: + alpha_avail = ui_state.CP.alphaLongitudinalAvailable + if not alpha_avail or ui_state.is_release: + self._alpha_long_toggle.set_visible(False) + ui_state.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.set_enabled(long_man_enabled) + if not long_man_enabled: + self._long_maneuver_toggle.set_checked(False) + ui_state.params.put_bool("LongitudinalManeuverMode", False) + else: + self._long_maneuver_toggle.set_enabled(False) + self._alpha_long_toggle.set_visible(False) + + # Refresh toggles from params to mirror external changes + for key, item in self._refresh_toggles: + item.set_checked(ui_state.params.get_bool(key)) + + def _on_joystick_debug_mode(self, state: bool): + ui_state.params.put_bool("JoystickDebugMode", state) + ui_state.params.put_bool("LongitudinalManeuverMode", False) + self._long_maneuver_toggle.set_checked(False) + + def _on_long_maneuver_mode(self, state: bool): + ui_state.params.put_bool("LongitudinalManeuverMode", state) + ui_state.params.put_bool("JoystickDebugMode", False) + self._joystick_toggle.set_checked(False) + restart_needed_callback(state) + + def _on_alpha_long_enabled(self, state: bool): + # TODO: show confirmation dialog before enabling + ui_state.params.put_bool("AlphaLongitudinalEnabled", state) + restart_needed_callback(state) + self._update_toggles() diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py new file mode 100644 index 0000000000..de2e11caf2 --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/device.py @@ -0,0 +1,378 @@ +import os +import threading +import json +import pyray as rl +from enum import IntEnum +from collections.abc import Callable + +from openpilot.common.basedir import BASEDIR +from openpilot.common.params import Params +from openpilot.common.time_helpers import system_time_valid +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton +from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog, BigDialog, BigConfirmationDialogV2 +from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog +from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog +from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import Widget, NavWidget +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.widgets.label import MiciLabel +from openpilot.system.ui.widgets.html_render import HtmlModal, HtmlRenderer +from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID + + +class MiciFccModal(NavWidget): + BACK_TOUCH_AREA_PERCENTAGE = 0.1 + + def __init__(self, file_path: str | None = None, text: str | None = None): + super().__init__() + self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) + self._content = HtmlRenderer(file_path=file_path, text=text) + self._scroll_panel = GuiScrollPanel2(horizontal=False) + self._fcc_logo = gui_app.texture("icons_mici/settings/device/fcc_logo.png", 76, 64) + + def _render(self, rect: rl.Rectangle): + content_height = self._content.get_total_height(int(rect.width)) + content_height += self._fcc_logo.height + 20 + + scroll_content_rect = rl.Rectangle(rect.x, rect.y, rect.width, content_height) + scroll_offset = self._scroll_panel.update(rect, scroll_content_rect.height) + + fcc_pos = rl.Vector2(rect.x + 20, rect.y + 20 + scroll_offset) + + scroll_content_rect.y += scroll_offset + self._fcc_logo.height + 20 + self._content.render(scroll_content_rect) + + rl.draw_texture_ex(self._fcc_logo, fcc_pos, 0.0, 1.0, rl.WHITE) + + return -1 + + +def _engaged_confirmation_callback(callback: Callable, action_text: str): + if not ui_state.engaged: + def confirm_callback(): + # Check engaged again in case it changed while the dialog was open + if not ui_state.engaged: + callback() + + red = False + if action_text == "power off": + icon = "icons_mici/settings/device/power.png" + red = True + elif action_text == "reboot": + icon = "icons_mici/settings/device/reboot.png" + elif action_text == "reset": + icon = "icons_mici/settings/device/lkas.png" + elif action_text == "uninstall": + icon = "icons_mici/settings/device/uninstall.png" + else: + # TODO: check + icon = "icons_mici/settings/comma_icon.png" + + dlg: BigConfirmationDialogV2 | BigDialog = BigConfirmationDialogV2(f"slide to\n{action_text.lower()}", icon, red=red, + exit_on_confirm=action_text == "reset", + confirm_callback=confirm_callback) + gui_app.set_modal_overlay(dlg) + else: + dlg = BigDialog(f"Disengage to {action_text}", "") + gui_app.set_modal_overlay(dlg) + + +class DeviceInfoLayoutMici(Widget): + def __init__(self): + super().__init__() + + self.set_rect(rl.Rectangle(0, 0, 360, 180)) + + params = Params() + header_color = rl.Color(255, 255, 255, int(255 * 0.9)) + subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + max_width = int(self._rect.width - 20) + self._dongle_id_label = MiciLabel("device ID", 48, width=max_width, color=header_color, font_weight=FontWeight.DISPLAY) + self._dongle_id_text_label = MiciLabel(params.get("DongleId") or 'N/A', 32, width=max_width, color=subheader_color, font_weight=FontWeight.ROMAN) + + self._serial_number_label = MiciLabel("serial", 48, color=header_color, font_weight=FontWeight.DISPLAY) + self._serial_number_text_label = MiciLabel(params.get("HardwareSerial") or 'N/A', 32, width=max_width, color=subheader_color, font_weight=FontWeight.ROMAN) + + def _render(self, _): + self._dongle_id_label.set_position(self._rect.x + 20, self._rect.y - 10) + self._dongle_id_label.render() + + self._dongle_id_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25) + self._dongle_id_text_label.render() + + self._serial_number_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30) + self._serial_number_label.render() + + self._serial_number_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25) + self._serial_number_text_label.render() + + +class UpdaterState(IntEnum): + IDLE = 0 + WAITING_FOR_UPDATER = 1 + UPDATER_RESPONDING = 2 + + +class PairBigButton(BigButton): + def __init__(self): + super().__init__("pair", "connect.comma.ai", "icons_mici/settings/comma_icon.png") + + def _update_state(self): + if ui_state.prime_state.is_paired(): + self.set_text("paired") + if ui_state.prime_state.is_prime(): + self.set_value("subscribed") + else: + self.set_value("upgrade to prime") + else: + self.set_text("pair") + self.set_value("connect.comma.ai") + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + + # TODO: show ad dialog when clicked if not prime + if ui_state.prime_state.is_paired(): + return + dlg: BigDialog | PairingDialog + if not system_time_valid(): + dlg = BigDialog(tr("Please connect to Wi-Fi to complete initial pairing"), "") + elif UNREGISTERED_DONGLE_ID == (ui_state.params.get("DongleId") or UNREGISTERED_DONGLE_ID): + dlg = BigDialog(tr("Device must be registered with the comma.ai backend to pair"), "") + else: + dlg = PairingDialog() + gui_app.set_modal_overlay(dlg) + + +UPDATER_TIMEOUT = 10.0 # seconds to wait for updater to respond + + +class UpdateOpenpilotBigButton(BigButton): + def __init__(self): + self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64) + self._txt_reboot_icon = gui_app.texture("icons_mici/settings/device/reboot.png", 64, 64) + self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64) + super().__init__("update openpilot", "", self._txt_update_icon) + + self._waiting_for_updater_t: float | None = None + self._hide_value_t: float | None = None + self._state: UpdaterState = UpdaterState.IDLE + + ui_state.add_offroad_transition_callback(self.offroad_transition) + + def offroad_transition(self): + if ui_state.is_offroad(): + self.set_enabled(True) + + def _handle_mouse_release(self, mouse_pos: MousePos): + self.set_enabled(False) + self._state = UpdaterState.WAITING_FOR_UPDATER + self.set_icon(self._txt_update_icon) + + def run(): + if self.get_value() == "download update": + os.system("pkill -SIGHUP -f system.updated.updated") + elif self.get_value() == "update now": + ui_state.params.put_bool("DoReboot", True) + else: + os.system("pkill -SIGUSR1 -f system.updated.updated") + + threading.Thread(target=run, daemon=True).start() + + def set_value(self, value: str): + super().set_value(value) + if value: + self.set_text("") + else: + self.set_text("update openpilot") + + def _update_state(self): + if ui_state.started: + self.set_enabled(False) + return + + updater_state = ui_state.params.get("UpdaterState") or "" + failed_count = ui_state.params.get("UpdateFailedCount") + failed = False if failed_count is None else int(failed_count) > 0 + + if ui_state.params.get_bool("UpdateAvailable"): + self.set_rotate_icon(False) + self.set_enabled(True) + if self.get_value() != "update now": + self.set_value("update now") + self.set_icon(self._txt_reboot_icon) + + elif self._state == UpdaterState.WAITING_FOR_UPDATER: + self.set_rotate_icon(True) + if updater_state != "idle": + self._state = UpdaterState.UPDATER_RESPONDING + + # Recover from updater not responding (time invalid shortly after boot) + if self._waiting_for_updater_t is None: + self._waiting_for_updater_t = rl.get_time() + + if self._waiting_for_updater_t is not None and rl.get_time() - self._waiting_for_updater_t > UPDATER_TIMEOUT: + self.set_rotate_icon(False) + self.set_value("updater failed to respond") + self._state = UpdaterState.IDLE + self._hide_value_t = rl.get_time() + + elif self._state == UpdaterState.UPDATER_RESPONDING: + if updater_state == "idle": + self.set_rotate_icon(False) + self._state = UpdaterState.IDLE + self._hide_value_t = rl.get_time() + else: + if self.get_value() != updater_state: + self.set_value(updater_state) + + elif self._state == UpdaterState.IDLE: + self.set_rotate_icon(False) + if failed: + if self.get_value() != "failed to update": + self.set_value("failed to update") + + elif ui_state.params.get_bool("UpdaterFetchAvailable"): + self.set_enabled(True) + if self.get_value() != "download update": + self.set_value("download update") + + elif self._hide_value_t is not None: + self.set_enabled(True) + if self.get_value() == "checking...": + self.set_value("up to date") + self.set_icon(self._txt_up_to_date_icon) + + # Hide previous text after short amount of time (up to date or failed) + if rl.get_time() - self._hide_value_t > 3.0: + self._hide_value_t = None + self.set_value("") + self.set_icon(self._txt_update_icon) + else: + if self.get_value() != "": + self.set_value("") + + if self._state != UpdaterState.WAITING_FOR_UPDATER: + self._waiting_for_updater_t = None + + +class DeviceLayoutMici(NavWidget): + def __init__(self, back_callback: Callable): + super().__init__() + + self._fcc_dialog: HtmlModal | None = None + self._driver_camera: DriverCameraDialog | None = None + self._training_guide: TrainingGuide | None = None + + def power_off_callback(): + ui_state.params.put_bool("DoShutdown", True) + + def reboot_callback(): + ui_state.params.put_bool("DoReboot", True) + + def reset_calibration_callback(): + params = ui_state.params + params.remove("CalibrationParams") + params.remove("LiveTorqueParameters") + params.remove("LiveParameters") + params.remove("LiveParametersV2") + params.remove("LiveDelay") + params.put_bool("OnroadCycleRequested", True) + + def uninstall_openpilot_callback(): + ui_state.params.put_bool("DoUninstall", True) + + reset_calibration_btn = BigButton("reset calibration", "", "icons_mici/settings/device/lkas.png") + reset_calibration_btn.set_click_callback(lambda: _engaged_confirmation_callback(reset_calibration_callback, "reset")) + + uninstall_openpilot_btn = BigButton("uninstall openpilot", "", "icons_mici/settings/device/uninstall.png") + uninstall_openpilot_btn.set_click_callback(lambda: _engaged_confirmation_callback(uninstall_openpilot_callback, "uninstall")) + + reboot_btn = BigCircleButton("icons_mici/settings/device/reboot.png", red=False) + reboot_btn.set_click_callback(lambda: _engaged_confirmation_callback(reboot_callback, "reboot")) + + self._power_off_btn = BigCircleButton("icons_mici/settings/device/power.png", red=True) + self._power_off_btn.set_click_callback(lambda: _engaged_confirmation_callback(power_off_callback, "power off")) + + self._load_languages() + + def language_callback(): + def selected_language_callback(): + selected_language = dlg.get_selected_option() + ui_state.params.put("LanguageSetting", self._languages[selected_language]) + + current_language_name = ui_state.params.get("LanguageSetting") + current_language = next(name for name, lang in self._languages.items() if lang == current_language_name) + + dlg = BigMultiOptionDialog(list(self._languages), default=current_language, right_btn_callback=selected_language_callback) + gui_app.set_modal_overlay(dlg) + + # lang_button = BigButton("change language", "", "icons_mici/settings/device/language.png") + # lang_button.set_click_callback(language_callback) + + regulatory_btn = BigButton("regulatory info", "", "icons_mici/settings/device/info.png") + regulatory_btn.set_click_callback(self._on_regulatory) + + driver_cam_btn = BigButton("driver camera preview", "", "icons_mici/settings/device/cameras.png") + driver_cam_btn.set_click_callback(self._show_driver_camera) + driver_cam_btn.set_enabled(lambda: ui_state.is_offroad()) + + review_training_guide_btn = BigButton("review training guide", "", "icons_mici/settings/device/info.png") + review_training_guide_btn.set_click_callback(self._on_review_training_guide) + review_training_guide_btn.set_enabled(lambda: ui_state.is_offroad()) + + self._scroller = Scroller([ + DeviceInfoLayoutMici(), + UpdateOpenpilotBigButton(), + PairBigButton(), + review_training_guide_btn, + driver_cam_btn, + # lang_button, + reset_calibration_btn, + uninstall_openpilot_btn, + regulatory_btn, + reboot_btn, + self._power_off_btn, + ], snap_items=False) + + # Set up back navigation + self.set_back_callback(back_callback) + + # Hide power off button when onroad + ui_state.add_offroad_transition_callback(self._offroad_transition) + + def _on_regulatory(self): + if not self._fcc_dialog: + self._fcc_dialog = MiciFccModal(os.path.join(BASEDIR, "selfdrive/assets/offroad/mici_fcc.html")) + gui_app.set_modal_overlay(self._fcc_dialog, callback=setattr(self, '_fcc_dialog', None)) + + def _offroad_transition(self): + self._power_off_btn.set_visible(ui_state.is_offroad()) + + def _show_driver_camera(self): + if not self._driver_camera: + self._driver_camera = DriverCameraDialog() + gui_app.set_modal_overlay(self._driver_camera, callback=lambda result: setattr(self, '_driver_camera', None)) + + def _on_review_training_guide(self): + if not self._training_guide: + def completed_callback(): + gui_app.set_modal_overlay(None) + + self._training_guide = TrainingGuide(completed_callback=completed_callback) + gui_app.set_modal_overlay(self._training_guide, callback=lambda result: setattr(self, '_training_guide', None)) + + def _load_languages(self): + with open(os.path.join(BASEDIR, "selfdrive/ui/translations/languages.json")) as f: + self._languages = json.load(f) + + def show_event(self): + super().show_event() + self._scroller.show_event() + + def _render(self, rect: rl.Rectangle): + self._scroller.render(rect) diff --git a/selfdrive/ui/mici/layouts/settings/firehose.py b/selfdrive/ui/mici/layouts/settings/firehose.py new file mode 100644 index 0000000000..303d976b07 --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/firehose.py @@ -0,0 +1,223 @@ +import threading +import time +import pyray as rl + +from openpilot.common.api import api_get +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.ui.lib.api_helpers import get_token +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, FONT_SCALE +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 +from openpilot.system.ui.lib.multilang import tr, trn, tr_noop +from openpilot.system.ui.widgets import NavWidget + + +TITLE = tr_noop("Firehose Mode") +DESCRIPTION = tr_noop( + "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_INTRO = tr_noop( + "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." +) +FAQ_HEADER = tr_noop("Frequently Asked Questions") +FAQ_ITEMS = [ + (tr_noop("Does it matter how or where I drive?"), tr_noop("Nope, just drive as you normally would.")), + (tr_noop("Do all of my segments get pulled in Firehose Mode?"), tr_noop("No, we selectively pull a subset of your segments.")), + (tr_noop("What's a good USB-C adapter?"), tr_noop("Any fast phone or laptop charger should be fine.")), + (tr_noop("Does it matter which software I run?"), tr_noop("Yes, only upstream openpilot (and particular forks) are able to be used for training.")), +] + + +class FirehoseLayoutMici(NavWidget): + BACK_TOUCH_AREA_PERCENTAGE = 0.1 + + PARAM_KEY = "ApiCache_FirehoseStats" + GREEN = rl.Color(46, 204, 113, 255) + RED = rl.Color(231, 76, 60, 255) + GRAY = rl.Color(68, 68, 68, 255) + LIGHT_GRAY = rl.Color(228, 228, 228, 255) + UPDATE_INTERVAL = 30 # seconds + + def __init__(self, back_callback): + super().__init__() + self.set_back_callback(back_callback) + + self.params = Params() + self.segment_count = self._get_segment_count() + + self._scroll_panel = GuiScrollPanel2(horizontal=False) + self._content_height = 0 + + self._running = True + self._update_thread = threading.Thread(target=self._update_loop, daemon=True) + self._update_thread.start() + + def __del__(self): + self._running = False + try: + if self._update_thread and self._update_thread.is_alive(): + self._update_thread.join(timeout=1.0) + except Exception: + pass + + def show_event(self): + super().show_event() + self._scroll_panel.set_offset(0) + + def _get_segment_count(self) -> int: + stats = self.params.get(self.PARAM_KEY) + if not stats: + return 0 + try: + return int(stats.get("firehose", 0)) + except Exception: + cloudlog.exception(f"Failed to decode firehose stats: {stats}") + return 0 + + def _render(self, rect: rl.Rectangle): + # compute total content height for scrolling + content_height = self._measure_content_height(rect) + scroll_offset = self._scroll_panel.update(rect, content_height) + + # start drawing with offset + x = int(rect.x + 40) + y = int(rect.y + 40 + scroll_offset) + w = int(rect.width - 80) + + # Title + title_text = tr(TITLE) + title_font = gui_app.font(FontWeight.BOLD) + title_size = 64 + rl.draw_text_ex(title_font, title_text, rl.Vector2(x, y), title_size, 0, rl.WHITE) + y += int(title_size * FONT_SCALE) + 20 + + # Description + y = self._draw_wrapped_text(x, y, w, tr(DESCRIPTION), gui_app.font(FontWeight.ROMAN), 36, rl.WHITE) + y += 20 + + # Separator + rl.draw_rectangle(x, y, w, 2, self.GRAY) + y += 20 + + # Status + status_text, status_color = self._get_status() + y = self._draw_wrapped_text(x, y, w, status_text, gui_app.font(FontWeight.BOLD), 48, status_color) + y += 20 + + # Contribution count (if available) + if self.segment_count > 0: + 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), 42, rl.WHITE) + y += 20 + + # Separator + rl.draw_rectangle(x, y, w, 2, self.GRAY) + y += 20 + + # Instructions intro + y = self._draw_wrapped_text(x, y, w, tr(INSTRUCTIONS_INTRO), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY) + y += 20 + + # FAQ Header + y = self._draw_wrapped_text(x, y, w, tr(FAQ_HEADER), gui_app.font(FontWeight.BOLD), 44, rl.WHITE) + y += 20 + + # FAQ Items + for question, answer in FAQ_ITEMS: + y = self._draw_wrapped_text(x, y, w, tr(question), gui_app.font(FontWeight.BOLD), 32, self.LIGHT_GRAY) + y = self._draw_wrapped_text(x, y, w, tr(answer), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY) + y += 20 + + # return value not used by NavWidget + return -1 + + 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), font_size, 0, color) + y += int(font_size * FONT_SCALE) + return y + + def _measure_content_height(self, rect: rl.Rectangle) -> int: + # Rough measurement using the same wrapping as rendering + w = int(rect.width - 80) + y = 40 + + # Title + title_size = 72 + y += int(title_size * FONT_SCALE) + 20 + + # Description + desc_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(DESCRIPTION), 36, w) + y += int(len(desc_lines) * 36 * FONT_SCALE) + 20 + + # Separator + Status + y += 2 + 20 + status_text, _ = self._get_status() + status_lines = wrap_text(gui_app.font(FontWeight.BOLD), status_text, 48, w) + y += int(len(status_lines) * 48 * FONT_SCALE) + 20 + + # Contribution count + if self.segment_count > 0: + 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) + contrib_lines = wrap_text(gui_app.font(FontWeight.BOLD), contrib_text, 42, w) + y += int(len(contrib_lines) * 42 * FONT_SCALE) + 20 + + # Separator + Instructions + y += 2 + 20 + + # Instructions intro + intro_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(INSTRUCTIONS_INTRO), 32, w) + y += int(len(intro_lines) * 32 * FONT_SCALE) + 20 + + # FAQ Header + faq_header_lines = wrap_text(gui_app.font(FontWeight.BOLD), tr(FAQ_HEADER), 44, w) + y += int(len(faq_header_lines) * 44 * FONT_SCALE) + 20 + + # FAQ Items + for question, answer in FAQ_ITEMS: + q_lines = wrap_text(gui_app.font(FontWeight.BOLD), tr(question), 32, w) + y += int(len(q_lines) * 32 * FONT_SCALE) + a_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(answer), 32, w) + y += int(len(a_lines) * 32 * FONT_SCALE) + 20 + + # bottom padding + y += 40 + return 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 tr("ACTIVE"), self.GREEN + else: + return tr("INACTIVE: connect to an unmetered network"), self.RED + + def _fetch_firehose_stats(self): + try: + dongle_id = self.params.get("DongleId") + if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID: + return + identity_token = get_token(dongle_id) + response = api_get(f"v1/devices/{dongle_id}/firehose_stats", access_token=identity_token) + if response.status_code == 200: + data = response.json() + self.segment_count = data.get("firehose", 0) + self.params.put(self.PARAM_KEY, data) + except Exception as e: + cloudlog.error(f"Failed to fetch firehose stats: {e}") + + def _update_loop(self): + while self._running: + if not ui_state.started: + self._fetch_firehose_stats() + time.sleep(self.UPDATE_INTERVAL) diff --git a/selfdrive/ui/mici/layouts/settings/network.py b/selfdrive/ui/mici/layouts/settings/network.py new file mode 100644 index 0000000000..a62c1d153a --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/network.py @@ -0,0 +1,558 @@ +import math +import numpy as np +import pyray as rl +from enum import IntEnum +from collections.abc import Callable + +from openpilot.common.swaglog import cloudlog +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigToggle +from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog, BigInputDialog, BigDialogOptionButton, BigConfirmationDialogV2 +from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight +from openpilot.system.ui.widgets import Widget, NavWidget +from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, MeteredType + + +def normalize_ssid(ssid: str) -> str: + return ssid.replace("’", "'") # for iPhone hotspots + + +class NetworkPanelType(IntEnum): + NONE = 0 + WIFI = 1 + + +class LoadingAnimation(Widget): + def _render(self, _): + cx = int(self._rect.x + 70) + cy = int(self._rect.y + self._rect.height / 2 - 50) + + y_mag = 20 + anim_scale = 5 + spacing = 28 + + for i in range(3): + x = cx - spacing + i * spacing + y = int(cy + min(math.sin((rl.get_time() - i * 0.2) * anim_scale) * y_mag, 0)) + alpha = int(np.interp(cy - y, [0, y_mag], [255 * 0.45, 255 * 0.9])) + rl.draw_circle(x, y, 10, rl.Color(255, 255, 255, alpha)) + + +class WifiIcon(Widget): + def __init__(self): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, 89, 64)) + + self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 89, 64) + self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 89, 64) + self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 89, 64) + self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 89, 64) + self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 89, 64) + self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 23, 32) + + self._network: Network | None = None + self._scale = 1.0 + + def set_current_network(self, network: Network): + self._network = network + + def set_scale(self, scale: float): + self._scale = scale + + def _render(self, _): + if self._network is None: + return + + # Determine which wifi strength icon to use + strength = round(self._network.strength / 100 * 4) + if strength == 4: + strength_icon = self._wifi_full_txt + elif strength == 3: + strength_icon = self._wifi_medium_txt + elif strength == 2: + strength_icon = self._wifi_low_txt + elif self._network.strength < 0: + strength_icon = self._wifi_slash_txt + else: + strength_icon = self._wifi_none_txt + + icon_x = int(self._rect.x + (self._rect.width - strength_icon.width * self._scale) // 2) + icon_y = int(self._rect.y + (self._rect.height - strength_icon.height * self._scale) // 2) + rl.draw_texture_ex(strength_icon, (icon_x, icon_y), 0.0, self._scale, rl.WHITE) + + # Render lock icon at lower right of wifi icon if secured + if self._network.security_type not in (SecurityType.OPEN, SecurityType.UNSUPPORTED): + lock_scale = self._scale * 1.1 + lock_x = int(icon_x + 1 + strength_icon.width * self._scale - self._lock_txt.width * lock_scale / 2) + lock_y = int(icon_y + 1 + strength_icon.height * self._scale - self._lock_txt.height * lock_scale / 2) + rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, lock_scale, rl.WHITE) + + +class WifiItem(BigDialogOptionButton): + LEFT_MARGIN = 20 + + def __init__(self, network: Network): + super().__init__(network.ssid) + + self.set_rect(rl.Rectangle(0, 0, gui_app.width, 64)) + + self._selected_txt = gui_app.texture("icons_mici/settings/network/new/wifi_selected.png", 48, 96) + + self._network = network + self._wifi_icon = WifiIcon() + self._wifi_icon.set_current_network(network) + + def set_current_network(self, network: Network): + self._network = network + self._wifi_icon.set_current_network(network) + + def _render(self, _): + if self._network.is_connected: + selected_x = int(self._rect.x - self._selected_txt.width / 2) + selected_y = int(self._rect.y + (self._rect.height - self._selected_txt.height) / 2) + rl.draw_texture(self._selected_txt, selected_x, selected_y, rl.WHITE) + + self._wifi_icon.set_scale((1.0 if self._selected else 0.65) * 0.7) + self._wifi_icon.render(rl.Rectangle( + self._rect.x + self.LEFT_MARGIN, + self._rect.y, + self._rect.height, + self._rect.height + )) + + if self._selected: + self._label.set_font_size(74) + self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9))) + self._label.set_font_weight(FontWeight.DISPLAY) + else: + self._label.set_font_size(70) + self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.58))) + self._label.set_font_weight(FontWeight.DISPLAY_REGULAR) + + label_offset = self.LEFT_MARGIN + self._wifi_icon.rect.width + 20 + label_rect = rl.Rectangle(self._rect.x + label_offset, self._rect.y, self._rect.width - label_offset, self._rect.height) + self._label.set_text(normalize_ssid(self._network.ssid)) + self._label.render(label_rect) + + +class ConnectButton(Widget): + def __init__(self): + super().__init__() + self._bg_txt = gui_app.texture("icons_mici/settings/network/new/connect_button.png", 410, 100) + self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/connect_button_pressed.png", 410, 100) + self._bg_full_txt = gui_app.texture("icons_mici/settings/network/new/full_connect_button.png", 520, 100) + self._bg_full_pressed_txt = gui_app.texture("icons_mici/settings/network/new/full_connect_button_pressed.png", 520, 100) + + self._full: bool = False + + self._label = UnifiedLabel("", 36, FontWeight.MEDIUM, rl.Color(255, 255, 255, int(255 * 0.9)), + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + @property + def full(self) -> bool: + return self._full + + def set_full(self, full: bool): + self._full = full + self.set_rect(rl.Rectangle(0, 0, 520 if self._full else 410, 100)) + + def set_label(self, text: str): + self._label.set_text(text) + + def _render(self, _): + if self._full: + bg_txt = self._bg_full_pressed_txt if self.is_pressed and self.enabled else self._bg_full_txt + else: + bg_txt = self._bg_pressed_txt if self.is_pressed and self.enabled else self._bg_txt + + rl.draw_texture(bg_txt, int(self._rect.x), int(self._rect.y), rl.WHITE) + + self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9) if self.enabled else int(255 * 0.9 * 0.65))) + self._label.render(self._rect) + + +class ForgetButton(Widget): + HORIZONTAL_MARGIN = 8 + + def __init__(self, forget_network: Callable, open_network_manage_page): + super().__init__() + self._forget_network = forget_network + self._open_network_manage_page = open_network_manage_page + + self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 100, 100) + self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 100, 100) + self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 32, 36) + self.set_rect(rl.Rectangle(0, 0, 100 + self.HORIZONTAL_MARGIN * 2, 100)) + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + dlg = BigConfirmationDialogV2("slide to forget", "icons_mici/settings/network/new/trash.png", red=True, + confirm_callback=self._forget_network) + gui_app.set_modal_overlay(dlg, callback=self._open_network_manage_page) + + def _render(self, _): + bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt + rl.draw_texture(bg_txt, int(self._rect.x + self.HORIZONTAL_MARGIN), int(self._rect.y), rl.WHITE) + + trash_x = int(self._rect.x + (self._rect.width - self._trash_txt.width) // 2) + trash_y = int(self._rect.y + (self._rect.height - self._trash_txt.height) // 2) + rl.draw_texture(self._trash_txt, trash_x, trash_y, rl.WHITE) + + +class NetworkInfoPage(NavWidget): + def __init__(self, wifi_manager, connect_callback: Callable, forget_callback: Callable, open_network_manage_page: Callable): + super().__init__() + self._wifi_manager = wifi_manager + + self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + + self._wifi_icon = WifiIcon() + self._forget_btn = ForgetButton(lambda: forget_callback(self._network.ssid) if self._network is not None else None, + open_network_manage_page) + self._connect_btn = ConnectButton() + self._connect_btn.set_click_callback(lambda: connect_callback(self._network.ssid) if self._network is not None else None) + + self._title = UnifiedLabel("", 64, FontWeight.DISPLAY, rl.Color(255, 255, 255, int(255 * 0.9)), + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + self._subtitle = UnifiedLabel("", 36, FontWeight.ROMAN, rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)), + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) + + # State + self._network: Network | None = None + self._connecting: Callable[[], str | None] | None = None + + def update_networks(self, networks: dict[str, Network]): + # update current network from latest scan results + for ssid, network in networks.items(): + if self._network is not None and ssid == self._network.ssid: + self.set_current_network(network) + break + else: + # network disappeared, close page + gui_app.set_modal_overlay(None) + + def _update_state(self): + super()._update_state() + # Modal overlays stop main UI rendering, so we need to call here + self._wifi_manager.process_callbacks() + + if self._network is None: + return + + self._connect_btn.set_full(not self._network.is_saved and not self._is_connecting) + if self._is_connecting: + self._connect_btn.set_label("connecting...") + self._connect_btn.set_enabled(False) + elif self._network.is_connected: + self._connect_btn.set_label("connected") + self._connect_btn.set_enabled(False) + elif self._network.security_type == SecurityType.UNSUPPORTED: + self._connect_btn.set_label("connect") + self._connect_btn.set_enabled(False) + else: # saved or unknown + self._connect_btn.set_label("connect") + self._connect_btn.set_enabled(True) + + self._title.set_text(normalize_ssid(self._network.ssid)) + if self._network.security_type == SecurityType.OPEN: + self._subtitle.set_text("open") + elif self._network.security_type == SecurityType.UNSUPPORTED: + self._subtitle.set_text("unsupported") + else: + self._subtitle.set_text("secured") + + def set_current_network(self, network: Network): + self._network = network + self._wifi_icon.set_current_network(network) + + def set_connecting(self, is_connecting: Callable[[], str | None]): + self._connecting = is_connecting + + @property + def _is_connecting(self): + if self._connecting is None or self._network is None: + return False + is_connecting = self._connecting() == self._network.ssid + return is_connecting + + def _render(self, _): + self._wifi_icon.render(rl.Rectangle( + self._rect.x + 32, + self._rect.y + (self._rect.height - self._connect_btn.rect.height - self._wifi_icon.rect.height) / 2, + self._wifi_icon.rect.width, + self._wifi_icon.rect.height, + )) + + self._title.render(rl.Rectangle( + self._rect.x + self._wifi_icon.rect.width + 32 + 32, + self._rect.y + 32 - 16, + self._rect.width - (self._wifi_icon.rect.width + 32 + 32), + 64, + )) + + self._subtitle.render(rl.Rectangle( + self._rect.x + self._wifi_icon.rect.width + 32 + 32, + self._rect.y + 32 + 64 - 16, + self._rect.width - (self._wifi_icon.rect.width + 32 + 32), + 48, + )) + + self._connect_btn.render(rl.Rectangle( + self._rect.x + 8, + self._rect.y + self._rect.height - self._connect_btn.rect.height, + self._connect_btn.rect.width, + self._connect_btn.rect.height, + )) + + if not self._connect_btn.full: + self._forget_btn.render(rl.Rectangle( + self._rect.x + self._rect.width - self._forget_btn.rect.width, + self._rect.y + self._rect.height - self._forget_btn.rect.height, + self._forget_btn.rect.width, + self._forget_btn.rect.height, + )) + + return -1 + + +class WifiUIMici(BigMultiOptionDialog): + def __init__(self, wifi_manager: WifiManager, back_callback: Callable): + super().__init__([], None, None, right_btn_callback=None) + + # Set up back navigation + self.set_back_callback(back_callback) + + self._network_info_page = NetworkInfoPage(wifi_manager, self._connect_to_network, self._forget_network, self._open_network_manage_page) + self._network_info_page.set_connecting(lambda: self._connecting) + self._should_open_network_info_page = False # wait for scroll_to animation + + self._loading_animation = LoadingAnimation() + + self._wifi_manager = wifi_manager + self._connecting: str | None = None + self._networks: dict[str, Network] = {} + + self._wifi_manager.add_callbacks( + need_auth=self._on_need_auth, + activated=self._on_activated, + forgotten=self._on_forgotten, + networks_updated=self._on_network_updated, + disconnected=self._on_disconnected, + ) + + def show_event(self): + # Call super to prepare scroller; selection scroll is handled dynamically + super().show_event() + self._wifi_manager.set_active(True) + self._scroller.show_event() + + def hide_event(self): + super().hide_event() + self._wifi_manager.set_active(False) + + def _update_state(self): + super()._update_state() + if self._should_open_network_info_page: + self._should_open_network_info_page = False + self._open_network_manage_page() + + def _open_network_manage_page(self, result=None): + self._network_info_page.update_networks(self._networks) + gui_app.set_modal_overlay(self._network_info_page) + + def _forget_network(self, ssid: str): + network = self._networks.get(ssid) + if network is None: + cloudlog.warning(f"Trying to forget unknown network: {ssid}") + return + + self._wifi_manager.forget_connection(network.ssid) + + def _on_network_updated(self, networks: list[Network]): + self._networks = {network.ssid: network for network in networks} + self._update_buttons() + self._network_info_page.update_networks(self._networks) + + def _update_buttons(self): + for network in self._networks.values(): + # pop and re-insert to eliminate stuttering on update (prevents position lost for a frame) + network_button_idx = next((i for i, btn in enumerate(self._scroller._items) if btn.option == network.ssid), None) + if network_button_idx is not None: + network_button = self._scroller._items.pop(network_button_idx) + # Update network on existing button + network_button.set_current_network(network) + else: + network_button = WifiItem(network) + + def show_network_info_page(_network): + self._network_info_page.set_current_network(_network) + self._should_open_network_info_page = True + + network_button.set_click_callback(lambda _net=network,_button=network_button: _button._selected and show_network_info_page(_net)) + + self.add_button(network_button) + + # remove networks no longer present + self._scroller._items[:] = [btn for btn in self._scroller._items if btn.option in self._networks] + + def _connect_with_password(self, ssid: str, password: str): + if password: + self._connecting = ssid + self._wifi_manager.connect_to_network(ssid, password) + self._update_buttons() + + def _connect_to_network(self, ssid: str): + network = self._networks.get(ssid) + if network is None: + cloudlog.warning(f"Trying to connect to unknown network: {ssid}") + return + + if network.is_saved: + self._connecting = network.ssid + self._wifi_manager.activate_connection(network.ssid) + self._update_buttons() + elif network.security_type == SecurityType.OPEN: + self._connecting = network.ssid + self._wifi_manager.connect_to_network(network.ssid, "") + self._update_buttons() + else: + self._on_need_auth(network.ssid, False) + + def _on_need_auth(self, ssid, incorrect_password=True): + hint = "incorrect password..." if incorrect_password else "enter password..." + dlg = BigInputDialog(hint, "", minimum_length=8, + confirm_callback=lambda _password: self._connect_with_password(ssid, _password)) + # go back to the manage network page + gui_app.set_modal_overlay(dlg, self._open_network_manage_page) + + def _on_activated(self): + self._connecting = None + + def _on_forgotten(self): + self._connecting = None + + def _on_disconnected(self): + self._connecting = None + + def _render(self, _): + super()._render(_) + + if not self._networks: + self._loading_animation.render(self._rect) + + +class NetworkLayoutMici(NavWidget): + def __init__(self, back_callback: Callable): + super().__init__() + + self._current_panel = NetworkPanelType.WIFI + self.set_back_enabled(lambda: self._current_panel == NetworkPanelType.NONE) + + self._wifi_manager = WifiManager() + self._wifi_manager.set_active(False) + self._wifi_ui = WifiUIMici(self._wifi_manager, back_callback=lambda: self._switch_to_panel(NetworkPanelType.NONE)) + + self._wifi_manager.add_callbacks( + networks_updated=self._on_network_updated, + ) + + _tethering_icon = "icons_mici/settings/network/tethering.png" + + # ******** Tethering ******** + def tethering_toggle_callback(checked: bool): + self._tethering_toggle_btn.set_enabled(False) + self._network_metered_btn.set_enabled(False) + self._wifi_manager.set_tethering_active(checked) + + self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback) + + def tethering_password_callback(password: str): + if password: + self._wifi_manager.set_tethering_password(password) + + def tethering_password_clicked(): + tethering_password = self._wifi_manager.tethering_password + dlg = BigInputDialog("enter password...", tethering_password, minimum_length=8, + confirm_callback=tethering_password_callback) + gui_app.set_modal_overlay(dlg) + + txt_tethering = gui_app.texture(_tethering_icon, 64, 53) + self._tethering_password_btn = BigButton("tethering password", "", txt_tethering) + self._tethering_password_btn.set_click_callback(tethering_password_clicked) + + # ******** IP Address ******** + self._ip_address_btn = BigButton("IP Address", "Not connected") + + # ******** Network Metered ******** + def network_metered_callback(value: str): + self._network_metered_btn.set_enabled(False) + metered = { + 'default': MeteredType.UNKNOWN, + 'metered': MeteredType.YES, + 'unmetered': MeteredType.NO + }.get(value, MeteredType.UNKNOWN) + self._wifi_manager.set_current_network_metered(metered) + + # TODO: signal for current network metered type when changing networks, this is wrong until you press it once + # TODO: disable when not connected + self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback) + self._network_metered_btn.set_enabled(False) + + wifi_button = BigButton("wi-fi") + wifi_button.set_click_callback(lambda: self._switch_to_panel(NetworkPanelType.WIFI)) + + # Main scroller ---------------------------------- + self._scroller = Scroller([ + wifi_button, + self._network_metered_btn, + self._tethering_toggle_btn, + self._tethering_password_btn, + self._ip_address_btn, + ], snap_items=False) + + # Set up back navigation + self.set_back_callback(back_callback) + + def show_event(self): + super().show_event() + self._current_panel = NetworkPanelType.NONE + self._wifi_ui.show_event() + self._scroller.show_event() + + def hide_event(self): + super().hide_event() + self._wifi_ui.hide_event() + + def _on_network_updated(self, networks: list[Network]): + # Update tethering state + tethering_active = self._wifi_manager.is_tethering_active() + # TODO: use real signals (like activated/settings changed, etc.) to speed up re-enabling buttons + self._tethering_toggle_btn.set_enabled(True) + self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address)) + self._tethering_toggle_btn.set_checked(tethering_active) + + # Update IP address + self._ip_address_btn.set_value(self._wifi_manager.ipv4_address or "Not connected") + + # Update network metered + self._network_metered_btn.set_value( + { + MeteredType.UNKNOWN: 'default', + MeteredType.YES: 'metered', + MeteredType.NO: 'unmetered' + }.get(self._wifi_manager.current_network_metered, 'default')) + + def _switch_to_panel(self, panel_type: NetworkPanelType): + self._current_panel = panel_type + + def _render(self, rect: rl.Rectangle): + self._wifi_manager.process_callbacks() + + if self._current_panel == NetworkPanelType.WIFI: + self._wifi_ui.render(rect) + else: + self._scroller.render(rect) diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py new file mode 100644 index 0000000000..75238d581a --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -0,0 +1,113 @@ +import pyray as rl +from dataclasses import dataclass +from enum import IntEnum +from collections.abc import Callable + +from openpilot.common.params import Params +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici +from openpilot.selfdrive.ui.mici.layouts.settings.network import NetworkLayoutMici +from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton +from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici +from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayoutMici +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.widgets import Widget, NavWidget + + +class PanelType(IntEnum): + TOGGLES = 0 + NETWORK = 1 + DEVICE = 2 + DEVELOPER = 3 + USER_MANUAL = 4 + FIREHOSE = 5 + + +@dataclass +class PanelInfo: + name: str + instance: Widget + + +class SettingsLayout(NavWidget): + def __init__(self): + super().__init__() + self._params = Params() + self._current_panel = None # PanelType.DEVICE + + toggles_btn = BigButton("toggles", "", "icons_mici/settings/toggles_icon.png") + toggles_btn.set_click_callback(lambda: self._set_current_panel(PanelType.TOGGLES)) + network_btn = BigButton("network", "", "icons_mici/settings/network/wifi_strength_full.png") + network_btn.set_click_callback(lambda: self._set_current_panel(PanelType.NETWORK)) + device_btn = BigButton("device", "", "icons_mici/settings/device_icon.png") + device_btn.set_click_callback(lambda: self._set_current_panel(PanelType.DEVICE)) + developer_btn = BigButton("developer", "", "icons_mici/settings/developer_icon.png") + developer_btn.set_click_callback(lambda: self._set_current_panel(PanelType.DEVELOPER)) + + firehose_btn = BigButton("firehose", "", "icons_mici/settings/comma_icon.png") + firehose_btn.set_click_callback(lambda: self._set_current_panel(PanelType.FIREHOSE)) + + self._scroller = Scroller([ + toggles_btn, + network_btn, + device_btn, + PairBigButton(), + #BigDialogButton("manual", "", "icons_mici/settings/manual_icon.png", "Check out the mici user\nmanual at comma.ai/setup"), + firehose_btn, + developer_btn, + ], snap_items=False) + + # Set up back navigation + self.set_back_callback(self.close_settings) + self.set_back_enabled(lambda: self._current_panel is None) + + self._panels = { + PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayoutMici(back_callback=lambda: self._set_current_panel(None))), + PanelType.NETWORK: PanelInfo("Network", NetworkLayoutMici(back_callback=lambda: self._set_current_panel(None))), + PanelType.DEVICE: PanelInfo("Device", DeviceLayoutMici(back_callback=lambda: self._set_current_panel(None))), + PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayoutMici(back_callback=lambda: self._set_current_panel(None))), + PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayoutMici(back_callback=lambda: self._set_current_panel(None))), + } + + self._font_medium = gui_app.font(FontWeight.MEDIUM) + + # Callbacks + self._close_callback: Callable | None = None + + def show_event(self): + super().show_event() + self._set_current_panel(None) + self._scroller.show_event() + if self._current_panel is not None: + self._panels[self._current_panel].instance.show_event() + + def hide_event(self): + super().hide_event() + if self._current_panel is not None: + self._panels[self._current_panel].instance.hide_event() + + def set_callbacks(self, on_close: Callable): + self._close_callback = on_close + + def _render(self, rect: rl.Rectangle): + if self._current_panel is not None: + self._draw_current_panel() + else: + self._scroller.render(rect) + + def _draw_current_panel(self): + panel = self._panels[self._current_panel] + panel.instance.render(self._rect) + + def _set_current_panel(self, panel_type: PanelType | None): + if panel_type != self._current_panel: + if self._current_panel is not None: + self._panels[self._current_panel].instance.hide_event() + self._current_panel = panel_type + if self._current_panel is not None: + self._panels[self._current_panel].instance.show_event() + + def close_settings(self): + if self._close_callback: + self._close_callback() diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py new file mode 100644 index 0000000000..8efb516a42 --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -0,0 +1,95 @@ +import pyray as rl +from collections.abc import Callable +from cereal import log + +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import NavWidget +from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback +from openpilot.selfdrive.ui.ui_state import ui_state + +PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants + + +class TogglesLayoutMici(NavWidget): + def __init__(self, back_callback: Callable): + super().__init__() + self.set_back_callback(back_callback) + + self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"]) + self._experimental_btn = BigParamControl("experimental mode", "ExperimentalMode") + is_metric_toggle = BigParamControl("use metric units", "IsMetric") + ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled") + always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM") + record_front = BigParamControl("record & upload driver camera", "RecordFront", toggle_callback=restart_needed_callback) + record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) + enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) + + self._scroller = Scroller([ + self._personality_toggle, + self._experimental_btn, + is_metric_toggle, + ldw_toggle, + always_on_dm_toggle, + record_front, + record_mic, + enable_openpilot, + ], snap_items=False) + + # Toggle lists + self._refresh_toggles = ( + ("ExperimentalMode", self._experimental_btn), + ("IsMetric", is_metric_toggle), + ("IsLdwEnabled", ldw_toggle), + ("AlwaysOnDM", always_on_dm_toggle), + ("RecordFront", record_front), + ("RecordAudio", record_mic), + ("OpenpilotEnabledToggle", enable_openpilot), + ) + + enable_openpilot.set_enabled(lambda: not ui_state.engaged) + record_front.set_enabled(False if ui_state.params.get_bool("RecordFrontLock") else (lambda: not ui_state.engaged)) + record_mic.set_enabled(lambda: not ui_state.engaged) + + if ui_state.params.get_bool("ShowDebugInfo"): + gui_app.set_show_touches(True) + gui_app.set_show_fps(True) + + ui_state.add_engaged_transition_callback(self._update_toggles) + + def _update_state(self): + super()._update_state() + + 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._personality_toggle.set_value(self._personality_toggle._options[personality]) + ui_state.personality = personality + + def show_event(self): + super().show_event() + self._scroller.show_event() + self._update_toggles() + + def _update_toggles(self): + ui_state.update_params() + + # CP gating for experimental mode + if ui_state.CP is not None: + if ui_state.has_longitudinal_control: + self._experimental_btn.set_enabled(True) + self._personality_toggle.set_enabled(True) + else: + # no long for now + self._experimental_btn.set_enabled(False) + self._experimental_btn.set_checked(False) + self._personality_toggle.set_enabled(False) + ui_state.params.remove("ExperimentalMode") + + # Refresh toggles from params to mirror external changes + for key, item in self._refresh_toggles: + item.set_checked(ui_state.params.get_bool(key)) + + def _render(self, rect: rl.Rectangle): + self._scroller.render(rect) diff --git a/selfdrive/ui/mici/onroad/__init__.py b/selfdrive/ui/mici/onroad/__init__.py new file mode 100644 index 0000000000..bb45117b94 --- /dev/null +++ b/selfdrive/ui/mici/onroad/__init__.py @@ -0,0 +1,12 @@ +import pyray as rl + +SIDE_PANEL_WIDTH = 60 + + +def blend_colors(a: rl.Color, b: rl.Color, f: float) -> rl.Color: + h0, s0, v0 = (hsv0 := rl.color_to_hsv(a)).x, hsv0.y, hsv0.z + h1, s1, v1 = (hsv1 := rl.color_to_hsv(b)).x, hsv1.y, hsv1.z + dh = ((h1 - h0 + 180) % 360) - 180 # shortest hue delta + return rl.color_from_hsv((h0 + f * dh) % 360, + s0 + f * (s1 - s0), + v0 + f * (v1 - v0)) diff --git a/selfdrive/ui/mici/onroad/alert_renderer.py b/selfdrive/ui/mici/onroad/alert_renderer.py new file mode 100644 index 0000000000..eb5555660a --- /dev/null +++ b/selfdrive/ui/mici/onroad/alert_renderer.py @@ -0,0 +1,361 @@ +import time +from enum import StrEnum +from typing import NamedTuple +import pyray as rl +import random +import string +from dataclasses import dataclass +from cereal import messaging, log, car +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter +from openpilot.system.hardware import TICI +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel + +AlertSize = log.SelfdriveState.AlertSize +AlertStatus = log.SelfdriveState.AlertStatus + +ALERT_MARGIN = 18 + +ALERT_FONT_SMALL = 66 - 50 +ALERT_FONT_BIG = 88 - 40 + +SELFDRIVE_STATE_TIMEOUT = 5 # Seconds +SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds + +# Constants +ALERT_COLORS = { + AlertStatus.normal: rl.Color(0, 0, 0, 255), + AlertStatus.userPrompt: rl.Color(255, 115, 0, 255), + AlertStatus.critical: rl.Color(255, 0, 21, 255), +} + +TURN_SIGNAL_BLINK_PERIOD = 1 / (80 / 60) # Mazda heartbeat turn signal BPM + +DEBUG = False + + +class IconSide(StrEnum): + left = 'left' + right = 'right' + + +class IconLayout(NamedTuple): + texture: rl.Texture + side: IconSide + margin_x: int + margin_y: int + + +class AlertLayout(NamedTuple): + text_rect: rl.Rectangle + icon: IconLayout | None + + +@dataclass +class Alert: + text1: str = "" + text2: str = "" + size: int = 0 + status: int = 0 + visual_alert: int = car.CarControl.HUDControl.VisualAlert.none + alert_type: str = "" + + +# Pre-defined alert instances +ALERT_STARTUP_PENDING = Alert( + text1="openpilot Unavailable", + text2="Waiting to start", + size=AlertSize.mid, + status=AlertStatus.normal, +) + +ALERT_CRITICAL_TIMEOUT = Alert( + text1="TAKE CONTROL IMMEDIATELY", + text2="System Unresponsive", + size=AlertSize.full, + status=AlertStatus.critical, +) + +ALERT_CRITICAL_REBOOT = Alert( + text1="System Unresponsive", + text2="Reboot Device", + size=AlertSize.full, + status=AlertStatus.critical, +) + + +class AlertRenderer(Widget): + def __init__(self): + super().__init__() + self.font_regular: rl.Font = gui_app.font(FontWeight.MEDIUM) + self.font_roman: rl.Font = gui_app.font(FontWeight.ROMAN) + self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD) + self.font_display: rl.Font = gui_app.font(FontWeight.DISPLAY) + + self._alert_text1_label = UnifiedLabel(text="", font_size=ALERT_FONT_BIG, font_weight=FontWeight.DISPLAY, line_height=0.86, + letter_spacing=-0.02) + self._alert_text2_label = UnifiedLabel(text="", font_size=ALERT_FONT_SMALL, font_weight=FontWeight.ROMAN, line_height=0.86, + letter_spacing=0.025) + + self._prev_alert: Alert | None = None + self._text_gen_time = 0 + self._alert_text2_gen = '' + + # animation filters + # TODO: use 0.1 but with proper alert height calculation + self._alert_y_filter = BounceFilter(0, 0.1, 1 / gui_app.target_fps) + self._alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) + + self._turn_signal_timer = 0.0 + self._turn_signal_alpha_filter = FirstOrderFilter(0.0, 0.3, 1 / gui_app.target_fps) + self._last_icon_side: IconSide | None = None + + self._load_icons() + + def _load_icons(self): + self._txt_turn_signal_left = gui_app.texture('icons_mici/onroad/turn_signal_left.png', 100, 91) + self._txt_turn_signal_right = gui_app.texture('icons_mici/onroad/turn_signal_right.png', 100, 91) + self._txt_blind_spot_left = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 108, 128) + self._txt_blind_spot_right = gui_app.texture('icons_mici/onroad/blind_spot_right.png', 108, 128) + + 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 + 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 + waiting_for_startup = recv_frame < ui_state.started_frame + if waiting_for_startup and time_since_onroad > 5: + return ALERT_STARTUP_PENDING + + # 2. Lost communication with selfdriveState after receiving it + if TICI and not waiting_for_startup: + ss_missing = time.monotonic() - sm.recv_time['selfdriveState'] + if ss_missing > SELFDRIVE_STATE_TIMEOUT: + if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT: + return ALERT_CRITICAL_TIMEOUT + return ALERT_CRITICAL_REBOOT + + # No alert if size is none + if ss.alertSize == 0: + return None + + # Return current alert + ret = Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw, + visual_alert=ss.alertHudVisual, alert_type=ss.alertType) + self._prev_alert = ret + return ret + + def will_render(self) -> tuple[Alert | None, bool]: + alert = self.get_alert(ui_state.sm) + return alert or self._prev_alert, alert is None + + def _icon_helper(self, alert: Alert) -> AlertLayout: + icon_side = None + txt_icon = None + icon_margin_x = 20 + icon_margin_y = 18 + + # alert_type format is "EventName/eventType" (e.g., "preLaneChangeLeft/warning") + event_name = alert.alert_type.split('/')[0] if alert.alert_type else '' + + if event_name == 'preLaneChangeLeft': + icon_side = IconSide.left + txt_icon = self._txt_turn_signal_left + icon_margin_x = 2 + icon_margin_y = 5 + + elif event_name == 'preLaneChangeRight': + icon_side = IconSide.right + txt_icon = self._txt_turn_signal_right + icon_margin_x = 2 + icon_margin_y = 5 + + elif event_name == 'laneChange': + icon_side = self._last_icon_side + txt_icon = self._txt_turn_signal_left if self._last_icon_side == 'left' else self._txt_turn_signal_right + icon_margin_x = 2 + icon_margin_y = 5 + + elif event_name == 'laneChangeBlocked': + CS = ui_state.sm['carState'] + if CS.leftBlinker: + icon_side = IconSide.left + elif CS.rightBlinker: + icon_side = IconSide.right + else: + icon_side = self._last_icon_side + txt_icon = self._txt_blind_spot_left if icon_side == 'left' else self._txt_blind_spot_right + icon_margin_x = 8 + icon_margin_y = 0 + + else: + self._turn_signal_timer = 0.0 + + self._last_icon_side = icon_side + + # create text rect based on icon presence + text_x = self._rect.x + ALERT_MARGIN + text_width = self._rect.width - ALERT_MARGIN + if icon_side == 'left': + text_x = self._rect.x + self._txt_turn_signal_right.width + 20 * 2 + text_width = self._rect.width - ALERT_MARGIN - self._txt_turn_signal_right.width - 20 * 2 + elif icon_side == 'right': + text_x = self._rect.x + ALERT_MARGIN + text_width = self._rect.width - ALERT_MARGIN - self._txt_turn_signal_right.width - 20 * 2 + + text_rect = rl.Rectangle( + text_x, + self._alert_y_filter.x, + text_width, + self._rect.height, + ) + icon_layout = IconLayout(txt_icon, icon_side, icon_margin_x, icon_margin_y) if txt_icon is not None and icon_side is not None else None + return AlertLayout(text_rect, icon_layout) + + def _render(self, rect: rl.Rectangle) -> bool: + alert = self.get_alert(ui_state.sm) + + # Animate fade and slide in/out + self._alert_y_filter.update(self._rect.y - 50 if alert is None else self._rect.y) + self._alpha_filter.update(0 if alert is None else 1) + + if alert is None: + # If still animating out, keep the previous alert + if self._alpha_filter.x > 0.01 and self._prev_alert is not None: + alert = self._prev_alert + else: + self._prev_alert = None + return False + + self._draw_background(alert) + + alert_layout = self._icon_helper(alert) + self._draw_text(alert, alert_layout) + self._draw_icons(alert_layout) + + return True + + def _draw_icons(self, alert_layout: AlertLayout) -> None: + if alert_layout.icon is None: + return + + if time.monotonic() - self._turn_signal_timer > TURN_SIGNAL_BLINK_PERIOD: + self._turn_signal_timer = time.monotonic() + self._turn_signal_alpha_filter.x = 255 * 2 + else: + self._turn_signal_alpha_filter.update(255 * 0.2) + + if alert_layout.icon.side == 'left': + pos_x = int(self._rect.x + alert_layout.icon.margin_x) + else: + pos_x = int(self._rect.x + self._rect.width - alert_layout.icon.margin_x - alert_layout.icon.texture.width) + + if alert_layout.icon.texture not in (self._txt_turn_signal_left, self._txt_turn_signal_right): + icon_alpha = 255 + else: + icon_alpha = int(min(self._turn_signal_alpha_filter.x, 255)) + + rl.draw_texture(alert_layout.icon.texture, pos_x, int(self._rect.y + alert_layout.icon.margin_y), + rl.Color(255, 255, 255, int(icon_alpha * self._alpha_filter.x))) + + def _draw_background(self, alert: Alert) -> None: + # draw top gradient for alert text at top + color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal]) + color = rl.Color(color.r, color.g, color.b, int(255 * 0.90 * self._alpha_filter.x)) + translucent_color = rl.Color(color.r, color.g, color.b, int(0 * self._alpha_filter.x)) + + small_alert_height = round(self._rect.height * 0.583) # 140px at mici height + medium_alert_height = round(self._rect.height * 0.833) # 200px at mici height + + # alert_type format is "EventName/eventType" (e.g., "preLaneChangeLeft/warning") + event_name = alert.alert_type.split('/')[0] if alert.alert_type else '' + + if event_name == 'preLaneChangeLeft': + bg_height = small_alert_height + elif event_name == 'preLaneChangeRight': + bg_height = small_alert_height + elif event_name == 'laneChange': + bg_height = small_alert_height + elif event_name == 'laneChangeBlocked': + bg_height = medium_alert_height + else: + bg_height = int(self._rect.height) + + solid_height = round(bg_height * 0.2) + rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.width), solid_height, color) + rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + solid_height), int(self._rect.width), + int(bg_height - solid_height), + color, translucent_color) + + def _draw_text(self, alert: Alert, alert_layout: AlertLayout) -> None: + icon_side = alert_layout.icon.side if alert_layout.icon is not None else None + + # TODO: hack + alert_text1 = alert.text1.lower().replace('calibrating: ', 'calibrating:\n') + can_draw_second_line = False + # TODO: there should be a common way to determine font size based on text length to maximize rect + if len(alert_text1) <= 12: + can_draw_second_line = True + font_size = 92 - 10 + elif len(alert_text1) <= 16: + can_draw_second_line = True + font_size = 70 + else: + font_size = 64 - 10 + + if icon_side is not None: + font_size -= 10 + + color = rl.Color(255, 255, 255, int(255 * 0.9 * self._alpha_filter.x)) + + text1_y_offset = 11 if font_size >= 70 else 4 + text_rect1 = rl.Rectangle( + alert_layout.text_rect.x, + alert_layout.text_rect.y - text1_y_offset, + alert_layout.text_rect.width, + alert_layout.text_rect.height, + ) + self._alert_text1_label.set_text(alert_text1) + self._alert_text1_label.set_text_color(color) + self._alert_text1_label.set_font_size(font_size) + self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) + self._alert_text1_label.render(text_rect1) + + alert_text2 = alert.text2.lower() + + # randomize chars and length for testing + if DEBUG: + if time.monotonic() - self._text_gen_time > 0.5: + self._alert_text2_gen = ''.join(random.choices(string.ascii_lowercase + ' ', k=random.randint(0, 40))) + self._text_gen_time = time.monotonic() + alert_text2 = self._alert_text2_gen or alert_text2 + + if can_draw_second_line and alert_text2: + last_line_h = self._alert_text1_label.rect.y + self._alert_text1_label.get_content_height(int(alert_layout.text_rect.width)) + last_line_h -= 4 + if len(alert_text2) > 18: + small_font_size = 36 + elif len(alert_text2) > 24: + small_font_size = 32 + else: + small_font_size = 40 + text_rect2 = rl.Rectangle( + alert_layout.text_rect.x, + last_line_h, + alert_layout.text_rect.width, + alert_layout.text_rect.height - last_line_h + ) + color = rl.Color(255, 255, 255, int(255 * 0.65 * self._alpha_filter.x)) + + self._alert_text2_label.set_text(alert_text2) + self._alert_text2_label.set_text_color(color) + self._alert_text2_label.set_font_size(small_font_size) + self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) + self._alert_text2_label.render(text_rect2) diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py new file mode 100644 index 0000000000..ab55f392f7 --- /dev/null +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -0,0 +1,358 @@ +import numpy as np +import pyray as rl +from cereal import car, log +from msgq.visionipc import VisionStreamType +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus +from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH +from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer +from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer +from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer +from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer +from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall +from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView +from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.widgets import Widget +from openpilot.common.filter_simple import BounceFilter +from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame +from openpilot.common.transformations.orientation import rot_from_euler +from enum import IntEnum + +OpState = log.SelfdriveState.OpenpilotState +CALIBRATED = log.LiveCalibrationData.Status.calibrated +ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD +WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD +DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"] + + +class BookmarkState(IntEnum): + HIDDEN = 0 + DRAGGING = 1 + TRIGGERED = 2 + +WIDE_CAM_MAX_SPEED = 5.0 # m/s (10 mph) +ROAD_CAM_MIN_SPEED = 10 # m/s (25 mph) + +CAM_Y_OFFSET = 20 + + +class BookmarkIcon(Widget): + PEEK_THRESHOLD = 50 # If icon peeks out this much, snap it fully visible + FULL_VISIBLE_OFFSET = 200 # How far onscreen when fully visible + HIDDEN_OFFSET = -50 # How far offscreen when hidden + + def __init__(self, bookmark_callback): + super().__init__() + self._bookmark_callback = bookmark_callback + self._icon = gui_app.texture("icons_mici/onroad/bookmark.png", 180, 180) + self._offset_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps) + + # State + self._interacting = False + self._state = BookmarkState.HIDDEN + self._swipe_start_x = 0.0 + self._swipe_current_x = 0.0 + self._is_swiping = False + self._is_swiping_left: bool = False + self._triggered_time: float = 0.0 + + def is_swiping_left(self) -> bool: + """Check if currently swiping left (for scroller to disable).""" + return self._is_swiping_left + + def interacting(self): + interacting, self._interacting = self._interacting, False + return interacting + + def _update_state(self): + if self._state == BookmarkState.DRAGGING: + # Allow pulling past activated position with rubber band effect + swipe_offset = self._swipe_start_x - self._swipe_current_x + swipe_offset = min(swipe_offset, self.FULL_VISIBLE_OFFSET + 50) + self._offset_filter.update(swipe_offset) + + elif self._state == BookmarkState.TRIGGERED: + # Continue animating to fully visible + self._offset_filter.update(self.FULL_VISIBLE_OFFSET) + # Stay in TRIGGERED state for 1 second + if rl.get_time() - self._triggered_time >= 1.5: + self._state = BookmarkState.HIDDEN + + elif self._state == BookmarkState.HIDDEN: + self._offset_filter.update(self.HIDDEN_OFFSET) + + if self._offset_filter.x < 1e-3: + self._interacting = False + + def _handle_mouse_event(self, mouse_event: MouseEvent): + if not ui_state.started: + return + + if mouse_event.left_pressed: + # Store relative position within widget + self._swipe_start_x = mouse_event.pos.x + self._swipe_current_x = mouse_event.pos.x + self._is_swiping = True + self._is_swiping_left = False + self._state = BookmarkState.DRAGGING + + elif mouse_event.left_down and self._is_swiping: + self._swipe_current_x = mouse_event.pos.x + swipe_offset = self._swipe_start_x - self._swipe_current_x + self._is_swiping_left = swipe_offset > 0 + if self._is_swiping_left: + self._interacting = True + + elif mouse_event.left_released: + if self._is_swiping: + swipe_distance = self._swipe_start_x - self._swipe_current_x + + # If peeking past threshold, transition to animating to fully visible and bookmark + if swipe_distance > self.PEEK_THRESHOLD: + self._state = BookmarkState.TRIGGERED + self._triggered_time = rl.get_time() + self._bookmark_callback() + else: + # Otherwise, transition back to hidden + self._state = BookmarkState.HIDDEN + + # Reset swipe state + self._is_swiping = False + self._is_swiping_left = False + + def _render(self, _): + """Render the bookmark icon.""" + if self._offset_filter.x > 0: + icon_x = self.rect.x + self.rect.width - round(self._offset_filter.x) + icon_y = self.rect.y + (self.rect.height - self._icon.height) / 2 # Vertically centered + rl.draw_texture(self._icon, int(icon_x), int(icon_y), rl.WHITE) + + +class AugmentedRoadView(CameraView): + def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD): + super().__init__("camerad", stream_type) + self._bookmark_callback = bookmark_callback + self._set_placeholder_color(rl.BLACK) + + self.device_camera: DeviceCameraConfig | None = None + self.view_from_calib = view_frame_from_device_frame.copy() + self.view_from_wide_calib = view_frame_from_device_frame.copy() + + self._last_calib_time: float = 0 + self._last_rect_dims = (0.0, 0.0) + self._last_stream_type = stream_type + self._cached_matrix: np.ndarray | None = None + self._content_rect = rl.Rectangle() + self._last_click_time = 0.0 + + # Bookmark icon with swipe gesture + self._bookmark_icon = BookmarkIcon(bookmark_callback) + + self._model_renderer = ModelRenderer() + self._hud_renderer = HudRenderer() + self._alert_renderer = AlertRenderer() + self._driver_state_renderer = DriverStateRenderer() + self._confidence_ball = ConfidenceBall() + self._offroad_label = UnifiedLabel("start the car to\nuse openpilot", 54, FontWeight.DISPLAY, + text_color=rl.Color(255, 255, 255, int(255 * 0.9)), + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png") + + def is_swiping_left(self) -> bool: + """Check if currently swiping left (for scroller to disable).""" + return self._bookmark_icon.is_swiping_left() + + def _update_state(self): + super()._update_state() + + # update offroad label + if ui_state.panda_type == log.PandaState.PandaType.unknown: + self._offroad_label.set_text("system booting") + else: + self._offroad_label.set_text("start the car to\nuse openpilot") + + def _handle_mouse_release(self, mouse_pos: MousePos): + # Don't trigger click callback if bookmark was triggered + if not self._bookmark_icon.interacting(): + super()._handle_mouse_release(mouse_pos) + + def _render(self, _): + self._switch_stream_if_needed(ui_state.sm) + + # Update calibration before rendering + self._update_calibration() + + # Create inner content area with border padding + self._content_rect = rl.Rectangle( + self.rect.x, + self.rect.y, + self.rect.width - SIDE_PANEL_WIDTH, + self.rect.height, + ) + + # 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( + int(self._content_rect.x), + int(self._content_rect.y), + int(self._content_rect.width), + int(self._content_rect.height) + ) + + # Render the base camera view + super()._render(self._content_rect) + + # Draw all UI overlays + self._model_renderer.render(self._content_rect) + + # Fade out bottom of overlays for looks + rl.draw_texture_ex(self._fade_texture, rl.Vector2(self._content_rect.x, self._content_rect.y), 0.0, 1.0, rl.WHITE) + + alert_to_render, not_animating_out = self._alert_renderer.will_render() + + # Hide DMoji when disengaged unless AlwaysOnDM is enabled + should_draw_dmoji = (not self._hud_renderer.drawing_top_icons() and ui_state.is_onroad() and + (ui_state.status != UIStatus.DISENGAGED or ui_state.always_on_dm)) + self._driver_state_renderer.set_should_draw(should_draw_dmoji) + self._driver_state_renderer.set_position(self._rect.x + 16, self._rect.y + 10) + self._driver_state_renderer.render() + + self._hud_renderer.set_can_draw_top_icons(alert_to_render is None) + self._hud_renderer.set_wheel_critical_icon(alert_to_render is not None and not not_animating_out and + alert_to_render.visual_alert == car.CarControl.HUDControl.VisualAlert.steerRequired) + # TODO: have alert renderer draw offroad mici label below + if ui_state.started: + self._alert_renderer.render(self._content_rect) + self._hud_renderer.render(self._content_rect) + + # Draw fake rounded border + rl.draw_rectangle_rounded_lines_ex(self._content_rect, 0.2 * 1.02, 10, 50, rl.BLACK) + + # End clipping region + rl.end_scissor_mode() + + # Custom UI extension point - add custom overlays here + # Use self._content_rect for positioning within camera bounds + self._confidence_ball.render(self.rect) + + self._bookmark_icon.render(self.rect) + + # Draw darkened background and text if not onroad + if not ui_state.started: + rl.draw_rectangle(int(self.rect.x), int(self.rect.y), int(self.rect.width), int(self.rect.height), rl.Color(0, 0, 0, 175)) + self._offroad_label.render(self._content_rect) + + def _switch_stream_if_needed(self, sm): + if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams: + v_ego = sm['carState'].vEgo + if v_ego < WIDE_CAM_MAX_SPEED: + target = WIDE_CAM + elif v_ego > ROAD_CAM_MIN_SPEED: + target = ROAD_CAM + else: + # Hysteresis zone - keep current stream + target = self.stream_type + else: + target = ROAD_CAM + + if self.stream_type != target: + self.switch_stream(target) + + def _update_calibration(self): + # Update device camera if not already set + sm = ui_state.sm + if not self.device_camera and sm.seen['roadCameraState'] and sm.seen['deviceState']: + self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] + + # Check if live calibration data is available and valid + if not (sm.updated["liveCalibration"] and sm.valid['liveCalibration']): + return + + calib = sm['liveCalibration'] + if len(calib.rpyCalib) != 3 or calib.calStatus != CALIBRATED: + return + + # Update view_from_calib matrix + device_from_calib = rot_from_euler(calib.rpyCalib) + self.view_from_calib = view_frame_from_device_frame @ device_from_calib + + # Update wide calibration if available + if hasattr(calib, 'wideFromDeviceEuler') and len(calib.wideFromDeviceEuler) == 3: + wide_from_device = rot_from_euler(calib.wideFromDeviceEuler) + self.view_from_wide_calib = view_frame_from_device_frame @ wide_from_device @ device_from_calib + + def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray: + # Get camera configuration + # TODO: cache with vEgo? + calib_time = ui_state.sm.recv_frame['liveCalibration'] + current_dims = (self._content_rect.width, self._content_rect.height) + device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA + is_wide_camera = self.stream_type == WIDE_CAM + intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics + calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib + if is_wide_camera: + zoom = 0.7 * 1.5 + else: + zoom = np.interp(ui_state.sm['carState'].vEgo, [10, 30], [0.8, 1.0]) + + # Calculate transforms for vanishing point + inf_point = np.array([1000.0, 0.0, 0.0]) + calib_transform = intrinsic @ calibration + kep = calib_transform @ inf_point + + # Calculate center points and dimensions + x, y = self._content_rect.x, self._content_rect.y + w, h = self._content_rect.width, self._content_rect.height + cx, cy = intrinsic[0, 2], intrinsic[1, 2] + + # Calculate max allowed offsets with margins + margin = 5 + max_x_offset = cx * zoom - w / 2 - margin + max_y_offset = cy * zoom - h / 2 - margin + + # Calculate and clamp offsets to prevent out-of-bounds issues + try: + if abs(kep[2]) > 1e-6: + x_offset = np.clip((kep[0] / kep[2] - cx) * zoom, -max_x_offset, max_x_offset) + y_offset = np.clip((kep[1] / kep[2] - cy) * zoom + CAM_Y_OFFSET, -max_y_offset, max_y_offset) + else: + x_offset, y_offset = 0, 0 + except (ZeroDivisionError, OverflowError): + x_offset, y_offset = 0, 0 + + # Cache the computed transformation matrix to avoid recalculations + self._last_calib_time = calib_time + self._last_rect_dims = current_dims + self._last_stream_type = self.stream_type + self._cached_matrix = np.array([ + [zoom * 2 * cx / w, 0, -x_offset / w * 2], + [0, zoom * 2 * cy / h, -y_offset / h * 2], + [0, 0, 1.0] + ]) + + video_transform = np.array([ + [zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)], + [0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)], + [0.0, 0.0, 1.0] + ]) + self._model_renderer.set_transform(video_transform @ calib_transform) + + return self._cached_matrix + + +if __name__ == "__main__": + gui_app.init_window("OnRoad Camera View") + road_camera_view = AugmentedRoadView(ROAD_CAM) + print("***press space to switch camera view***") + try: + for _ in gui_app.render(): + ui_state.update() + if rl.is_key_released(rl.KeyboardKey.KEY_SPACE): + if WIDE_CAM in road_camera_view.available_streams: + stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM + road_camera_view.switch_stream(stream) + road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + finally: + road_camera_view.close() diff --git a/selfdrive/ui/mici/onroad/cameraview.py b/selfdrive/ui/mici/onroad/cameraview.py new file mode 100644 index 0000000000..f962210afb --- /dev/null +++ b/selfdrive/ui/mici/onroad/cameraview.py @@ -0,0 +1,390 @@ +import platform +import numpy as np +import pyray as rl + +from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf +from openpilot.common.swaglog import cloudlog +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, UIStatus + +CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts + +VERSION = """ +#version 300 es +precision mediump float; +""" +if platform.system() == "Darwin": + VERSION = """ + #version 330 core + """ + + +VERTEX_SHADER = VERSION + """ +in vec3 vertexPosition; +in vec2 vertexTexCoord; +in vec3 vertexNormal; +in vec4 vertexColor; +uniform mat4 mvp; +out vec2 fragTexCoord; +out vec4 fragColor; +void main() { + fragTexCoord = vertexTexCoord; + fragColor = vertexColor; + gl_Position = mvp * vec4(vertexPosition, 1.0); +} +""" + +# Choose fragment shader based on platform capabilities +if TICI: + FRAME_FRAGMENT_SHADER = """ + #version 300 es + #extension GL_OES_EGL_image_external_essl3 : enable + precision mediump float; + in vec2 fragTexCoord; + uniform samplerExternalOES texture0; + out vec4 fragColor; + uniform int engaged; + + void main() { + vec4 color = texture(texture0, fragTexCoord); + if (engaged == 1) { + float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); // Luma + color.rgb = mix(vec3(gray), color.rgb, 0.2); // 20% saturation + color.rgb = clamp((color.rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast + color.rgb = pow(color.rgb, vec3(1.0/1.28)); + fragColor = vec4(color.rgb, color.a); + } else { + fragColor = vec4(color.rgb * 0.85, color.a); // 85% opacity + } + } + """ +else: + FRAME_FRAGMENT_SHADER = VERSION + """ + in vec2 fragTexCoord; + uniform sampler2D texture0; + uniform sampler2D texture1; + out vec4 fragColor; + uniform int engaged; + + void main() { + float y = texture(texture0, fragTexCoord).r; + vec2 uv = texture(texture1, fragTexCoord).ra - 0.5; + vec3 rgb = vec3(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x); + if (engaged == 1) { + float gray = dot(rgb, vec3(0.299, 0.587, 0.114)); + rgb = mix(vec3(gray), rgb, 0.2); // 20% saturation + rgb = clamp((rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast + fragColor = vec4(rgb, 1.0); + } else { + fragColor = vec4(rgb * 0.85, 1.0); // 85% opacity + } + } + """ + + +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) + self._stream_type = stream_type + self.available_streams: list[VisionStreamType] = [] + + # Target stream for switching + self._target_client: VisionIpcClient | None = None + self._target_stream_type: VisionStreamType | None = None + self._switching: bool = False + + self._texture_needs_update = True + self.last_connection_attempt: float = 0.0 + self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER) + self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1 + self._engaged_loc = rl.get_shader_location(self.shader, "engaged") + self._engaged_val = rl.ffi.new("int[1]", [1]) + + self.frame: VisionBuf | None = None + self.texture_y: rl.Texture | None = None + self.texture_uv: rl.Texture | None = None + + # EGL resources + self.egl_images: dict[int, EGLImage] = {} + self.egl_texture: rl.Texture | None = None + + self._placeholder_color: rl.Color | None = None + + # Initialize EGL for zero-copy rendering on TICI + if TICI: + if not init_egl(): + raise RuntimeError("Failed to initialize EGL") + + # Create a 1x1 pixel placeholder texture for EGL image binding + temp_image = rl.gen_image_color(1, 1, rl.BLACK) + 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 + self.available_streams.clear() + 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 + + def switch_stream(self, stream_type: VisionStreamType) -> None: + if self._stream_type == stream_type: + return + + if self._switching and self._target_stream_type == stream_type: + return + + cloudlog.debug(f'Preparing switch from {self._stream_type} to {stream_type}') + + if self._target_client: + del self._target_client + + self._target_stream_type = stream_type + self._target_client = VisionIpcClient(self._name, stream_type, conflate=True) + self._switching = True + + @property + def stream_type(self) -> VisionStreamType: + return self._stream_type + + def close(self) -> None: + self._clear_textures() + + # Clean up EGL texture + if TICI and self.egl_texture: + rl.unload_texture(self.egl_texture) + self.egl_texture = None + + # Clean up shader + if self.shader and self.shader.id: + rl.unload_shader(self.shader) + + self.client = None + + def __del__(self): + self.close() + + def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray: + if not self.frame: + return np.eye(3) + + # Calculate aspect ratios + widget_aspect_ratio = rect.width / rect.height + frame_aspect_ratio = self.frame.width / self.frame.height + + # Calculate scaling factors to maintain aspect ratio + zx = min(frame_aspect_ratio / widget_aspect_ratio, 1.0) + zy = min(widget_aspect_ratio / frame_aspect_ratio, 1.0) + + return np.array([ + [zx, 0.0, 0.0], + [0.0, zy, 0.0], + [0.0, 0.0, 1.0] + ]) + + def _render(self, rect: rl.Rectangle): + if self._switching: + self._handle_switch() + + if not self._ensure_connection(): + self._draw_placeholder(rect) + return + + # Try to get a new buffer without blocking + buffer = self.client.recv(timeout_ms=0) + if buffer: + self._texture_needs_update = True + self.frame = buffer + + if not self.frame: + self._draw_placeholder(rect) + return + + transform = self._calc_frame_matrix(rect) + src_rect = rl.Rectangle(0, 0, float(self.frame.width), float(self.frame.height)) + # Flip driver camera horizontally + if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER: + src_rect.width = -src_rect.width + + # Calculate scale + scale_x = rect.width * transform[0, 0] # zx + scale_y = rect.height * transform[1, 1] # zy + + # Calculate base position (centered) + x_offset = rect.x + (rect.width - scale_x) / 2 + y_offset = rect.y + (rect.height - scale_y) / 2 + + x_offset += transform[0, 2] * rect.width / 2 + y_offset += transform[1, 2] * rect.height / 2 + + dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y) + + # Render with appropriate method + if TICI: + self._render_egl(src_rect, dst_rect) + else: + self._render_textures(src_rect, dst_rect) + + def _draw_placeholder(self, rect: rl.Rectangle): + if self._placeholder_color: + rl.draw_rectangle_rec(rect, self._placeholder_color) + + def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None: + """Render using EGL for direct buffer access""" + if self.frame is None or self.egl_texture is None: + return + + idx = self.frame.idx + egl_image = self.egl_images.get(idx) + + # Create EGL image if needed + if egl_image is None: + egl_image = create_egl_image(self.frame.width, self.frame.height, self.frame.stride, self.frame.fd, self.frame.uv_offset) + if egl_image: + self.egl_images[idx] = egl_image + else: + return + + # Update texture dimensions to match current frame + self.egl_texture.width = self.frame.width + self.egl_texture.height = self.frame.height + + # Bind the EGL image to our texture + bind_egl_image_to_texture(self.egl_texture.id, egl_image) + + # Render with shader + rl.begin_shader_mode(self.shader) + self._update_texture_color_filtering() + rl.draw_texture_pro(self.egl_texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) + rl.end_shader_mode() + + def _render_textures(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None: + """Render using texture copies""" + if not self.texture_y or not self.texture_uv or self.frame is None: + return + + # Update textures with new frame data + if self._texture_needs_update: + y_data = self.frame.data[: self.frame.uv_offset] + uv_data = self.frame.data[self.frame.uv_offset:] + + rl.update_texture(self.texture_y, rl.ffi.cast("void *", y_data.ctypes.data)) + rl.update_texture(self.texture_uv, rl.ffi.cast("void *", uv_data.ctypes.data)) + self._texture_needs_update = False + + # Render with shader + rl.begin_shader_mode(self.shader) + self._update_texture_color_filtering() + rl.set_shader_value_texture(self.shader, self._texture1_loc, self.texture_uv) + rl.draw_texture_pro(self.texture_y, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) + rl.end_shader_mode() + + def _update_texture_color_filtering(self): + self._engaged_val[0] = 1 if ui_state.status != UIStatus.DISENGAGED else 0 + rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT) + + def _ensure_connection(self) -> bool: + if not self.client.is_connected(): + self.frame = None + self.available_streams.clear() + + # Throttle connection attempts + current_time = rl.get_time() + if current_time - self.last_connection_attempt < CONNECTION_RETRY_INTERVAL: + return False + self.last_connection_attempt = current_time + + if not self.client.connect(False) or not self.client.num_buffers: + return False + + cloudlog.debug(f"Connected to {self._name} stream: {self._stream_type}, buffers: {self.client.num_buffers}") + self._initialize_textures() + self.available_streams = self.client.available_streams(self._name, block=False) + + return True + + def _handle_switch(self) -> None: + """Check if target stream is ready and switch immediately.""" + if not self._target_client or not self._switching: + return + + # Try to connect target if needed + if not self._target_client.is_connected(): + if not self._target_client.connect(False) or not self._target_client.num_buffers: + return + + cloudlog.debug(f"Target stream connected: {self._target_stream_type}") + + # Check if target has frames ready + target_frame = self._target_client.recv(timeout_ms=0) + if target_frame: + self.frame = target_frame # Update current frame to target frame + self._complete_switch() + + def _complete_switch(self) -> None: + """Instantly switch to target stream.""" + cloudlog.debug(f"Switching to {self._target_stream_type}") + # Clean up current resources + if self.client: + del self.client + + # Switch to target + self.client = self._target_client + self._stream_type = self._target_stream_type + self._texture_needs_update = True + + # Reset state + self._target_client = None + self._target_stream_type = None + self._switching = False + + # Initialize textures for new stream + self._initialize_textures() + + def _initialize_textures(self): + self._clear_textures() + if not TICI: + self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), + int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) + self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), + int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) + + def _clear_textures(self): + if self.texture_y and self.texture_y.id: + rl.unload_texture(self.texture_y) + self.texture_y = None + + if self.texture_uv and self.texture_uv.id: + rl.unload_texture(self.texture_uv) + self.texture_uv = None + + # Clean up EGL resources + if TICI: + for data in self.egl_images.values(): + destroy_egl_image(data) + self.egl_images = {} + + +if __name__ == "__main__": + gui_app.init_window("camera view") + road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD) + for _ in gui_app.render(): + road.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) diff --git a/selfdrive/ui/mici/onroad/confidence_ball.py b/selfdrive/ui/mici/onroad/confidence_ball.py new file mode 100644 index 0000000000..de792282c5 --- /dev/null +++ b/selfdrive/ui/mici/onroad/confidence_ball.py @@ -0,0 +1,78 @@ +import math +import pyray as rl +from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.lib.application import gui_app +from openpilot.common.filter_simple import FirstOrderFilter + + +def draw_circle_gradient(center_x: float, center_y: float, radius: int, + top: rl.Color, bottom: rl.Color) -> None: + # Draw a square with the gradient + rl.draw_rectangle_gradient_v(int(center_x - radius), int(center_y - radius), + radius * 2, radius * 2, + top, bottom) + + # Paint over square with a ring + outer_radius = math.ceil(radius * math.sqrt(2)) + 1 + rl.draw_ring(rl.Vector2(center_x, center_y), radius, outer_radius, + 0.0, 360.0, + 20, rl.BLACK) + + +class ConfidenceBall(Widget): + def __init__(self, demo: bool = False): + super().__init__() + self._demo = demo + self._confidence_filter = FirstOrderFilter(-0.5, 0.5, 1 / gui_app.target_fps) + + def update_filter(self, value: float): + self._confidence_filter.update(value) + + def _update_state(self): + if self._demo: + return + + # animate status dot in from bottom + if ui_state.status == UIStatus.DISENGAGED: + self._confidence_filter.update(-0.5) + else: + self._confidence_filter.update((1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs or [1])) * + (1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs or [1]))) + + def _render(self, _): + content_rect = rl.Rectangle( + self.rect.x + self.rect.width - SIDE_PANEL_WIDTH, + self.rect.y, + SIDE_PANEL_WIDTH, + self.rect.height, + ) + + status_dot_radius = 24 + dot_height = (1 - self._confidence_filter.x) * (content_rect.height - 2 * status_dot_radius) + status_dot_radius + dot_height = self._rect.y + dot_height + + # confidence zones + if ui_state.status == UIStatus.ENGAGED or self._demo: + if self._confidence_filter.x > 0.5: + top_dot_color = rl.Color(0, 255, 204, 255) + bottom_dot_color = rl.Color(0, 255, 38, 255) + elif self._confidence_filter.x > 0.2: + top_dot_color = rl.Color(255, 200, 0, 255) + bottom_dot_color = rl.Color(255, 115, 0, 255) + else: + top_dot_color = rl.Color(255, 0, 21, 255) + bottom_dot_color = rl.Color(255, 0, 89, 255) + + elif ui_state.status == UIStatus.OVERRIDE: + top_dot_color = rl.Color(255, 255, 255, 255) + bottom_dot_color = rl.Color(82, 82, 82, 255) + + else: + top_dot_color = rl.Color(50, 50, 50, 255) + bottom_dot_color = rl.Color(13, 13, 13, 255) + + draw_circle_gradient(content_rect.x + content_rect.width - status_dot_radius, + dot_height, status_dot_radius, + top_dot_color, bottom_dot_color) diff --git a/selfdrive/ui/mici/onroad/driver_camera_dialog.py b/selfdrive/ui/mici/onroad/driver_camera_dialog.py new file mode 100644 index 0000000000..f2fa5e8fe8 --- /dev/null +++ b/selfdrive/ui/mici/onroad/driver_camera_dialog.py @@ -0,0 +1,241 @@ +import pyray as rl +from cereal import log, messaging +from msgq.visionipc import VisionStreamType +from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView +from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer +from openpilot.selfdrive.ui.ui_state import ui_state, device +from openpilot.selfdrive.selfdrived.events import EVENTS, ET +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import NavWidget +from openpilot.system.ui.widgets.label import gui_label + +EventName = log.OnroadEvent.EventName + +EVENT_TO_INT = EventName.schema.enumerants + + +class DriverCameraDialog(NavWidget): + def __init__(self, no_escape=False): + super().__init__() + self._camera_view = CameraView("camerad", VisionStreamType.VISION_STREAM_DRIVER) + self._original_calc_frame_matrix = self._camera_view._calc_frame_matrix + self._camera_view._calc_frame_matrix = self._calc_driver_frame_matrix + self.driver_state_renderer = DriverStateRenderer(lines=True) + self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200)) + self.driver_state_renderer.load_icons() + self._pm = messaging.PubMaster(['selfdriveState']) + if not no_escape: + # TODO: this can grow unbounded, should be given some thought + device.add_interactive_timeout_callback(self.stop_dmonitoringmodeld) + self.set_back_callback(self._dismiss) + self.set_back_enabled(not no_escape) + + # Load eye icons + self._eye_fill_texture = None + self._eye_orange_texture = None + self._eye_size = 74 + self._glasses_texture = None + self._glasses_size = 171 + + self._load_eye_textures() + + def stop_dmonitoringmodeld(self): + ui_state.params.put_bool("IsDriverViewEnabled", False) + gui_app.set_modal_overlay(None) + + def show_event(self): + super().show_event() + ui_state.params.put_bool("IsDriverViewEnabled", True) + self._publish_alert_sound(None) + device.reset_interactive_timeout(300) + ui_state.params.remove("DriverTooDistracted") + + def hide_event(self): + super().hide_event() + device.reset_interactive_timeout() + + def _handle_mouse_release(self, _): + ui_state.params.remove("DriverTooDistracted") + + def _dismiss(self): + self.stop_dmonitoringmodeld() + + def close(self): + if self._camera_view: + self._camera_view.close() + + def _update_state(self): + if self._camera_view: + self._camera_view._update_state() + # Enable driver state renderer to show Dmoji in preview + self.driver_state_renderer.set_should_draw(True) + self.driver_state_renderer.set_force_active(True) + super()._update_state() + + def _render(self, rect): + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + self._camera_view._render(rect) + + if not self._camera_view.frame: + gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + rl.end_scissor_mode() + self._publish_alert_sound(None) + return -1 + + self._draw_face_detection(rect) + + # Position dmoji on opposite side from driver + dm_state = ui_state.sm["driverMonitoringState"] + driver_state_rect = ( + rect.x if dm_state.isRHD else rect.x + rect.width - self.driver_state_renderer.rect.width, + rect.y + (rect.height - self.driver_state_renderer.rect.height) / 2, + ) + self.driver_state_renderer.set_position(*driver_state_rect) + self.driver_state_renderer.render() + + # Render driver monitoring alerts + self._render_dm_alerts(rect) + + rl.end_scissor_mode() + return -1 + + def _publish_alert_sound(self, dm_state): + """Publish selfdriveState with only alertSound field set""" + msg = messaging.new_message('selfdriveState') + if dm_state is not None and len(dm_state.events): + event_name = EVENT_TO_INT[dm_state.events[0].name] + if event_name is not None and event_name in EVENTS and ET.PERMANENT in EVENTS[event_name]: + msg.selfdriveState.alertSound = EVENTS[event_name][ET.PERMANENT].audible_alert + self._pm.send('selfdriveState', msg) + + def _render_dm_alerts(self, rect: rl.Rectangle): + """Render driver monitoring event names""" + dm_state = ui_state.sm["driverMonitoringState"] + self._publish_alert_sound(dm_state) + + gui_label(rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height), + f"Awareness: {dm_state.awarenessStatus * 100:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + color=rl.Color(0, 0, 0, 180)) + gui_label(rect, f"Awareness: {dm_state.awarenessStatus * 100:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + color=rl.Color(255, 255, 255, int(255 * 0.9))) + + if not dm_state.events: + return + + # Show first event (only one should be active at a time) + event_name_str = str(dm_state.events[0].name).split('.')[-1] + alignment = rl.GuiTextAlignment.TEXT_ALIGN_RIGHT if dm_state.isRHD else rl.GuiTextAlignment.TEXT_ALIGN_LEFT + + shadow_rect = rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height) + gui_label(shadow_rect, event_name_str, font_size=40, font_weight=FontWeight.BOLD, + alignment=alignment, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, + color=rl.Color(0, 0, 0, 180)) + gui_label(rect, event_name_str, font_size=40, font_weight=FontWeight.BOLD, + alignment=alignment, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, + color=rl.Color(255, 255, 255, int(255 * 0.9))) + + def _load_eye_textures(self): + """Lazy load eye textures""" + if self._eye_fill_texture is None: + self._eye_fill_texture = gui_app.texture("icons_mici/onroad/eye_fill.png", self._eye_size, self._eye_size) + if self._eye_orange_texture is None: + self._eye_orange_texture = gui_app.texture("icons_mici/onroad/eye_orange.png", self._eye_size, self._eye_size) + if self._glasses_texture is None: + self._glasses_texture = gui_app.texture("icons_mici/onroad/glasses.png", self._glasses_size, self._glasses_size) + + def _draw_face_detection(self, rect: rl.Rectangle) -> None: + driver_state = ui_state.sm["driverStateV2"] + is_rhd = driver_state.wheelOnRightProb > 0.5 + driver_data = driver_state.rightDriverData if is_rhd else driver_state.leftDriverData + face_detect = driver_data.faceProb > 0.7 + if not face_detect: + return + + # Get face position and orientation + face_x, face_y = driver_data.facePosition + face_std = max(driver_data.faceOrientationStd[0], driver_data.faceOrientationStd[1]) + alpha = 0.7 + if face_std > 0.15: + alpha = max(0.7 - (face_std - 0.15) * 3.5, 0.0) + + # use approx instead of distort_points + # TODO: replace with distort_points + tici_x = 1080.0 - 1714.0 * face_x + tici_y = -135.0 + (504.0 + abs(face_x) * 112.0) + (1205.0 - abs(face_x) * 724.0) * face_y + + # Tici coords are relative to center, scale offset + offset_x = (tici_x - 1080.0) * 1.25 + offset_y = (tici_y - 540.0) * 1.25 + + # Map to mici screen (scale from 2160x1080 to rect dimensions) + scale_x = rect.width / 2160.0 + scale_y = rect.height / 1080.0 + fbox_x = rect.x + rect.width / 2 + offset_x * scale_x + fbox_y = rect.y + rect.height / 2 + offset_y * scale_y + box_size = 50 + line_thickness = 3 + + line_color = rl.Color(255, 255, 255, int(alpha * 255)) + rl.draw_rectangle_rounded_lines_ex( + rl.Rectangle(fbox_x - box_size / 2, fbox_y - box_size / 2, box_size, box_size), + 35.0 / box_size / 2, + line_thickness, + line_thickness, + line_color, + ) + + # Draw eye indicators based on eye probabilities + eye_offset_x = 10 + eye_offset_y = 10 + eye_spacing = self._eye_size + 15 + + left_eye_x = rect.x + eye_offset_x + left_eye_y = rect.y + eye_offset_y + left_eye_prob = driver_data.leftEyeProb + + right_eye_x = rect.x + eye_offset_x + eye_spacing + right_eye_y = rect.y + eye_offset_y + right_eye_prob = driver_data.rightEyeProb + + # Draw eyes with opacity based on probability + for eye_x, eye_y, eye_prob in [(left_eye_x, left_eye_y, left_eye_prob), (right_eye_x, right_eye_y, right_eye_prob)]: + fill_opacity = eye_prob + orange_opacity = 1.0 - eye_prob + + rl.draw_texture_v(self._eye_orange_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * orange_opacity))) + rl.draw_texture_v(self._eye_fill_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * fill_opacity))) + + # Draw sunglasses indicator based on sunglasses probability + # Position glasses centered between the two eyes at top left + glasses_x = rect.x + eye_offset_x - 4 + glasses_y = rect.y + glasses_pos = rl.Vector2(glasses_x, glasses_y) + glasses_prob = driver_data.sunglassesProb + rl.draw_texture_v(self._glasses_texture, glasses_pos, rl.Color(70, 80, 161, int(255 * glasses_prob))) + + def _calc_driver_frame_matrix(self, rect: rl.Rectangle): + base = self._original_calc_frame_matrix(rect) + driver_view_ratio = 1.5 + base[0, 0] *= driver_view_ratio + base[1, 1] *= driver_view_ratio + return base + + +if __name__ == "__main__": + gui_app.init_window("Driver Camera View (mici)") + + driver_camera_view = DriverCameraDialog() + try: + for _ in gui_app.render(): + ui_state.update() + driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + finally: + driver_camera_view.close() diff --git a/selfdrive/ui/mici/onroad/driver_state.py b/selfdrive/ui/mici/onroad/driver_state.py new file mode 100644 index 0000000000..369055846e --- /dev/null +++ b/selfdrive/ui/mici/onroad/driver_state.py @@ -0,0 +1,227 @@ +import pyray as rl +from collections.abc import Callable +import numpy as np +import math +from cereal import log +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import Widget +from openpilot.selfdrive.ui.ui_state import ui_state + +AlertSize = log.SelfdriveState.AlertSize + +DEBUG = False + +LOOKING_CENTER_THRESHOLD_UPPER = math.radians(6) +LOOKING_CENTER_THRESHOLD_LOWER = math.radians(3) + + +class DriverStateRenderer(Widget): + BASE_SIZE = 60 + LINES_ANGLE_INCREMENT = 5 + LINES_STALE_ANGLES = 3.0 # seconds + + def __init__(self, lines: bool = False, confirm_mode: bool = False, confirm_callback: Callable | None = None): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, self.BASE_SIZE, self.BASE_SIZE)) + self._lines = lines or confirm_mode + + # In confirm mode, user must fill out the circle to confirm some action in the UI + self._confirm_mode = confirm_mode + self._confirm_callback = confirm_callback + self._confirm_angles: dict[int, float] = {} # angle: timestamp + + # In line mode, track smoothed angles + assert 360 % self.LINES_ANGLE_INCREMENT == 0 + self._head_angles = {i * self.LINES_ANGLE_INCREMENT: FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) for i in range(360 // self.LINES_ANGLE_INCREMENT)} + + self._is_active = False + self._is_rhd = False + self._face_detected = False + self._should_draw = False + self._force_active = False + self._looking_center = False + + self._fade_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps) + self._pitch_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False) + self._yaw_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False) + self._rotation_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps, initialized=False) + self._looking_center_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + + # Load the driver face icons + self.load_icons() + + def load_icons(self): + """Load or reload the driver face icon texture""" + self._dm_person = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_person.png", self._rect.width, self._rect.height) + self._dm_cone = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_cone.png", self._rect.width, self._rect.height) + center_size = round(36 / self.BASE_SIZE * self._rect.width) + self._dm_center = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_center.png", center_size, center_size) + background_size = round(52 / self.BASE_SIZE * self._rect.width) + self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", background_size, background_size) + + def set_should_draw(self, should_draw: bool): + self._should_draw = should_draw + + @property + def should_draw(self): + return (self._should_draw and ui_state.sm["selfdriveState"].alertSize == AlertSize.none and + ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame) + + def set_force_active(self, force_active: bool): + """Force the dmoji to always appear active (green) regardless of actual state""" + self._force_active = force_active + + @property + def effective_active(self) -> bool: + """Returns True if dmoji should appear active (either actually active or forced)""" + return bool(self._force_active or self._is_active) + + def _render(self, _): + if DEBUG: + rl.draw_rectangle_lines_ex(self._rect, 1, rl.RED) + + rl.draw_texture(self._dm_background, + int(self._rect.x + (self._rect.width - self._dm_background.width) / 2), + int(self._rect.y + (self._rect.height - self._dm_background.height) / 2), + rl.Color(255, 255, 255, int(255 * self._fade_filter.x))) + + rl.draw_texture(self._dm_person, int(self._rect.x), int(self._rect.y), + rl.Color(255, 255, 255, int(255 * 0.9 * self._fade_filter.x))) + + if self.effective_active: + source_rect = rl.Rectangle(0, 0, self._dm_cone.width, self._dm_cone.height) + dest_rect = rl.Rectangle( + self._rect.x + self._rect.width / 2, + self._rect.y + self._rect.height / 2, + self._dm_cone.width, + self._dm_cone.height, + ) + + if not self._lines: + rl.draw_texture_pro( + self._dm_cone, + source_rect, + dest_rect, + rl.Vector2(dest_rect.width / 2, dest_rect.height / 2), + self._rotation_filter.x - 90, + rl.Color(255, 255, 255, int(255 * self._fade_filter.x * (1 - self._looking_center_filter.x))), + ) + + rl.draw_texture_ex( + self._dm_center, + (int(self._rect.x + (self._rect.width - self._dm_center.width) / 2), + int(self._rect.y + (self._rect.height - self._dm_center.height) / 2)), + 0, + 1.0, + rl.Color(255, 255, 255, int(255 * self._fade_filter.x * self._looking_center_filter.x)), + ) + + else: + # remove old angles + now = rl.get_time() + self._confirm_angles = {angle: t for angle, t in self._confirm_angles.items() if now - t < self.LINES_STALE_ANGLES} + + looking_center = self._looking_center_filter.x > 0.2 + for angle, f in self._head_angles.items(): + dst_from_current = ((angle - self._rotation_filter.x) % 360) - 180 + target = 1.0 if abs(dst_from_current) <= self.LINES_ANGLE_INCREMENT * 5 else 0.0 + if not self._face_detected: + target = 0.0 + + if self._confirm_mode: + # Extra careful to not add angles when looking near center + if target > 0 and not looking_center: + self._confirm_angles[angle] = now + + # User is looking at area already confirmed, reduce target to indicate where they are + if angle in self._confirm_angles and target == 0: + target = 0.65 + + # Reduce all line lengths when looking center + if self._looking_center: + target = np.interp(self._looking_center_filter.x, [0.0, 1.0], [target, 0.45]) + + f.update(target) + self._draw_line(angle, f, self._looking_center and angle not in self._confirm_angles) + + # if all lines placed, reset for next time and call callback + if self._confirm_mode: + if len(self._confirm_angles) >= 360 // self.LINES_ANGLE_INCREMENT: + self._confirm_angles = {} + if self._confirm_callback is not None: + self._confirm_callback() + + def _draw_line(self, angle: int, f: FirstOrderFilter, grey: bool): + line_length = self._rect.width / 6 + line_length = round(np.interp(f.x, [0.0, 1.0], [0, line_length])) + line_offset = self._rect.width / 2 - line_length * 2 # ensure line ends within rect + center_x = self._rect.x + self._rect.width / 2 + center_y = self._rect.y + self._rect.height / 2 + start_x = center_x + (line_offset + line_length) * math.cos(math.radians(angle)) + start_y = center_y + (line_offset + line_length) * math.sin(math.radians(angle)) + end_x = start_x + line_length * math.cos(math.radians(angle)) + end_y = start_y + line_length * math.sin(math.radians(angle)) + color = rl.Color(0, 255, 64, 255) + + if grey: + color = rl.Color(166, 166, 166, 255) + + if f.x > 0.01: + rl.draw_line_ex((start_x, start_y), (end_x, end_y), 12, color) + + def _update_state(self): + sm = ui_state.sm + + # Get monitoring state + dm_state = sm["driverMonitoringState"] + self._is_active = dm_state.isActiveMode + self._is_rhd = dm_state.isRHD + self._face_detected = dm_state.faceDetected + + driverstate = sm["driverStateV2"] + driver_data = driverstate.rightDriverData if self._is_rhd else driverstate.leftDriverData + driver_orient = driver_data.faceOrientation + + if len(driver_orient) != 3: + return + + pitch, yaw, roll = driver_orient + pitch = self._pitch_filter.update(pitch) + yaw = self._yaw_filter.update(yaw) + + # hysteresis on looking center + if abs(pitch) < LOOKING_CENTER_THRESHOLD_LOWER and abs(yaw) < LOOKING_CENTER_THRESHOLD_LOWER: + self._looking_center = True + elif abs(pitch) > LOOKING_CENTER_THRESHOLD_UPPER or abs(yaw) > LOOKING_CENTER_THRESHOLD_UPPER: + self._looking_center = False + self._looking_center_filter.update(1 if self._looking_center else 0) + + if DEBUG: + pitchd = math.degrees(pitch) + yawd = math.degrees(yaw) + rolld = math.degrees(roll) + + rl.draw_line_ex((0, 100), (200, 100), 3, rl.RED) + rl.draw_line_ex((0, 120), (200, 120), 3, rl.RED) + rl.draw_line_ex((0, 140), (200, 140), 3, rl.RED) + + pitch_x = 100 + pitchd + yaw_x = 100 + yawd + roll_x = 100 + rolld + rl.draw_circle(int(pitch_x), 100, 5, rl.GREEN) + rl.draw_circle(int(yaw_x), 120, 5, rl.GREEN) + rl.draw_circle(int(roll_x), 140, 5, rl.GREEN) + + # filter head rotation, handling wrap-around + rotation = math.degrees(math.atan2(pitch, yaw)) + angle_diff = rotation - self._rotation_filter.x + angle_diff = ((angle_diff + 180) % 360) - 180 + self._rotation_filter.update(self._rotation_filter.x + angle_diff) + + if not self.should_draw: + self._fade_filter.update(0.0) + elif not self.effective_active: + self._fade_filter.update(0.35) + else: + self._fade_filter.update(1.0) diff --git a/selfdrive/ui/mici/onroad/hud_renderer.py b/selfdrive/ui/mici/onroad/hud_renderer.py new file mode 100644 index 0000000000..bb5171d6e3 --- /dev/null +++ b/selfdrive/ui/mici/onroad/hud_renderer.py @@ -0,0 +1,287 @@ +import pyray as rl +from dataclasses import dataclass +from openpilot.common.constants import CV +from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar +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 +from openpilot.common.filter_simple import FirstOrderFilter +from cereal import log + +EventName = log.OnroadEvent.EventName + +# Constants +SET_SPEED_NA = 255 +KM_TO_MILE = 0.621371 +CRUISE_DISABLED_CHAR = '–' + +SET_SPEED_PERSISTENCE = 2.5 # seconds + + +@dataclass(frozen=True) +class FontSizes: + current_speed: int = 176 + speed_unit: int = 66 + max_speed: int = 36 + set_speed: int = 112 + + +@dataclass(frozen=True) +class Colors: + white: rl.Color = rl.WHITE + disengaged: rl.Color = rl.Color(145, 155, 149, 255) + override: rl.Color = rl.Color(145, 155, 149, 255) # Added + engaged: rl.Color = rl.Color(128, 216, 166, 255) + disengaged_bg: rl.Color = rl.Color(0, 0, 0, 153) + override_bg: rl.Color = rl.Color(145, 155, 149, 204) + engaged_bg: rl.Color = rl.Color(128, 216, 166, 204) + grey: rl.Color = rl.Color(166, 166, 166, 255) + dark_grey: rl.Color = rl.Color(114, 114, 114, 255) + black_translucent: rl.Color = rl.Color(0, 0, 0, 166) + white_translucent: rl.Color = rl.Color(255, 255, 255, 200) + border_translucent: rl.Color = rl.Color(255, 255, 255, 75) + header_gradient_start: rl.Color = rl.Color(0, 0, 0, 114) + header_gradient_end: rl.Color = rl.BLANK + + +FONT_SIZES = FontSizes() +COLORS = Colors() + + +class TurnIntent(Widget): + FADE_IN_ANGLE = 30 # degrees + + def __init__(self): + super().__init__() + self._pre = False + self._turn_intent_direction: int = 0 + + self._turn_intent_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) + self._turn_intent_rotation_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps) + + self._txt_turn_intent_left: rl.Texture = gui_app.texture('icons_mici/turn_intent_left.png', 50, 19) + self._txt_turn_intent_right: rl.Texture = gui_app.texture('icons_mici/turn_intent_right.png', 50, 19) + + def _render(self, _): + if self._turn_intent_alpha_filter.x > 1e-2: + turn_intent_texture = self._txt_turn_intent_right if self._turn_intent_direction == 1 else self._txt_turn_intent_left + src_rect = rl.Rectangle(0, 0, turn_intent_texture.width, turn_intent_texture.height) + dest_rect = rl.Rectangle(self._rect.x + self._rect.width / 2, self._rect.y + self._rect.height / 2, + turn_intent_texture.width, turn_intent_texture.height) + + origin = (turn_intent_texture.width / 2, self._rect.height / 2) + color = rl.Color(255, 255, 255, int(255 * self._turn_intent_alpha_filter.x)) + rl.draw_texture_pro(turn_intent_texture, src_rect, dest_rect, origin, self._turn_intent_rotation_filter.x, color) + + def _update_state(self) -> None: + sm = ui_state.sm + + left = any(e.name == EventName.preLaneChangeLeft for e in sm['onroadEvents']) + right = any(e.name == EventName.preLaneChangeRight for e in sm['onroadEvents']) + if left or right: + # pre lane change + if not self._pre: + self._turn_intent_rotation_filter.x = self.FADE_IN_ANGLE if left else -self.FADE_IN_ANGLE + + self._pre = True + self._turn_intent_direction = -1 if left else 1 + self._turn_intent_alpha_filter.update(1) + self._turn_intent_rotation_filter.update(0) + elif any(e.name == EventName.laneChange for e in sm['onroadEvents']): + # fade out and rotate away + self._pre = False + self._turn_intent_alpha_filter.update(0) + + if self._turn_intent_direction == 0: + # unknown. missed pre frame? + self._turn_intent_rotation_filter.update(0) + else: + self._turn_intent_rotation_filter.update(self._turn_intent_direction * self.FADE_IN_ANGLE) + else: + # didn't complete lane change, just hide + self._pre = False + self._turn_intent_direction = 0 + self._turn_intent_alpha_filter.update(0) + self._turn_intent_rotation_filter.update(0) + + +class HudRenderer(Widget): + def __init__(self): + super().__init__() + """Initialize the HUD renderer.""" + self.is_cruise_set: bool = False + self.is_cruise_available: bool = True + self.set_speed: float = SET_SPEED_NA + self._set_speed_changed_time: float = 0 + self.speed: float = 0.0 + self.v_ego_cluster_seen: bool = False + self._engaged: bool = False + + self._can_draw_top_icons = True + self._show_wheel_critical = False + + self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD) + self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM) + self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD) + self._font_display: rl.Font = gui_app.font(FontWeight.DISPLAY) + + self._turn_intent = TurnIntent() + self._torque_bar = TorqueBar() + + self._txt_wheel: rl.Texture = gui_app.texture('icons_mici/wheel.png', 50, 50) + self._txt_wheel_critical: rl.Texture = gui_app.texture('icons_mici/wheel_critical.png', 50, 50) + self._txt_exclamation_point: rl.Texture = gui_app.texture('icons_mici/exclamation_point.png', 44, 44) + + self._wheel_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) + self._wheel_y_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps) + + self._set_speed_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + + def set_wheel_critical_icon(self, critical: bool): + """Set the wheel icon to critical or normal state.""" + self._show_wheel_critical = critical + + def set_can_draw_top_icons(self, can_draw_top_icons: bool): + """Set whether to draw the top part of the HUD.""" + self._can_draw_top_icons = can_draw_top_icons + + def drawing_top_icons(self) -> bool: + # whether we're drawing any top icons currently + return bool(self._set_speed_alpha_filter.x > 1e-2) + + def _update_state(self) -> None: + """Update HUD state based on car state and controls state.""" + sm = ui_state.sm + if sm.recv_frame["carState"] < ui_state.started_frame: + self.is_cruise_set = False + self.set_speed = SET_SPEED_NA + self.speed = 0.0 + return + + controls_state = sm['controlsState'] + car_state = sm['carState'] + + v_cruise_cluster = car_state.vCruiseCluster + set_speed = ( + controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster + ) + engaged = sm['selfdriveState'].enabled + if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged): + self._set_speed_changed_time = rl.get_time() + self._engaged = engaged + self.set_speed = set_speed + self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA + self.is_cruise_available = self.set_speed != -1 + + v_ego_cluster = car_state.vEgoCluster + self.v_ego_cluster_seen = self.v_ego_cluster_seen or v_ego_cluster != 0.0 + v_ego = v_ego_cluster if self.v_ego_cluster_seen else car_state.vEgo + speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH + self.speed = max(0.0, v_ego * speed_conversion) + + def _render(self, rect: rl.Rectangle) -> None: + """Render HUD elements to the screen.""" + + if ui_state.sm['controlsState'].lateralControlState.which() != 'angleState': + self._torque_bar.render(rect) + + if self.is_cruise_set: + self._draw_set_speed(rect) + + self._draw_steering_wheel(rect) + + def _draw_steering_wheel(self, rect: rl.Rectangle) -> None: + wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel + + if self._show_wheel_critical: + self._wheel_alpha_filter.update(255) + self._wheel_y_filter.update(0) + else: + if ui_state.status == UIStatus.DISENGAGED: + self._wheel_alpha_filter.update(0) + self._wheel_y_filter.update(wheel_txt.height / 2) + else: + self._wheel_alpha_filter.update(255 * 0.9) + self._wheel_y_filter.update(0) + + # pos + pos_x = int(rect.x + 21 + wheel_txt.width / 2) + pos_y = int(rect.y + rect.height - 14 - wheel_txt.height / 2 + self._wheel_y_filter.x) + rotation = -ui_state.sm['carState'].steeringAngleDeg + + turn_intent_margin = 25 + self._turn_intent.render(rl.Rectangle( + pos_x - wheel_txt.width / 2 - turn_intent_margin, + pos_y - wheel_txt.height / 2 - turn_intent_margin, + wheel_txt.width + turn_intent_margin * 2, + wheel_txt.height + turn_intent_margin * 2, + )) + + src_rect = rl.Rectangle(0, 0, wheel_txt.width, wheel_txt.height) + dest_rect = rl.Rectangle(pos_x, pos_y, wheel_txt.width, wheel_txt.height) + origin = (wheel_txt.width / 2, wheel_txt.height / 2) + + # color and draw + color = rl.Color(255, 255, 255, int(self._wheel_alpha_filter.x)) + rl.draw_texture_pro(wheel_txt, src_rect, dest_rect, origin, rotation, color) + + if self._show_wheel_critical: + # Draw exclamation point icon + EXCLAMATION_POINT_SPACING = 10 + exclamation_pos_x = pos_x - self._txt_exclamation_point.width / 2 + wheel_txt.width / 2 + EXCLAMATION_POINT_SPACING + exclamation_pos_y = pos_y - self._txt_exclamation_point.height / 2 + rl.draw_texture(self._txt_exclamation_point, int(exclamation_pos_x), int(exclamation_pos_y), rl.WHITE) + + def _draw_set_speed(self, rect: rl.Rectangle) -> None: + """Draw the MAX speed indicator box.""" + x = rect.x + y = rect.y + + alpha = self._set_speed_alpha_filter.update(0 < rl.get_time() - self._set_speed_changed_time < SET_SPEED_PERSISTENCE and + self._can_draw_top_icons and self._engaged) + + # draw drop shadow + circle_radius = 162 // 2 + rl.draw_circle_gradient(int(x + circle_radius), int(y + circle_radius), circle_radius, + rl.Color(0, 0, 0, int(255 / 2 * alpha)), rl.Color(0, 0, 0, 0)) + + set_speed_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha)) + max_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha)) + + set_speed = self.set_speed + if self.is_cruise_set and not ui_state.is_metric: + set_speed *= KM_TO_MILE + + set_speed_text = CRUISE_DISABLED_CHAR if not self.is_cruise_set else str(round(set_speed)) + rl.draw_text_ex( + self._font_display, + set_speed_text, + rl.Vector2(x + 13 + 4, y + 3 - 8 - 3 + 4), + FONT_SIZES.set_speed, + 0, + set_speed_color, + ) + + max_text = tr("MAX") + rl.draw_text_ex( + self._font_semi_bold, + max_text, + rl.Vector2(x + 25, y + FONT_SIZES.set_speed - 7 + 4), + FONT_SIZES.max_speed, + 0, + max_color, + ) + + def _draw_current_speed(self, rect: rl.Rectangle) -> None: + """Draw the current vehicle speed and unit.""" + speed_text = str(round(self.speed)) + speed_text_size = measure_text_cached(self._font_bold, speed_text, FONT_SIZES.current_speed) + 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 = 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/mici/onroad/model_renderer.py b/selfdrive/ui/mici/onroad/model_renderer.py new file mode 100644 index 0000000000..3f1badfe84 --- /dev/null +++ b/selfdrive/ui/mici/onroad/model_renderer.py @@ -0,0 +1,479 @@ +import colorsys +import numpy as np +import pyray as rl +from cereal import messaging, car +from dataclasses import dataclass, field +from openpilot.common.params import Params +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus +from openpilot.selfdrive.ui.mici.onroad import blend_colors +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient +from openpilot.system.ui.widgets import Widget + +CLIP_MARGIN = 500 +MIN_DRAW_DISTANCE = 10.0 +MAX_DRAW_DISTANCE = 100.0 + +THROTTLE_COLORS = [ + rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4) + rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35) + rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0) +] + +NO_THROTTLE_COLORS = [ + rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4) + rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35) + rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0) +] + +LANE_LINE_COLORS = { + UIStatus.DISENGAGED: rl.Color(200, 200, 200, 255), + UIStatus.OVERRIDE: rl.Color(255, 255, 255, 255), + UIStatus.ENGAGED: rl.Color(0, 255, 64, 255), +} + + +@dataclass +class ModelPoints: + raw_points: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32)) + projected_points: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32)) + + +@dataclass +class LeadVehicle: + glow: list[float] = field(default_factory=list) + chevron: list[float] = field(default_factory=list) + fill_alpha: int = 0 + + +class ModelRenderer(Widget): + def __init__(self): + super().__init__() + self._longitudinal_control = False + self._experimental_mode = False + self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps) + self._prev_allow_throttle = True + self._lane_line_probs = np.zeros(4, dtype=np.float32) + self._road_edge_stds = np.zeros(2, dtype=np.float32) + self._lead_vehicles = [LeadVehicle(), LeadVehicle()] + self._path_offset_z = HEIGHT_INIT[0] + + # Initialize ModelPoints objects + self._path = ModelPoints() + self._lane_lines = [ModelPoints() for _ in range(4)] + self._road_edges = [ModelPoints() for _ in range(2)] + self._acceleration_x = np.empty((0,), dtype=np.float32) + + self._acceleration_x_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + self._acceleration_x_filter2 = FirstOrderFilter(0.0, 1, 1 / gui_app.target_fps) + + self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps) + self._ll_color_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + + # Transform matrix (3x3 for car space to screen space) + self._car_space_transform = np.zeros((3, 3), dtype=np.float32) + self._transform_dirty = True + self._clip_region = None + + 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"): + cp = messaging.log_from_bytes(car_params, car.CarParams) + self._longitudinal_control = cp.openpilotLongitudinalControl + + def set_transform(self, transform: np.ndarray): + self._car_space_transform = transform.astype(np.float32) + self._transform_dirty = True + + def _render(self, rect: rl.Rectangle): + sm = ui_state.sm + + self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque) + + # Check if data is up-to-date + if (sm.recv_frame["liveCalibration"] < ui_state.started_frame or + sm.recv_frame["modelV2"] < ui_state.started_frame): + return + + # Set up clipping region + self._clip_region = rl.Rectangle( + rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN + ) + + # Update state + self._experimental_mode = sm['selfdriveState'].experimentalMode + + live_calib = sm['liveCalibration'] + self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0] + + if sm.updated['carParams']: + self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl + + model = sm['modelV2'] + radar_state = sm['radarState'] if sm.valid['radarState'] else None + lead_one = radar_state.leadOne if radar_state else None + render_lead_indicator = self._longitudinal_control and radar_state is not None + + # Update model data when needed + model_updated = sm.updated['modelV2'] + if model_updated or sm.updated['radarState'] or self._transform_dirty: + if model_updated: + self._update_raw_points(model) + + path_x_array = self._path.raw_points[:, 0] + if path_x_array.size == 0: + return + + self._update_model(lead_one, path_x_array) + if render_lead_indicator: + self._update_leads(radar_state, path_x_array) + self._transform_dirty = False + + # Draw elements (hide when disengaged) + if ui_state.status != UIStatus.DISENGAGED: + self._draw_lane_lines() + self._draw_path(sm) + + # if render_lead_indicator and radar_state: + # self._draw_lead_indicator() + + def _update_raw_points(self, model): + """Update raw 3D points from model data""" + self._path.raw_points = np.array([model.position.x, model.position.y, model.position.z], dtype=np.float32).T + + for i, lane_line in enumerate(model.laneLines): + self._lane_lines[i].raw_points = np.array([lane_line.x, lane_line.y, lane_line.z], dtype=np.float32).T + + for i, road_edge in enumerate(model.roadEdges): + self._road_edges[i].raw_points = np.array([road_edge.x, road_edge.y, road_edge.z], dtype=np.float32).T + + self._lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32) + self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32) + self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32) + + def _update_leads(self, radar_state, path_x_array): + """Update positions of lead vehicles""" + self._lead_vehicles = [LeadVehicle(), LeadVehicle()] + leads = [radar_state.leadOne, radar_state.leadTwo] + + for i, lead_data in enumerate(leads): + if lead_data and lead_data.status: + d_rel, y_rel, v_rel = lead_data.dRel, lead_data.yRel, lead_data.vRel + idx = self._get_path_length_idx(path_x_array, d_rel) + + # Get z-coordinate from path at the lead vehicle position + z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0 + point = self._map_to_screen(d_rel, -y_rel, z + self._path_offset_z) + if point: + self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect) + + def _update_model(self, lead, path_x_array): + """Update model visualization data based on model message""" + max_distance = np.clip(path_x_array[-1], MIN_DRAW_DISTANCE, MAX_DRAW_DISTANCE) + max_idx = self._get_path_length_idx(self._lane_lines[0].raw_points[:, 0], max_distance) + + # Update lane lines using raw points + line_width_factor = 0.12 + for i, lane_line in enumerate(self._lane_lines): + if i in (1, 2): + line_width_factor = 0.16 + lane_line.projected_points = self._map_line_to_polygon( + lane_line.raw_points, line_width_factor * self._lane_line_probs[i], 0.0, max_idx + ) + + # 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, line_width_factor, 0.0, max_idx) + + # Update path using raw points + if lead and lead.status: + lead_d = lead.dRel * 2.0 + max_distance = np.clip(lead_d - min(lead_d * 0.35, 10.0), 0.0, max_distance) + + soon_acceleration = self._acceleration_x[len(self._acceleration_x) // 4] if len(self._acceleration_x) > 0 else 0 + self._acceleration_x_filter.update(soon_acceleration) + self._acceleration_x_filter2.update(soon_acceleration) + + # make path width wider/thinner when initially braking/accelerating + if self._experimental_mode and False: + high_pass_acceleration = self._acceleration_x_filter.x - self._acceleration_x_filter2.x + y_off = np.interp(high_pass_acceleration, [-1, 0, 1], [0.9 * 2, 0.9, 0.9 / 2]) + else: + y_off = 0.9 + + 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, y_off, self._path_offset_z, max_idx, allow_invert=False + ) + + self._update_experimental_gradient() + + def _update_experimental_gradient(self): + """Pre-calculate experimental mode gradient colors""" + if not self._experimental_mode: + return + + max_len = min(len(self._path.projected_points) // 2, len(self._acceleration_x)) + + segment_colors = [] + gradient_stops = [] + + i = 0 + while i < max_len: + # Some points (screen space) are out of frame (rect space) + track_y = self._path.projected_points[i][1] + if track_y < self._rect.y or track_y > (self._rect.y + self._rect.height): + i += 1 + continue + + # Calculate color based on acceleration (0 is bottom, 1 is top) + lin_grad_point = 1 - (track_y - self._rect.y) / self._rect.height + + # speed up: 120, slow down: 0 + path_hue = np.clip(60 + self._acceleration_x[i] * 35, 0, 120) + + saturation = min(abs(self._acceleration_x[i] * 1.5), 1) + lightness = np.interp(saturation, [0.0, 1.0], [0.95, 0.62]) + alpha = np.interp(lin_grad_point, [0.75 / 2.0, 0.75], [0.4, 0.0]) + + # Use HSL to RGB conversion + color = self._hsla_to_color(path_hue / 360.0, saturation, lightness, alpha) + + gradient_stops.append(lin_grad_point) + segment_colors.append(color) + + # Skip a point, unless next is last + 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 + + def _update_lead_vehicle(self, d_rel, v_rel, point, rect): + speed_buff, lead_buff = 10.0, 40.0 + + # Calculate fill alpha + fill_alpha = 0 + if d_rel < lead_buff: + fill_alpha = 255 * (1.0 - (d_rel / lead_buff)) + if v_rel < 0: + fill_alpha += 255 * (-1 * (v_rel / speed_buff)) + fill_alpha = min(fill_alpha, 255) + + # Calculate size and position + sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 1 + x = np.clip(point[0], 0.0, rect.width - sz / 2) + y = min(point[1], rect.height - sz * 0.6) + + g_xo = sz / 5 + g_yo = sz / 10 + + glow = [(x + (sz * 1.35) + g_xo, y + sz + g_yo), (x, y - g_yo), (x - (sz * 1.35) - g_xo, y + sz + g_yo)] + chevron = [(x + (sz * 1.25), y + sz), (x, y), (x - (sz * 1.25), y + sz)] + + return LeadVehicle(glow=glow, chevron=chevron, fill_alpha=int(fill_alpha)) + + def _get_ll_color(self, prob: float, adjacent: bool, left: bool): + alpha = np.clip(prob, 0.0, 0.7) + if adjacent: + _base_color = LANE_LINE_COLORS.get(ui_state.status, LANE_LINE_COLORS[UIStatus.DISENGAGED]) + color = rl.Color(_base_color.r, _base_color.g, _base_color.b, int(alpha * 255)) + + # turn adjacent lls orange if torque is high + torque = self._torque_filter.x + high_torque = abs(torque) > 0.6 + if high_torque and (left == (torque > 0)): + color = blend_colors( + color, + rl.Color(255, 115, 0, int(alpha * 255)), # orange + np.interp(abs(torque), [0.6, 0.8], [0.0, 1.0]) + ) + else: + color = rl.Color(255, 255, 255, int(alpha * 255)) + + if ui_state.status == UIStatus.DISENGAGED: + color = rl.Color(0, 0, 0, int(alpha * 255)) + + return color + + def _draw_lane_lines(self): + """Draw lane lines and road edges""" + """Two closest lines should be green (lane line or road edges)""" + for i, lane_line in enumerate(self._lane_lines): + if lane_line.projected_points.size == 0: + continue + + color = self._get_ll_color(float(self._lane_line_probs[i]), i in (1, 2), i in (0, 1)) + draw_polygon(self._rect, lane_line.projected_points, color) + + for i, road_edge in enumerate(self._road_edges): + if road_edge.projected_points.size == 0: + continue + + # if closest lane lines are not confident, make road edges green + color = self._get_ll_color(float(1.0 - self._road_edge_stds[i]), float(self._lane_line_probs[i + 1]) < 0.25, i == 0) + draw_polygon(self._rect, road_edge.projected_points, color) + + def _draw_path(self, sm): + """Draw path with dynamic coloring based on mode and throttle state.""" + if not self._path.projected_points.size: + return + + allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control + self._blend_filter.update(int(allow_throttle)) + + if self._experimental_mode: + # Draw with acceleration coloring + if ui_state.status == UIStatus.DISENGAGED: + draw_polygon(self._rect, self._path.projected_points, rl.Color(0, 0, 0, 90)) + elif 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)) + else: + # 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 = 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], + ) + + if ui_state.status == UIStatus.DISENGAGED: + draw_polygon(self._rect, self._path.projected_points, rl.Color(0, 0, 0, 90)) + else: + draw_polygon(self._rect, self._path.projected_points, gradient=gradient) + + def _draw_lead_indicator(self): + # Draw lead vehicles if available + for lead in self._lead_vehicles: + if not lead.glow or not lead.chevron: + continue + + rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255)) + 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""" + if len(pos_x_array) == 0: + return 0 + indices = np.where(pos_x_array <= path_height)[0] + return indices[-1] if indices.size > 0 else 0 + + def _map_to_screen(self, in_x, in_y, in_z): + """Project a point in car space to screen space""" + input_pt = np.array([in_x, in_y, in_z]) + pt = self._car_space_transform @ input_pt + + if abs(pt[2]) < 1e-6: + return None + + x, y = pt[0] / pt[2], pt[1] / pt[2] + + clip = self._clip_region + if not (clip.x <= x <= clip.x + clip.width and clip.y <= y <= clip.y + clip.height): + return None + + 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: + """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] + points = points[points[:, 0] >= 0] + if points.shape[0] == 0: + return np.empty((0, 2), dtype=np.float32) + + N = points.shape[0] + # Generate left and right 3D points in one array using broadcasting + offsets = np.array([[0, -y_off, z_off], [0, y_off, z_off]], dtype=np.float32) + points_3d = points[None, :, :] + offsets[:, None, :] # Shape: 2xNx3 + points_3d = points_3d.reshape(2 * N, 3) # Shape: (2*N)x3 + + # Transform all points to projected space in one operation + proj = self._car_space_transform @ points_3d.T # Shape: 3x(2*N) + proj = proj.reshape(3, 2, N) + left_proj = proj[:, 0, :] + right_proj = proj[:, 1, :] + + # Filter points where z is sufficiently large + valid_proj = (np.abs(left_proj[2]) >= 1e-6) & (np.abs(right_proj[2]) >= 1e-6) + if not np.any(valid_proj): + return np.empty((0, 2), dtype=np.float32) + + # Compute screen coordinates + left_screen = left_proj[:2, valid_proj] / left_proj[2, valid_proj][None, :] + right_screen = right_proj[:2, valid_proj] / right_proj[2, valid_proj][None, :] + + # Define clip region bounds + clip = self._clip_region + x_min, x_max = clip.x, clip.x + clip.width + y_min, y_max = clip.y, clip.y + clip.height + + # Filter points within clip region + left_in_clip = ( + (left_screen[0] >= x_min) & (left_screen[0] <= x_max) & + (left_screen[1] >= y_min) & (left_screen[1] <= y_max) + ) + right_in_clip = ( + (right_screen[0] >= x_min) & (right_screen[0] <= x_max) & + (right_screen[1] >= y_min) & (right_screen[1] <= y_max) + ) + both_in_clip = left_in_clip & right_in_clip + + if not np.any(both_in_clip): + return np.empty((0, 2), dtype=np.float32) + + # Select valid and clipped points + left_screen = left_screen[:, both_in_clip] + right_screen = right_screen[:, both_in_clip] + + # Handle Y-coordinate inversion on hills + if not allow_invert and left_screen.shape[1] > 1: + y = left_screen[1, :] # y-coordinates + keep = y == np.minimum.accumulate(y) + if not np.any(keep): + return np.empty((0, 2), dtype=np.float32) + left_screen = left_screen[:, keep] + right_screen = right_screen[:, keep] + + return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32) + + @staticmethod + def _hsla_to_color(h, s, l, a): + rgb = colorsys.hls_to_rgb(h, l, s) + return rl.Color( + int(rgb[0] * 255), + int(rgb[1] * 255), + int(rgb[2] * 255), + int(a * 255) + ) + + @staticmethod + def _blend_colors(begin_colors, end_colors, t): + if t >= 1.0: + return end_colors + if t <= 0.0: + return begin_colors + + inv_t = 1.0 - t + return [rl.Color( + int(inv_t * start.r + t * end.r), + int(inv_t * start.g + t * end.g), + int(inv_t * start.b + t * end.b), + int(inv_t * start.a + t * end.a) + ) for start, end in zip(begin_colors, end_colors, strict=True)] diff --git a/selfdrive/ui/mici/onroad/torque_bar.py b/selfdrive/ui/mici/onroad/torque_bar.py new file mode 100644 index 0000000000..1f6dffe879 --- /dev/null +++ b/selfdrive/ui/mici/onroad/torque_bar.py @@ -0,0 +1,253 @@ +import math +import time +from functools import wraps +from collections import OrderedDict + +import numpy as np +import pyray as rl +from opendbc.car import ACCELERATION_DUE_TO_GRAVITY +from openpilot.selfdrive.ui.mici.onroad import blend_colors +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient +from openpilot.system.ui.widgets import Widget +from openpilot.common.filter_simple import FirstOrderFilter + +# TODO: arc_bar_pts doesn't consider rounded end caps part of the angle span +TORQUE_ANGLE_SPAN = 12.7 + +DEBUG = False + + +def quantized_lru_cache(maxsize=128): + def decorator(func): + cache = OrderedDict() + @wraps(func) + def wrapper(cx, cy, r_mid, thickness, a0_deg, a1_deg, **kwargs): + # Quantize inputs: balanced for smoothness vs cache effectiveness + key = (round(cx), round(cy), round(r_mid), + round(thickness), # 1px precision for smoother height transitions + round(a0_deg * 10) / 10, # 0.1° precision for smoother angle transitions + round(a1_deg * 10) / 10, + tuple(sorted(kwargs.items()))) + + if key in cache: + cache.move_to_end(key) + else: + if len(cache) >= maxsize: + cache.popitem(last=False) + + result = func(cx, cy, r_mid, thickness, a0_deg, a1_deg, **kwargs) + cache[key] = result + return cache[key] + return wrapper + return decorator + + +@quantized_lru_cache(maxsize=256) +def arc_bar_pts(cx: float, cy: float, + r_mid: float, thickness: float, + a0_deg: float, a1_deg: float, + *, max_points: int = 100, cap_segs: int = 10, + cap_radius: float = 7, px_per_seg: float = 2.0) -> np.ndarray: + """Return Nx2 np.float32 points for a single closed polygon (rounded thick arc).""" + + def get_cap(left: bool, a_deg: float): + # end cap at a1: center (a1), sweep a1→a1+180 (skip endpoints to avoid dupes) + # quarter arc (outer corner) at a1 with fixed pixel radius cap_radius + + nx, ny = math.cos(math.radians(a_deg)), math.sin(math.radians(a_deg)) # outward normal + tx, ty = -ny, nx # tangent (CCW) + + mx, my = cx + nx * r_mid, cy + ny * r_mid # mid-point at a1 + if DEBUG: + rl.draw_circle(int(mx), int(my), 4, rl.PURPLE) + + ex = mx + nx * (half - cap_radius) + ey = my + ny * (half - cap_radius) + + if DEBUG: + rl.draw_circle(int(ex), int(ey), 2, rl.WHITE) + + # sweep 90° in the local (t,n) frame: from outer edge toward inside + if not left: + alpha = np.deg2rad(np.linspace(90, 0, cap_segs + 2))[1:-1] + else: + alpha = np.deg2rad(np.linspace(180, 90, cap_segs + 2))[1:-1] + cap_end = np.c_[ex + np.cos(alpha) * cap_radius * tx + np.sin(alpha) * cap_radius * nx, + ey + np.cos(alpha) * cap_radius * ty + np.sin(alpha) * cap_radius * ny] + + # bottom quarter (inner corner) at a1 + ex2 = mx + nx * (-half + cap_radius) + ey2 = my + ny * (-half + cap_radius) + if DEBUG: + rl.draw_circle(int(ex2), int(ey2), 2, rl.WHITE) + + if not left: + alpha2 = np.deg2rad(np.linspace(0, -90, cap_segs + 1))[:-1] # include 0 once, exclude -90 + else: + alpha2 = np.deg2rad(np.linspace(90 - 90 - 90, 0 - 90 - 90, cap_segs + 1))[:-1] + cap_end_bot = np.c_[ex2 + np.cos(alpha2) * cap_radius * tx + np.sin(alpha2) * cap_radius * nx, + ey2 + np.cos(alpha2) * cap_radius * ty + np.sin(alpha2) * cap_radius * ny] + + # append to the top quarter + if not left: + cap_end = np.vstack((cap_end, cap_end_bot)) + else: + cap_end = np.vstack((cap_end_bot, cap_end)) + + return cap_end + + if a1_deg < a0_deg: + a0_deg, a1_deg = a1_deg, a0_deg + half = thickness * 0.5 + + cap_radius = min(cap_radius, half) + + span = max(1e-3, a1_deg - a0_deg) + + # pick arc segment count from arc length, clamp to shader points[] budget + arc_len = r_mid * math.radians(span) + arc_segs = max(6, int(arc_len / px_per_seg)) + max_arc = (max_points - (4 * cap_segs + 3)) // 2 + arc_segs = max(6, min(arc_segs, max_arc)) + + # outer arc a0→a1 + ang_o = np.deg2rad(np.linspace(a0_deg, a1_deg, arc_segs + 1)) + outer = np.c_[cx + np.cos(ang_o) * (r_mid + half), + cy + np.sin(ang_o) * (r_mid + half)] + + # end cap at a1 + cap_end = get_cap(False, a1_deg) + + # inner arc a1→a0 + ang_i = np.deg2rad(np.linspace(a1_deg, a0_deg, arc_segs + 1)) + inner = np.c_[cx + np.cos(ang_i) * (r_mid - half), + cy + np.sin(ang_i) * (r_mid - half)] + + # start cap at a0 + cap_start = get_cap(True, a0_deg) + + pts = np.vstack((outer, cap_end, inner, cap_start, outer[:1])).astype(np.float32) + + if DEBUG: + n = len(pts) + idx = int(time.monotonic() * 12) % max(1, n) # speed: 12 pts/sec + for i, (x, y) in enumerate(pts): + j = (i - idx) % n # rotate the gradient + t = j / n + color = rl.Color(255, int(255 * (1 - t)), int(255 * t), 255) + rl.draw_circle(int(x), int(y), 2, color) + + return pts + + +class TorqueBar(Widget): + def __init__(self, demo: bool = False): + super().__init__() + self._demo = demo + self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps) + self._torque_line_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + + def update_filter(self, value: float): + """Update the torque filter value (for demo mode).""" + self._torque_filter.update(value) + + def _update_state(self): + if self._demo: + return + + # torque line + if ui_state.sm['controlsState'].lateralControlState.which() == 'angleState': + controls_state = ui_state.sm['controlsState'] + car_state = ui_state.sm['carState'] + live_parameters = ui_state.sm['liveParameters'] + lateral_acceleration = controls_state.curvature * car_state.vEgo ** 2 - live_parameters.roll * ACCELERATION_DUE_TO_GRAVITY + # TODO: pull from carparams + max_lateral_acceleration = 3 + + # from selfdrived + actual_lateral_accel = controls_state.curvature * car_state.vEgo ** 2 + desired_lateral_accel = controls_state.desiredCurvature * car_state.vEgo ** 2 + accel_diff = (desired_lateral_accel - actual_lateral_accel) + + self._torque_filter.update(min(max(lateral_acceleration / max_lateral_acceleration + accel_diff, -1), 1)) + else: + self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque) + + def _render(self, rect: rl.Rectangle) -> None: + # adjust y pos with torque + torque_line_offset = np.interp(abs(self._torque_filter.x), [0.5, 1], [22, 26]) + torque_line_height = np.interp(abs(self._torque_filter.x), [0.5, 1], [14, 56]) + + # animate alpha and angle span + if not self._demo: + self._torque_line_alpha_filter.update(ui_state.status != UIStatus.DISENGAGED) + else: + self._torque_line_alpha_filter.update(1.0) + + torque_line_bg_alpha = np.interp(abs(self._torque_filter.x), [0.5, 1.0], [0.25, 0.5]) + torque_line_bg_color = rl.Color(255, 255, 255, int(255 * torque_line_bg_alpha * self._torque_line_alpha_filter.x)) + if ui_state.status != UIStatus.ENGAGED and not self._demo: + torque_line_bg_color = rl.Color(255, 255, 255, int(255 * 0.15 * self._torque_line_alpha_filter.x)) + + # draw curved line polygon torque bar + torque_line_radius = 1200 + top_angle = -90 + torque_bg_angle_span = self._torque_line_alpha_filter.x * TORQUE_ANGLE_SPAN + torque_start_angle = top_angle - torque_bg_angle_span / 2 + torque_end_angle = top_angle + torque_bg_angle_span / 2 + # centerline radius & center (you already have these values) + mid_r = torque_line_radius + torque_line_height / 2 + + cx = rect.x + rect.width / 2 + 8 # offset 8px to right of camera feed + cy = rect.y + rect.height + torque_line_radius - torque_line_offset + + # draw bg torque indicator line + bg_pts = arc_bar_pts(cx, cy, mid_r, torque_line_height, torque_start_angle, torque_end_angle) + draw_polygon(rect, bg_pts, color=torque_line_bg_color) + + # draw torque indicator line + a0s = top_angle + a1s = a0s + torque_bg_angle_span / 2 * self._torque_filter.x + sl_pts = arc_bar_pts(cx, cy, mid_r, torque_line_height, a0s, a1s) + + # draw beautiful gradient from center to 65% of the bg torque bar width + start_grad_pt = cx / rect.width + if self._torque_filter.x < 0: + end_grad_pt = (cx * (1 - 0.65) + (min(bg_pts[:, 0]) * 0.65)) / rect.width + else: + end_grad_pt = (cx * (1 - 0.65) + (max(bg_pts[:, 0]) * 0.65)) / rect.width + + # fade to orange as we approach max torque + start_color = blend_colors( + rl.Color(255, 255, 255, int(255 * 0.9 * self._torque_line_alpha_filter.x)), + rl.Color(255, 200, 0, int(255 * self._torque_line_alpha_filter.x)), # yellow + max(0, abs(self._torque_filter.x) - 0.75) * 4, + ) + end_color = blend_colors( + rl.Color(255, 255, 255, int(255 * 0.9 * self._torque_line_alpha_filter.x)), + rl.Color(255, 115, 0, int(255 * self._torque_line_alpha_filter.x)), # orange + max(0, abs(self._torque_filter.x) - 0.75) * 4, + ) + + if ui_state.status != UIStatus.ENGAGED and not self._demo: + start_color = end_color = rl.Color(255, 255, 255, int(255 * 0.35 * self._torque_line_alpha_filter.x)) + + gradient = Gradient( + start=(start_grad_pt, 0), + end=(end_grad_pt, 0), + colors=[ + start_color, + end_color, + ], + stops=[0.0, 1.0], + ) + + draw_polygon(rect, sl_pts, gradient=gradient) + + # draw center torque bar dot + if abs(self._torque_filter.x) < 0.5: + dot_y = self._rect.y + self._rect.height - torque_line_offset - torque_line_height / 2 + rl.draw_circle(int(cx), int(dot_y), 10 // 2, + rl.Color(182, 182, 182, int(255 * 0.9 * self._torque_line_alpha_filter.x))) diff --git a/selfdrive/ui/mici/widgets/button.py b/selfdrive/ui/mici/widgets/button.py new file mode 100644 index 0000000000..be08e0fee3 --- /dev/null +++ b/selfdrive/ui/mici/widgets/button.py @@ -0,0 +1,375 @@ +import pyray as rl +from typing import Union +from enum import Enum +from collections.abc import Callable +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import MiciLabel +from openpilot.system.ui.widgets.scroller import DO_ZOOM +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.common.filter_simple import BounceFilter + +try: + from openpilot.common.params import Params +except ImportError: + Params = None + +SCROLLING_SPEED_PX_S = 50 +COMPLICATION_SIZE = 36 +LABEL_COLOR = rl.WHITE +LABEL_HORIZONTAL_PADDING = 40 +COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255) +PRESSED_SCALE = 1.15 if DO_ZOOM else 1.07 + + +class ScrollState(Enum): + PRE_SCROLL = 0 + SCROLLING = 1 + POST_SCROLL = 2 + + +class BigCircleButton(Widget): + def __init__(self, icon: str, red: bool = False): + super().__init__() + self._red = red + + # State + self.set_rect(rl.Rectangle(0, 0, 180, 180)) + self._press_state_enabled = True + self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) + + # Icons + self._txt_icon = gui_app.texture(icon, 64, 53) + self._txt_btn_disabled_bg = gui_app.texture("icons_mici/buttons/button_circle_disabled.png", 180, 180) + + self._txt_btn_bg = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180) + self._txt_btn_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_hover.png", 180, 180) + + self._txt_btn_red_bg = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180) + self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_hover.png", 180, 180) + + def set_enable_pressed_state(self, pressed: bool): + self._press_state_enabled = pressed + + def _render(self, _): + # draw background + txt_bg = self._txt_btn_bg if not self._red else self._txt_btn_red_bg + if not self.enabled: + txt_bg = self._txt_btn_disabled_bg + elif self.is_pressed and self._press_state_enabled: + txt_bg = self._txt_btn_pressed_bg if not self._red else self._txt_btn_red_pressed_bg + + scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed and self._press_state_enabled else 1.0) + btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2 + btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2 + rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE) + + # draw icon + icon_color = rl.WHITE if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35)) + rl.draw_texture(self._txt_icon, int(self._rect.x + (self._rect.width - self._txt_icon.width) / 2), + int(self._rect.y + (self._rect.height - self._txt_icon.height) / 2), icon_color) + + +class BigCircleToggle(BigCircleButton): + def __init__(self, icon: str, toggle_callback: Callable = None): + super().__init__(icon, False) + self._toggle_callback = toggle_callback + + # State + self._checked = False + + # Icons + self._txt_toggle_enabled = gui_app.texture("icons_mici/buttons/toggle_dot_enabled.png", 66, 66) + self._txt_toggle_disabled = gui_app.texture("icons_mici/buttons/toggle_dot_disabled.png", 70, 70) # TODO: why discrepancy? + + def set_checked(self, checked: bool): + self._checked = checked + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + + self._checked = not self._checked + if self._toggle_callback: + self._toggle_callback(self._checked) + + def _render(self, _): + super()._render(_) + + # draw status icon + rl.draw_texture(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled, + int(self._rect.x + (self._rect.width - self._txt_toggle_enabled.width) / 2), + int(self._rect.y + 5), rl.WHITE) + + +class BigButton(Widget): + """A lightweight stand-in for the Qt BigButton, drawn & updated each frame.""" + + def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = ""): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, 402, 180)) + self.text = text + self.value = value + self.set_icon(icon) + + self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) + + self._rotate_icon_t: float | None = None + + self._label_font = gui_app.font(FontWeight.DISPLAY) + self._value_font = gui_app.font(FontWeight.ROMAN) + + self._label = MiciLabel(text, font_size=self._get_label_font_size(), width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2), + font_weight=FontWeight.DISPLAY, color=LABEL_COLOR, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True) + self._sub_label = MiciLabel(value, font_size=COMPLICATION_SIZE, width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2), + font_weight=FontWeight.ROMAN, color=COMPLICATION_GREY, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True) + + self._load_images() + + # internal state + self._scroll_offset = 0 # in pixels + self._needs_scroll = measure_text_cached(self._label_font, text, self._get_label_font_size()).x + 25 > self._rect.width + self._scroll_timer = 0 + self._scroll_state = ScrollState.PRE_SCROLL + + def set_icon(self, icon: Union[str, rl.Texture]): + self._txt_icon = gui_app.texture(icon, 64, 64) if isinstance(icon, str) and len(icon) else icon + + def set_rotate_icon(self, rotate: bool): + if rotate and self._rotate_icon_t is not None: + return + self._rotate_icon_t = rl.get_time() if rotate else None + + def _load_images(self): + self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180) + self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180) + self._txt_disabled_bg = gui_app.texture("icons_mici/buttons/button_rectangle_disabled.png", 402, 180) + self._txt_hover_bg = gui_app.texture("icons_mici/buttons/button_rectangle_hover.png", 402, 180) + + def _get_label_font_size(self): + if len(self.text) < 12: + font_size = 64 + elif len(self.text) < 17: + font_size = 48 + elif len(self.text) < 20: + font_size = 42 + else: + font_size = 36 + + if self.value: + font_size -= 20 + + return font_size + + def set_text(self, text: str): + self.text = text + self._label.set_text(text) + + def set_value(self, value: str): + self.value = value + self._sub_label.set_text(value) + + def get_value(self) -> str: + return self.value + + def get_text(self): + return self.text + + def _update_state(self): + # hold on text for a bit, scroll, hold again, reset + if self._needs_scroll: + """`dt` should be seconds since last frame (rl.get_frame_time()).""" + # TODO: this comment is generated by GPT, prob wrong and misused + dt = rl.get_frame_time() + + self._scroll_timer += dt + if self._scroll_state == ScrollState.PRE_SCROLL: + if self._scroll_timer < 0.5: + return + self._scroll_state = ScrollState.SCROLLING + self._scroll_timer = 0 + + elif self._scroll_state == ScrollState.SCROLLING: + self._scroll_offset -= SCROLLING_SPEED_PX_S * dt + # reset when text has completely left the button + 50 px gap + # TODO: use global constant for 30+30 px gap + # TODO: add std Widget padding option integrated into the self._rect + full_len = measure_text_cached(self._label_font, self.text, self._get_label_font_size()).x + 30 + 30 + if self._scroll_offset < (self._rect.width - full_len): + self._scroll_state = ScrollState.POST_SCROLL + self._scroll_timer = 0 + + elif self._scroll_state == ScrollState.POST_SCROLL: + # wait for a bit before starting to scroll again + if self._scroll_timer < 0.75: + return + self._scroll_state = ScrollState.PRE_SCROLL + self._scroll_timer = 0 + self._scroll_offset = 0 + + def _render(self, _): + # draw _txt_default_bg + txt_bg = self._txt_default_bg + if not self.enabled: + txt_bg = self._txt_disabled_bg + elif self.is_pressed: + txt_bg = self._txt_hover_bg + + scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0) + btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2 + btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2 + rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE) + + # LABEL ------------------------------------------------------------------ + lx = self._rect.x + LABEL_HORIZONTAL_PADDING + ly = btn_y + self._rect.height - 33 # - 40# - self._get_label_font_size() / 2 + + if self.value: + self._sub_label.set_position(lx, ly) + ly -= self._sub_label.font_size + 9 + self._sub_label.render() + + label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35)) + self._label.set_color(label_color) + self._label.set_position(lx, ly) + self._label.render() + + # ICON ------------------------------------------------------------------- + if self._txt_icon: + rotation = 0 + if self._rotate_icon_t is not None: + rotation = (rl.get_time() - self._rotate_icon_t) * 180 + + # drop top right with 30px padding + x = self._rect.x + self._rect.width - 30 - self._txt_icon.width / 2 + y = self._rect.y + 30 + self._txt_icon.height / 2 + source_rec = rl.Rectangle(0, 0, self._txt_icon.width, self._txt_icon.height) + dest_rec = rl.Rectangle(int(x), int(y), self._txt_icon.width, self._txt_icon.height) + origin = rl.Vector2(self._txt_icon.width / 2, self._txt_icon.height / 2) + rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.WHITE) + + +class BigToggle(BigButton): + def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable = None): + super().__init__(text, value, "") + self._checked = initial_state + self._toggle_callback = toggle_callback + + self._label.set_font_size(48) + + def _load_images(self): + super()._load_images() + self._txt_enabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_enabled.png", 84, 66) + self._txt_disabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_disabled.png", 84, 66) + + def set_checked(self, checked: bool): + self._checked = checked + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + self._checked = not self._checked + if self._toggle_callback: + self._toggle_callback(self._checked) + + def _draw_pill(self, x: float, y: float, checked: bool): + # draw toggle icon top right + if checked: + rl.draw_texture(self._txt_enabled_toggle, int(x), int(y), rl.WHITE) + else: + rl.draw_texture(self._txt_disabled_toggle, int(x), int(y), rl.WHITE) + + def _render(self, _): + super()._render(_) + + x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width + y = self._rect.y + self._draw_pill(x, y, self._checked) + + +class BigMultiToggle(BigToggle): + def __init__(self, text: str, options: list[str], toggle_callback: Callable = None, + select_callback: Callable = None): + super().__init__(text, "", toggle_callback=toggle_callback) + assert len(options) > 0 + self._options = options + self._select_callback = select_callback + + self._label.set_width(int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)) + # TODO: why isn't this automatic? + self._label.set_font_size(self._get_label_font_size()) + + self.set_value(self._options[0]) + + def _get_label_font_size(self): + font_size = super()._get_label_font_size() + return font_size - 6 + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + cur_idx = self._options.index(self.value) + new_idx = (cur_idx + 1) % len(self._options) + self.set_value(self._options[new_idx]) + if self._select_callback: + self._select_callback(self.value) + + def _render(self, _): + BigButton._render(self, _) + + checked_idx = self._options.index(self.value) + + x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width + y = self._rect.y + + for i in range(len(self._options)): + self._draw_pill(x, y, checked_idx == i) + y += 35 + + +class BigMultiParamToggle(BigMultiToggle): + def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable = None, + select_callback: Callable = None): + super().__init__(text, options, toggle_callback, select_callback) + self._param = param + + self._params = Params() + self._load_value() + + def _load_value(self): + self.set_value(self._options[self._params.get(self._param) or 0]) + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + new_idx = self._options.index(self.value) + self._params.put_nonblocking(self._param, new_idx) + + +class BigParamControl(BigToggle): + def __init__(self, text: str, param: str, toggle_callback: Callable = None): + super().__init__(text, "", toggle_callback=toggle_callback) + self.param = param + self.params = Params() + self.set_checked(self.params.get_bool(self.param, False)) + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + self.params.put_bool(self.param, self._checked) + + def refresh(self): + self.set_checked(self.params.get_bool(self.param, False)) + + +# TODO: param control base class +class BigCircleParamControl(BigCircleToggle): + def __init__(self, icon: str, param: str, toggle_callback: Callable = None): + super().__init__(icon, toggle_callback) + self._param = param + self.params = Params() + self.set_checked(self.params.get_bool(self._param, False)) + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + self.params.put_bool(self._param, self._checked) + + def refresh(self): + self.set_checked(self.params.get_bool(self._param, False)) diff --git a/selfdrive/ui/mici/widgets/dialog.py b/selfdrive/ui/mici/widgets/dialog.py new file mode 100644 index 0000000000..d64ab65ef2 --- /dev/null +++ b/selfdrive/ui/mici/widgets/dialog.py @@ -0,0 +1,395 @@ +import abc +import math +import pyray as rl +from typing import Union +from collections.abc import Callable +from typing import cast +from openpilot.selfdrive.ui.mici.widgets.side_button import SideButton +from openpilot.system.ui.widgets import Widget, NavWidget, DialogResult +from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label +from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard +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.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.slider import RedBigSlider, BigSlider +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.ui.mici.widgets.button import BigButton + +DEBUG = False + +PADDING = 20 + + +class BigDialogBase(NavWidget, abc.ABC): + def __init__(self, right_btn: str | None = None, right_btn_callback: Callable | None = None): + super().__init__() + self._ret = DialogResult.NO_ACTION + self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + self.set_back_callback(lambda: setattr(self, '_ret', DialogResult.CANCEL)) + + self._right_btn = None + if right_btn: + def right_btn_callback_wrapper(): + gui_app.set_modal_overlay(None) + if right_btn_callback: + right_btn_callback() + + self._right_btn = SideButton(right_btn) + self._right_btn.set_click_callback(right_btn_callback_wrapper) + # move to right side + self._right_btn._rect.x = self._rect.x + self._rect.width - self._right_btn._rect.width + + def _render(self, _) -> DialogResult: + """ + Allows `gui_app.set_modal_overlay(BigDialog(...))`. + The overlay runner keeps calling until result != NO_ACTION. + """ + if self._right_btn: + self._right_btn.set_position(self._right_btn._rect.x, self._rect.y) + self._right_btn.render() + + return self._ret + + +class BigDialog(BigDialogBase): + def __init__(self, + title: str, + description: str, + right_btn: str | None = None, + right_btn_callback: Callable | None = None): + super().__init__(right_btn, right_btn_callback) + self._title = title + self._description = description + + def _render(self, _) -> DialogResult: + super()._render(_) + + # draw title + # TODO: we desperately need layouts + # TODO: coming up with these numbers manually is a pain and not scalable + # TODO: no clue what any of these numbers mean. VBox and HBox would remove all of this shite + max_width = self._rect.width - PADDING * 2 + if self._right_btn: + max_width -= self._right_btn._rect.width + + title_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.BOLD), self._title, 50, int(max_width))) + title_size = measure_text_cached(gui_app.font(FontWeight.BOLD), title_wrapped, 50) + text_x_offset = 0 + title_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING), + int(self._rect.y + PADDING), + int(max_width), + int(title_size.y)) + gui_label(title_rect, title_wrapped, 50, font_weight=FontWeight.BOLD, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + + # draw description + desc_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.MEDIUM), self._description, 30, int(max_width))) + desc_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), desc_wrapped, 30) + desc_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING), + int(self._rect.y + self._rect.height / 3), + int(max_width), + int(desc_size.y)) + # TODO: text align doesn't seem to work properly with newlines + gui_label(desc_rect, desc_wrapped, 30, font_weight=FontWeight.MEDIUM, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + + return self._ret + + +class BigConfirmationDialogV2(BigDialogBase): + def __init__(self, title: str, icon: str, red: bool = False, + exit_on_confirm: bool = True, + confirm_callback: Callable | None = None): + super().__init__() + self._confirm_callback = confirm_callback + self._exit_on_confirm = exit_on_confirm + + icon_txt = gui_app.texture(icon, 64, 53) + self._slider: BigSlider | RedBigSlider + if red: + self._slider = RedBigSlider(title, icon_txt, confirm_callback=self._on_confirm) + else: + self._slider = BigSlider(title, icon_txt, confirm_callback=self._on_confirm) + self._slider.set_enabled(lambda: not self._swiping_away) + + def _on_confirm(self): + if self._confirm_callback: + self._confirm_callback() + if self._exit_on_confirm: + self._ret = DialogResult.CONFIRM + + def _update_state(self): + super()._update_state() + if self._swiping_away and not self._slider.confirmed: + self._slider.reset() + + def _render(self, _) -> DialogResult: + self._slider.render(self._rect) + return self._ret + + +class BigInputDialog(BigDialogBase): + BACK_TOUCH_AREA_PERCENTAGE = 0.2 + BACKSPACE_RATE = 25 # hz + + def __init__(self, + hint: str, + default_text: str = "", + minimum_length: int = 1, + confirm_callback: Callable[[str], None] = None): + super().__init__(None, None) + self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)), + font_weight=FontWeight.MEDIUM) + self._keyboard = MiciKeyboard() + self._keyboard.set_text(default_text) + self._minimum_length = minimum_length + + self._backspace_held_time: float | None = None + + self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 44, 44) + self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) + + self._enter_img = gui_app.texture("icons_mici/settings/keyboard/confirm.png", 44, 44) + self._enter_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) + + # rects for top buttons + self._top_left_button_rect = rl.Rectangle(0, 0, 0, 0) + self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0) + + def confirm_callback_wrapper(): + self._ret = DialogResult.CONFIRM + if confirm_callback: + confirm_callback(self._keyboard.text()) + self._confirm_callback = confirm_callback_wrapper + + def _update_state(self): + super()._update_state() + + last_mouse_event = gui_app.last_mouse_event + if last_mouse_event.left_down and rl.check_collision_point_rec(last_mouse_event.pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 1: + if self._backspace_held_time is None: + self._backspace_held_time = rl.get_time() + + if rl.get_time() - self._backspace_held_time > 0.5: + if gui_app.frame % round(gui_app.target_fps / self.BACKSPACE_RATE) == 0: + self._keyboard.backspace() + + else: + self._backspace_held_time = None + + def _render(self, _): + text_input_size = 35 + + # draw current text so far below everything. text floats left but always stays in view + text = self._keyboard.text() + candidate_char = self._keyboard.get_candidate_character() + text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, text_input_size) + text_x = PADDING * 2 + self._enter_img.width + + # text needs to move left if we're at the end where right button is + text_rect = rl.Rectangle(text_x, + int(self._rect.y + PADDING), + # clip width to right button when in view + int(self._rect.width - text_x - PADDING * 2 - self._enter_img.width + 5), # TODO: why 5? + int(text_size.y)) + + # draw rounded background for text input + bg_block_margin = 5 + text_field_rect = rl.Rectangle(text_rect.x - bg_block_margin, text_rect.y - bg_block_margin, + text_rect.width + bg_block_margin * 2, text_input_size + bg_block_margin * 2) + + # draw text input + # push text left with a gradient on left side if too long + if text_size.x > text_rect.width: + text_x -= text_size.x - text_rect.width + + rl.begin_scissor_mode(int(text_rect.x), int(text_rect.y), int(text_rect.width), int(text_rect.height)) + rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_rect.y), text_input_size, 0, rl.WHITE) + + # draw grayed out character user is hovering over + if candidate_char: + candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), candidate_char, text_input_size) + rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), candidate_char, + rl.Vector2(min(text_x + text_size.x, text_rect.x + text_rect.width) - candidate_char_size.x, text_rect.y), + text_input_size, 0, rl.Color(255, 255, 255, 128)) + + rl.end_scissor_mode() + + # draw gradient on left side to indicate more text + if text_size.x > text_rect.width: + rl.draw_rectangle_gradient_h(int(text_rect.x), int(text_rect.y), 80, int(text_rect.height), + rl.BLACK, rl.BLANK) + + # draw cursor + if text: + blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2 + cursor_x = min(text_x + text_size.x + 3, text_rect.x + text_rect.width) + rl.draw_rectangle_rounded(rl.Rectangle(int(cursor_x), int(text_rect.y), 4, int(text_size.y)), + 1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha))) + + # draw backspace icon with nice fade + self._backspace_img_alpha.update(255 * bool(text)) + if self._backspace_img_alpha.x > 1: + color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x)) + rl.draw_texture(self._backspace_img, int(self._rect.width - self._enter_img.width - 15), int(text_field_rect.y), color) + + if not text and self._hint_label.text and not candidate_char: + # draw description if no text entered yet and not drawing candidate char + self._hint_label.render(text_field_rect) + + # TODO: move to update state + # make rect take up entire area so it's easier to click + self._top_left_button_rect = rl.Rectangle(self._rect.x, self._rect.y, text_field_rect.x, self._rect.height - self._keyboard.get_keyboard_height()) + self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y, + self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height) + + self._enter_img_alpha.update(255 if (len(text) >= self._minimum_length) else 255 * 0.35) + if self._enter_img_alpha.x > 1: + color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x)) + rl.draw_texture(self._enter_img, int(self._rect.x + 15), int(text_field_rect.y), color) + + # keyboard goes over everything + self._keyboard.render(self._rect) + + # draw debugging rect bounds + if DEBUG: + rl.draw_rectangle_lines_ex(text_field_rect, 1, rl.Color(100, 100, 100, 255)) + rl.draw_rectangle_lines_ex(text_rect, 1, rl.Color(0, 255, 0, 255)) + rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0, 255, 0, 255)) + rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0, 255, 0, 255)) + + return self._ret + + def _handle_mouse_press(self, mouse_pos: MousePos): + super()._handle_mouse_press(mouse_pos) + # TODO: need to track where press was so enter and back can activate on release rather than press + # or turn into icon widgets :eyes_open: + # handle backspace icon click + if rl.check_collision_point_rec(mouse_pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 254: + self._keyboard.backspace() + elif rl.check_collision_point_rec(mouse_pos, self._top_left_button_rect) and self._enter_img_alpha.x > 254: + # handle enter icon click + self._confirm_callback() + + +class BigDialogOptionButton(Widget): + def __init__(self, option: str): + super().__init__() + self.option = option + self.set_rect(rl.Rectangle(0, 0, int(gui_app.width / 2 + 220), 64)) + + self._selected = False + + self._label = UnifiedLabel(option, font_size=70, text_color=rl.Color(255, 255, 255, int(255 * 0.58)), + font_weight=FontWeight.DISPLAY_REGULAR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) + + def set_selected(self, selected: bool): + self._selected = selected + + def _render(self, _): + if DEBUG: + rl.draw_rectangle_lines_ex(self._rect, 1, rl.Color(0, 255, 0, 255)) + + # FIXME: offset x by -45 because scroller centers horizontally + if self._selected: + self._label.set_font_size(74) + self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9))) + self._label.set_font_weight(FontWeight.DISPLAY) + else: + self._label.set_font_size(70) + self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.58))) + self._label.set_font_weight(FontWeight.DISPLAY_REGULAR) + + self._label.render(self._rect) + + +class BigMultiOptionDialog(BigDialogBase): + BACK_TOUCH_AREA_PERCENTAGE = 0.1 + + def __init__(self, options: list[str], default: str | None, + right_btn: str | None = 'check', right_btn_callback: Callable[[], None] = None): + super().__init__(right_btn, right_btn_callback=right_btn_callback) + self._options = options + if default is not None: + assert default in options + + self._default_option: str = default or (options[0] if len(options) > 0 else "") + self._selected_option: str = self._default_option + self._last_selected_option: str = self._selected_option + + self._scroller = Scroller([], horizontal=False, pad_start=100, pad_end=100, spacing=0) + if self._right_btn is not None: + self._scroller.set_enabled(lambda: not cast(Widget, self._right_btn).is_pressed) + + for option in options: + self.add_button(BigDialogOptionButton(option)) + + def add_button(self, button: BigDialogOptionButton): + og_callback = button._click_callback + + def wrapped_callback(btn=button): + self._on_option_selected(btn.option) + if og_callback: + og_callback() + + button.set_click_callback(wrapped_callback) + self._scroller.add_widget(button) + + def show_event(self): + super().show_event() + self._scroller.show_event() + self._on_option_selected(self._default_option) + + def get_selected_option(self) -> str: + return self._selected_option + + def _on_option_selected(self, option: str): + y_pos = 0.0 + for btn in self._scroller._items: + if cast(BigDialogOptionButton, btn).option == option: + y_pos = btn.rect.y + + self._scroller.scroll_to(y_pos, smooth=True) + + def _selected_option_changed(self): + pass + + def _update_state(self): + super()._update_state() + + # get selection by whichever button is closest to center + center_y = self._rect.y + self._rect.height / 2 + closest_btn = (None, float('inf')) + for btn in self._scroller._items: + dist_y = abs((btn.rect.y + btn.rect.height / 2) - center_y) + if dist_y < closest_btn[1]: + closest_btn = (btn, dist_y) + + if closest_btn[0]: + for btn in self._scroller._items: + btn.set_selected(btn.option == closest_btn[0].option) + self._selected_option = closest_btn[0].option + + # Signal to subclasses if selection changed + if self._selected_option != self._last_selected_option: + self._selected_option_changed() + self._last_selected_option = self._selected_option + + def _render(self, _): + super()._render(_) + self._scroller.render(self._rect) + + return self._ret + + +class BigDialogButton(BigButton): + def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""): + super().__init__(text, value, icon) + self._description = description + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + + dlg = BigDialog(self.text, self._description) + gui_app.set_modal_overlay(dlg) diff --git a/selfdrive/ui/mici/widgets/pairing_dialog.py b/selfdrive/ui/mici/widgets/pairing_dialog.py new file mode 100644 index 0000000000..e064205d59 --- /dev/null +++ b/selfdrive/ui/mici/widgets/pairing_dialog.py @@ -0,0 +1,116 @@ +import pyray as rl +import qrcode +import numpy as np +import time + +from openpilot.common.api import Api +from openpilot.common.swaglog import cloudlog +from openpilot.common.params import Params +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.widgets import NavWidget +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.widgets.label import MiciLabel + + +class PairingDialog(NavWidget): + """Dialog for device pairing with QR code.""" + + QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds + + def __init__(self): + super().__init__() + self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) + self._params = Params() + self._qr_texture: rl.Texture | None = None + self._last_qr_generation = float("-inf") + + self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 84, 64) + self._pair_label = MiciLabel("pair with comma connect", 48, font_weight=FontWeight.BOLD, + color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True) + + def _get_pairing_url(self) -> str: + 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}") + token = "" + return f"https://connect.comma.ai/?pair={token}" + + def _generate_qr_code(self) -> None: + try: + qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0) + qr.add_data(self._get_pairing_url()) + qr.make(fit=True) + + pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA') + img_array = np.array(pil_img, dtype=np.uint8) + + if self._qr_texture and self._qr_texture.id != 0: + rl.unload_texture(self._qr_texture) + + rl_image = rl.Image() + rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data) + rl_image.width = pil_img.width + rl_image.height = pil_img.height + rl_image.mipmaps = 1 + 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}") + self._qr_texture = None + + def _check_qr_refresh(self) -> None: + current_time = time.monotonic() + if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL: + self._generate_qr_code() + self._last_qr_generation = current_time + + def _update_state(self): + super()._update_state() + if ui_state.prime_state.is_paired(): + self._playing_dismiss_animation = True + + def _render(self, rect: rl.Rectangle) -> int: + self._check_qr_refresh() + + self._render_qr_code() + + label_x = self._rect.x + 8 + self._rect.height + 24 + self._pair_label.set_width(int(self._rect.width - label_x)) + self._pair_label.set_position(label_x, self._rect.y + 16) + self._pair_label.render() + + rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16), + 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35))) + + return -1 + + def _render_qr_code(self) -> None: + if not self._qr_texture: + error_font = gui_app.font(FontWeight.BOLD) + rl.draw_text_ex( + error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED + ) + return + + scale = self._rect.height / self._qr_texture.height + pos = rl.Vector2(self._rect.x + 8, self._rect.y) + rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE) + + def __del__(self): + if self._qr_texture and self._qr_texture.id != 0: + rl.unload_texture(self._qr_texture) + + +if __name__ == "__main__": + gui_app.init_window("pairing device") + pairing = PairingDialog() + try: + for _ in gui_app.render(): + result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + if result != -1: + break + finally: + del pairing diff --git a/selfdrive/ui/mici/widgets/side_button.py b/selfdrive/ui/mici/widgets/side_button.py new file mode 100644 index 0000000000..4803b6d208 --- /dev/null +++ b/selfdrive/ui/mici/widgets/side_button.py @@ -0,0 +1,31 @@ +import pyray as rl +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.lib.application import gui_app + +# --------------------------------------------------------------------------- +# Constants extracted from the original Qt style +# --------------------------------------------------------------------------- +# TODO: this should be corrected, but Scroller relies on this being incorrect :/ +WIDTH, HEIGHT = 112, 240 + + +class SideButton(Widget): + def __init__(self, btn_type: str): + super().__init__() + self.type = btn_type + self.set_rect(rl.Rectangle(0, 0, WIDTH, HEIGHT)) + + # load pre-rendered button images + if btn_type not in ("check", "back"): + btn_type = "back" + btn_img_path = f"icons_mici/buttons/button_side_{btn_type}.png" + btn_img_pressed_path = f"icons_mici/buttons/button_side_{btn_type}_pressed.png" + self._txt_btn, self._txt_btn_back = gui_app.texture(btn_img_path, 100, 224), gui_app.texture(btn_img_pressed_path, 100, 224) + + def _render(self, _) -> bool: + x = int(self._rect.x + 12) + y = int(self._rect.y + (self._rect.height - self._txt_btn.height) / 2) + rl.draw_texture(self._txt_btn if not self.is_pressed else self._txt_btn_back, + x, y, rl.WHITE) + + return False diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py index 44116a2329..d88410ada3 100644 --- a/selfdrive/ui/soundd.py +++ b/selfdrive/ui/soundd.py @@ -12,6 +12,7 @@ from openpilot.common.utils import retry from openpilot.common.swaglog import cloudlog from openpilot.system import micd +from openpilot.system.hardware import HARDWARE SAMPLE_RATE = 48000 SAMPLE_BUFFER = 4096 # (approx 100ms) @@ -23,6 +24,10 @@ FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES) AMBIENT_DB = 30 # DB where MIN_VOLUME is applied DB_SCALE = 30 # AMBIENT_DB + DB_SCALE is where MAX_VOLUME is applied +VOLUME_BASE = 20 +if HARDWARE.get_device_type() == "tizi": + VOLUME_BASE = 10 + AudibleAlert = car.CarControl.HUDControl.AudibleAlert @@ -39,6 +44,11 @@ sound_list: dict[int, tuple[str, int | None, float]] = { AudibleAlert.warningSoft: ("warning_soft.wav", None, MAX_VOLUME), AudibleAlert.warningImmediate: ("warning_immediate.wav", None, MAX_VOLUME), } +if HARDWARE.get_device_type() == "tizi": + sound_list.update({ + AudibleAlert.engage: ("engage_tizi.wav", 1, MAX_VOLUME), + AudibleAlert.disengage: ("disengage_tizi.wav", 1, MAX_VOLUME), + }) def check_selfdrive_timeout_alert(sm): ss_missing = time.monotonic() - sm.recv_time['selfdriveState'] @@ -122,7 +132,7 @@ class Soundd: def calculate_volume(self, weighted_db): volume = ((weighted_db - AMBIENT_DB) / DB_SCALE) * (MAX_VOLUME - MIN_VOLUME) + MIN_VOLUME - return math.pow(10, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1)) + return math.pow(VOLUME_BASE, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1)) @retry(attempts=10, delay=3) def get_stream(self, sd): diff --git a/selfdrive/ui/tests/profile_onroad.py b/selfdrive/ui/tests/profile_onroad.py index 0294125ceb..b1fa4acc48 100755 --- a/selfdrive/ui/tests/profile_onroad.py +++ b/selfdrive/ui/tests/profile_onroad.py @@ -7,10 +7,9 @@ import numpy as np from msgq.visionipc import VisionIpcServer, VisionStreamType from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.selfdrive.ui.layouts.main import MainLayout +from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout from openpilot.system.ui.lib.application import gui_app from openpilot.tools.lib.logreader import LogReader -from openpilot.tools.plotjuggler.juggle import DEMO_ROUTE FPS = 60 @@ -56,7 +55,7 @@ def patch_submaster(message_chunks): if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description='Profile openpilot UI rendering and state updates') - parser.add_argument('route', type=str, nargs='?', default=DEMO_ROUTE + "/1", + parser.add_argument('route', type=str, nargs='?', default="302bab07c1511180/00000006--0b9a7005f1/3", help='Route to use for profiling') parser.add_argument('--loop', type=int, default=1, help='Number of times to loop the log (default: 1)') @@ -82,8 +81,8 @@ if __name__ == "__main__": if args.headless: os.environ['SDL_VIDEODRIVER'] = 'dummy' - gui_app.init_window("UI Profiling") - main_layout = MainLayout() + gui_app.init_window("UI Profiling", fps=600) + main_layout = MiciMainLayout() main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) print("Running...") diff --git a/selfdrive/ui/tests/test_ui/raylib_screenshots.py b/selfdrive/ui/tests/test_ui/raylib_screenshots.py index f36ad1badb..3d476c4319 100755 --- a/selfdrive/ui/tests/test_ui/raylib_screenshots.py +++ b/selfdrive/ui/tests/test_ui/raylib_screenshots.py @@ -246,6 +246,7 @@ CASES = { class TestUI: def __init__(self): os.environ["SCALE"] = os.getenv("SCALE", "1") + os.environ["BIG"] = "1" sys.modules["mouseinfo"] = False def setup(self): diff --git a/selfdrive/ui/ui.py b/selfdrive/ui/ui.py index a4b99825e9..7fe0dfbbc9 100755 --- a/selfdrive/ui/ui.py +++ b/selfdrive/ui/ui.py @@ -6,6 +6,7 @@ from openpilot.system.hardware import TICI from openpilot.common.realtime import config_realtime_process, set_core_affinity from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.main import MainLayout +from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout from openpilot.selfdrive.ui.ui_state import ui_state @@ -14,7 +15,10 @@ def main(): config_realtime_process(0, 51) gui_app.init_window("UI") - main_layout = MainLayout() + if gui_app.big_ui(): + main_layout = MainLayout() + else: + main_layout = MiciMainLayout() main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) for should_render in gui_app.render(): ui_state.update() diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index 8871e5de2c..4a6ff9ebd9 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -12,7 +12,7 @@ from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app from openpilot.system.hardware import HARDWARE, PC -BACKLIGHT_OFFROAD = 50 +BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50 class UIStatus(Enum): @@ -36,6 +36,7 @@ class UIState: [ "modelV2", "controlsState", + "onroadEvents", "liveCalibration", "radarState", "deviceState", @@ -49,6 +50,10 @@ class UIState: "managerState", "selfdriveState", "longitudinalPlan", + "gpsLocationExternal", + "carOutput", + "carControl", + "liveParameters", "rawAudioData", ] ) @@ -64,6 +69,8 @@ class UIState: # Core state variables self.is_metric: bool = self.params.get_bool("IsMetric") + self.is_release = self.params.get_bool("IsReleaseBranch") + self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM") self.started: bool = False self.ignition: bool = False self.recording_audio: bool = False @@ -133,6 +140,7 @@ class UIState: self.recording_audio = self.params.get_bool("RecordAudio") and self.started self.is_metric = self.params.get_bool("IsMetric") + self.always_on_dm = self.params.get_bool("AlwaysOnDM") def _update_status(self) -> None: if self.started and self.sm.updated["selfdriveState"]: @@ -181,16 +189,21 @@ class Device: self._interaction_time: float = -1 self._interactive_timeout_callbacks: list[Callable] = [] self._prev_timed_out = False - self._awake = False + self._awake: bool = True self._offroad_brightness: int = BACKLIGHT_OFFROAD self._last_brightness: int = 0 self._brightness_filter = FirstOrderFilter(BACKLIGHT_OFFROAD, 10.00, 1 / gui_app.target_fps) self._brightness_thread: threading.Thread | None = None + @property + def awake(self) -> bool: + return self._awake + def reset_interactive_timeout(self, timeout: int = -1) -> None: if timeout == -1: - timeout = 10 if ui_state.ignition else 30 + ignition_timeout = 10 if gui_app.big_ui() else 5 + timeout = ignition_timeout if ui_state.ignition else 30 self._interaction_time = time.monotonic() + timeout def add_interactive_timeout_callback(self, callback: Callable): @@ -220,7 +233,7 @@ class Device: else: clipped_brightness = ((clipped_brightness + 16.0) / 116.0) ** 3.0 - clipped_brightness = float(np.clip(100 * clipped_brightness, 10, 100)) + clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [30, 100])) brightness = round(self._brightness_filter.update(clipped_brightness)) if not self._awake: diff --git a/system/ui/README.md b/system/ui/README.md index 6e43c20d12..21c8ab974a 100644 --- a/system/ui/README.md +++ b/system/ui/README.md @@ -3,6 +3,7 @@ The user interfaces here are built with [raylib](https://www.raylib.com/). Quick start: +* set `BIG=1` to run the comma 3X UI (comma four UI runs by default) * set `SHOW_FPS=1` to show the FPS * set `STRICT_MODE=1` to kill the app if it drops too much below 60fps * set `SCALE=1.5` to scale the entire UI by 1.5x diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 16b924e442..e3370a5f74 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -27,6 +27,7 @@ MOUSE_THREAD_RATE = 140 # touch controller runs at 140Hz MAX_TOUCH_SLOTS = 2 TOUCH_HISTORY_TIMEOUT = 3.0 # Seconds before touch points fade out +BIG_UI = os.getenv("BIG", "0") == "1" ENABLE_VSYNC = os.getenv("ENABLE_VSYNC", "0") == "1" SHOW_FPS = os.getenv("SHOW_FPS") == "1" SHOW_TOUCHES = os.getenv("SHOW_TOUCHES") == "1" @@ -78,7 +79,7 @@ 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 +FONT_SCALE = 1.242 if BIG_UI else 1.16 ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets") FONT_DIR = ASSETS_DIR.joinpath("fonts") @@ -86,12 +87,17 @@ FONT_DIR = ASSETS_DIR.joinpath("fonts") class FontWeight(StrEnum): LIGHT = "Inter-Light.fnt" - NORMAL = "Inter-Regular.fnt" + NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt" MEDIUM = "Inter-Medium.fnt" - SEMI_BOLD = "Inter-SemiBold.fnt" BOLD = "Inter-Bold.fnt" + SEMI_BOLD = "Inter-SemiBold.fnt" UNIFONT = "unifont.fnt" + # Small UI fonts + DISPLAY_REGULAR = "Inter-Regular.fnt" + ROMAN = "Inter-Regular.fnt" + DISPLAY = "Inter-Bold.fnt" + def font_fallback(font: rl.Font) -> rl.Font: """Fall back to unifont for languages that require it.""" @@ -181,10 +187,10 @@ class MouseState: class GuiApplication: - def __init__(self, width: int, height: int): + def __init__(self, width: int | None = None, height: int | None = None): self._fonts: dict[FontWeight, rl.Font] = {} - self._width = width - self._height = height + self._width = width if width is not None else GuiApplication._default_width() + self._height = height if height is not None else GuiApplication._default_height() if PC and os.getenv("SCALE") is None: self._scale = self._calculate_auto_scale() @@ -654,5 +660,17 @@ class GuiApplication: # Apply 0.95 factor for window decorations/taskbar margin return max(0.3, min(w / self._width, h / self._height) * 0.95) + @staticmethod + def _default_width() -> int: + return 2160 if GuiApplication.big_ui() else 536 -gui_app = GuiApplication(2160, 1080) + @staticmethod + def _default_height() -> int: + return 1080 if GuiApplication.big_ui() else 240 + + @staticmethod + def big_ui() -> bool: + return HARDWARE.get_device_type() in ('tici', 'tizi') or BIG_UI + + +gui_app = GuiApplication() diff --git a/system/ui/lib/scroll_panel2.py b/system/ui/lib/scroll_panel2.py new file mode 100644 index 0000000000..8d9caadfdd --- /dev/null +++ b/system/ui/lib/scroll_panel2.py @@ -0,0 +1,219 @@ +import os +import math +import pyray as rl +from collections.abc import Callable +from enum import Enum +from typing import cast +from openpilot.system.ui.lib.application import gui_app, MouseEvent +from openpilot.system.hardware import TICI +from collections import deque + +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 +MIN_DRAG_PIXELS = 12 +AUTO_SCROLL_TC_SNAP = 0.025 +AUTO_SCROLL_TC = 0.18 +BOUNCE_RETURN_RATE = 10.0 +REJECT_DECELERATION_FACTOR = 3 +MAX_SPEED = 10000.0 # px/s + +DEBUG = os.getenv("DEBUG_SCROLL", "0") == "1" + + +# from https://ariya.io/2011/10/flick-list-with-its-momentum-scrolling-and-deceleration +class ScrollState(Enum): + STEADY = 0 + PRESSED = 1 + MANUAL_SCROLL = 2 + AUTO_SCROLL = 3 + + +class GuiScrollPanel2: + def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None: + self._horizontal = horizontal + self._handle_out_of_bounds = handle_out_of_bounds + self._AUTO_SCROLL_TC = AUTO_SCROLL_TC_SNAP if not self._handle_out_of_bounds else AUTO_SCROLL_TC + self._state = ScrollState.STEADY + self._offset: rl.Vector2 = rl.Vector2(0, 0) + self._initial_click_event: MouseEvent | None = None + self._previous_mouse_event: MouseEvent | None = None + self._velocity = 0.0 # pixels per second + self._velocity_buffer: deque[float] = deque(maxlen=12 if TICI else 6) + self._enabled: bool | Callable[[], bool] = True + + def set_enabled(self, enabled: bool | Callable[[], bool]) -> None: + self._enabled = enabled + + @property + def enabled(self) -> bool: + return self._enabled() if callable(self._enabled) else self._enabled + + def update(self, bounds: rl.Rectangle, content_size: float) -> float: + if DEBUG: + print('Old state:', self._state) + + bounds_size = bounds.width if self._horizontal else bounds.height + + for mouse_event in gui_app.mouse_events: + self._handle_mouse_event(mouse_event, bounds, bounds_size, content_size) + self._previous_mouse_event = mouse_event + + self._update_state(bounds_size, content_size) + + if DEBUG: + print('Velocity:', self._velocity) + print('Offset X:', self._offset.x, 'Y:', self._offset.y) + print('New state:', self._state) + print() + return self.get_offset() + + def _update_state(self, bounds_size: float, content_size: float) -> None: + """Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity.""" + if self._state == ScrollState.AUTO_SCROLL: + # simple exponential return if out of bounds + out_of_bounds = self.get_offset() > 0 or self.get_offset() < (bounds_size - content_size) + if out_of_bounds and self._handle_out_of_bounds: + if self.get_offset() < (bounds_size - content_size): # too far right + target = bounds_size - content_size + else: # too far left + target = 0.0 + + dt = rl.get_frame_time() or 1e-6 + factor = 1.0 - math.exp(-BOUNCE_RETURN_RATE * dt) + + dist = target - self.get_offset() + self.set_offset(self.get_offset() + dist * factor) # ease toward the edge + self._velocity *= (1.0 - factor) # damp any leftover fling + + # Steady once we are close enough to the target + if abs(dist) < 1 and abs(self._velocity) < MIN_VELOCITY: + self.set_offset(target) + self._state = ScrollState.STEADY + + elif abs(self._velocity) < MIN_VELOCITY: + self._velocity = 0.0 + self._state = ScrollState.STEADY + + # Update the offset based on the current velocity + dt = rl.get_frame_time() + self.set_offset(self.get_offset() + self._velocity * dt) # Adjust the offset based on velocity + alpha = 1 - (dt / (self._AUTO_SCROLL_TC + dt)) + self._velocity *= alpha + + def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float, + content_size: float) -> None: + out_of_bounds = self.get_offset() > 0 or self.get_offset() < (bounds_size - content_size) + if DEBUG: + print('Mouse event:', mouse_event) + + mouse_pos = self._get_mouse_pos(mouse_event) + + if not self.enabled: + # Reset state if not enabled + self._state = ScrollState.STEADY + self._velocity = 0.0 + self._velocity_buffer.clear() + + elif self._state == ScrollState.STEADY: + if rl.check_collision_point_rec(mouse_event.pos, bounds): + if mouse_event.left_pressed: + self._state = ScrollState.PRESSED + self._initial_click_event = mouse_event + + elif self._state == ScrollState.PRESSED: + initial_click_pos = self._get_mouse_pos(cast(MouseEvent, self._initial_click_event)) + diff = abs(mouse_pos - initial_click_pos) + if mouse_event.left_released: + # Special handling for down and up clicks across two frames + # TODO: not sure what that means or if it's accurate anymore + if out_of_bounds: + self._state = ScrollState.AUTO_SCROLL + elif diff <= MIN_DRAG_PIXELS: + self._state = ScrollState.STEADY + else: + self._state = ScrollState.MANUAL_SCROLL + elif diff > MIN_DRAG_PIXELS: + self._state = ScrollState.MANUAL_SCROLL + + elif self._state == ScrollState.MANUAL_SCROLL: + if mouse_event.left_released: + # Touch rejection: when releasing finger after swiping and stopping, panel + # reports a few erroneous touch events with high velocity, try to ignore. + + # If velocity decelerates very quickly, assume user doesn't intend to auto scroll + high_decel = False + if len(self._velocity_buffer) > 2: + # We limit max to first half since final few velocities can surpass first few + abs_velocity_buffer = [(abs(v), i) for i, v in enumerate(self._velocity_buffer)] + max_idx = max(abs_velocity_buffer[:len(abs_velocity_buffer) // 2])[1] + min_idx = min(abs_velocity_buffer)[1] + if DEBUG: + print('min_idx:', min_idx, 'max_idx:', max_idx, 'velocity buffer:', self._velocity_buffer) + if (abs(self._velocity_buffer[min_idx]) * REJECT_DECELERATION_FACTOR < abs(self._velocity_buffer[max_idx]) and + max_idx < min_idx): + if DEBUG: + print('deceleration too high, going to STEADY') + high_decel = True + + # If final velocity is below some threshold, switch to steady state too + low_speed = abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING * 1.5 # plus some margin + + if out_of_bounds or not (high_decel or low_speed): + self._state = ScrollState.AUTO_SCROLL + else: + # TODO: we should just set velocity and let autoscroll go back to steady. delays one frame but who cares + self._velocity = 0.0 + self._state = ScrollState.STEADY + self._velocity_buffer.clear() + else: + # Update velocity for when we release the mouse button. + # Do not update velocity on the same frame the mouse was released + previous_mouse_pos = self._get_mouse_pos(cast(MouseEvent, self._previous_mouse_event)) + delta_x = mouse_pos - previous_mouse_pos + self._velocity = delta_x / (mouse_event.t - cast(MouseEvent, self._previous_mouse_event).t) + self._velocity = max(-MAX_SPEED, min(MAX_SPEED, self._velocity)) + self._velocity_buffer.append(self._velocity) + + # rubber-banding: reduce dragging when out of bounds + # TODO: this drifts when dragging quickly + if out_of_bounds: + delta_x *= 0.25 + + # Update the offset based on the mouse movement + # Use internal _offset directly to preserve precision (don't round via get_offset()) + # TODO: make get_offset return float + current_offset = self._offset.x if self._horizontal else self._offset.y + self.set_offset(current_offset + delta_x) + + elif self._state == ScrollState.AUTO_SCROLL: + if mouse_event.left_pressed: + # Decide whether to click or scroll (block click if moving too fast) + if abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING: + # Traveling slow enough, click + self._state = ScrollState.PRESSED + self._initial_click_event = mouse_event + else: + # Go straight into manual scrolling to block erroneous input + self._state = ScrollState.MANUAL_SCROLL + # Reset velocity for touch down and up events that happen in back-to-back frames + self._velocity = 0.0 + + def _get_mouse_pos(self, mouse_event: MouseEvent) -> float: + return mouse_event.pos.x if self._horizontal else mouse_event.pos.y + + def get_offset(self) -> int: + return round(self._offset.x if self._horizontal else self._offset.y) + + def set_offset(self, value: float) -> None: + if self._horizontal: + self._offset.x = value + else: + self._offset.y = value + + @property + def state(self) -> ScrollState: + return self._state + + def is_touch_valid(self) -> bool: + # MIN_VELOCITY_FOR_CLICKING is checked in auto-scroll state + return bool(self._state != ScrollState.MANUAL_SCROLL) diff --git a/system/ui/lib/shader_polygon.py b/system/ui/lib/shader_polygon.py index 7be6638af1..94af35e157 100644 --- a/system/ui/lib/shader_polygon.py +++ b/system/ui/lib/shader_polygon.py @@ -191,7 +191,9 @@ def triangulate(pts: np.ndarray) -> list[tuple[float, float]]: # TODO: consider deduping close screenspace points # interleave points to produce a triangle strip - assert len(pts) % 2 == 0, "Interleaving expects even number of points" + # assert len(pts) % 2 == 0, "Interleaving expects even number of points" + if len(pts) % 2 != 0: + pts = pts[:-1] tri_strip = [] for i in range(len(pts) // 2): diff --git a/system/ui/lib/text_measure.py b/system/ui/lib/text_measure.py index 544ba5b870..dee4b419ff 100644 --- a/system/ui/lib/text_measure.py +++ b/system/ui/lib/text_measure.py @@ -5,9 +5,10 @@ from openpilot.system.ui.lib.emoji import find_emoji _cache: dict[int, rl.Vector2] = {} -def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: int = 0) -> rl.Vector2: +def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: float = 0) -> rl.Vector2: """Caches text measurements to avoid redundant calculations.""" font = font_fallback(font) + spacing = round(spacing, 4) key = hash((font.texture.id, text, font_size, spacing)) if key in _cache: return _cache[key] diff --git a/system/ui/lib/wifi_manager.py b/system/ui/lib/wifi_manager.py index 4cf2ccebc8..217ac5e89a 100644 --- a/system/ui/lib/wifi_manager.py +++ b/system/ui/lib/wifi_manager.py @@ -35,7 +35,7 @@ except Exception: TETHERING_IP_ADDRESS = "192.168.43.1" DEFAULT_TETHERING_PASSWORD = "swagswagcomma" SIGNAL_QUEUE_SIZE = 10 -SCAN_PERIOD_SECONDS = 10 +SCAN_PERIOD_SECONDS = 5 class SecurityType(IntEnum): @@ -75,6 +75,7 @@ class Network: is_connected: bool security_type: SecurityType is_saved: bool + ip_address: str = "" # TODO: implement @classmethod def from_dbus(cls, ssid: str, aps: list["AccessPoint"], is_saved: bool) -> "Network": @@ -627,7 +628,7 @@ class WifiManager: known_connections = self._get_connections() networks = [Network.from_dbus(ssid, ap_list, ssid in known_connections) for ssid, ap_list in aps.items()] - networks.sort(key=lambda n: (-n.is_connected, -n.strength, n.ssid.lower())) + networks.sort(key=lambda n: (-n.is_connected, n.ssid.lower())) self._networks = networks self._update_ipv4_address() diff --git a/system/ui/lib/wrap_text.py b/system/ui/lib/wrap_text.py index 745d37b468..3fabfbb66b 100644 --- a/system/ui/lib/wrap_text.py +++ b/system/ui/lib/wrap_text.py @@ -3,7 +3,7 @@ from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.application import font_fallback -def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -> list[str]: +def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]: if not word: return [] @@ -11,7 +11,7 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) - remaining = word while remaining: - if measure_text_cached(font, remaining, font_size).x <= max_width: + if measure_text_cached(font, remaining, font_size, spacing).x <= max_width: parts.append(remaining) break @@ -22,7 +22,7 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) - while left <= right: mid = (left + right) // 2 substring = remaining[:mid] - width = measure_text_cached(font, substring, font_size).x + width = measure_text_cached(font, substring, font_size, spacing).x if width <= max_width: best_fit = mid @@ -40,9 +40,10 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) - _cache: dict[int, list[str]] = {} -def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[str]: +def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]: font = font_fallback(font) - key = hash((font.texture.id, text, font_size, max_width)) + spacing = round(spacing, 4) + key = hash((font.texture.id, text, font_size, max_width, spacing)) if key in _cache: return _cache[key] @@ -69,7 +70,7 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[ current_line: list[str] = [] for word in words: - word_width = int(measure_text_cached(font, word, font_size).x) + word_width = measure_text_cached(font, word, font_size, spacing).x # Check if word alone exceeds max width (need to break the word) if word_width > max_width: @@ -79,12 +80,12 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[ current_line = [] # Break the long word into parts - lines.extend(_break_long_word(font, word, font_size, max_width)) + lines.extend(_break_long_word(font, word, font_size, max_width, spacing)) continue # Measure the actual joined string to get accurate width (accounts for kerning, etc.) test_line = " ".join(current_line + [word]) if current_line else word - test_width = int(measure_text_cached(font, test_line, font_size).x) + test_width = measure_text_cached(font, test_line, font_size, spacing).x # Check if word fits on current line if test_width <= max_width: diff --git a/system/ui/mici_reset.py b/system/ui/mici_reset.py new file mode 100755 index 0000000000..d9bb45d99a --- /dev/null +++ b/system/ui/mici_reset.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +import os +import sys +import threading +import time +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.widgets import Widget +from openpilot.system.ui.widgets.slider import SmallSlider +from openpilot.system.ui.widgets.button import SmallButton, FullRoundedButton +from openpilot.system.ui.widgets.label import gui_label, gui_text_box + +USERDATA = "/dev/disk/by-partlabel/userdata" +TIMEOUT = 3*60 + + +class ResetMode(IntEnum): + USER_RESET = 0 # user initiated a factory reset from openpilot + RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover + FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata + + +class ResetState(IntEnum): + NONE = 0 + RESETTING = 1 + FAILED = 2 + + +class Reset(Widget): + def __init__(self, mode): + super().__init__() + self._mode = mode + self._previous_reset_state = None + self._reset_state = ResetState.NONE + + self._cancel_button = SmallButton("cancel") + self._cancel_button.set_click_callback(self._cancel_callback) + + self._reboot_button = FullRoundedButton("reboot") + self._reboot_button.set_click_callback(self._do_reboot) + + self._confirm_slider = SmallSlider("reset", self._confirm) + + self._render_status = True + + def _cancel_callback(self): + self._render_status = False + + def _do_reboot(self): + if PC: + return + + os.system("sudo reboot") + + def _do_erase(self): + if PC: + return + + # Removing data and formatting + rm = os.system("sudo rm -rf /data/*") + os.system(f"sudo umount {USERDATA}") + fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}") + + if rm == 0 or fmt == 0: + os.system("sudo reboot") + else: + self._reset_state = ResetState.FAILED + + def start_reset(self): + self._reset_state = ResetState.RESETTING + threading.Timer(0.1, self._do_erase).start() + + def _update_state(self): + if self._reset_state != self._previous_reset_state: + self._previous_reset_state = self._reset_state + self._timeout_st = time.monotonic() + elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT: + exit(0) + + def _render(self, rect: rl.Rectangle): + label_rect = rl.Rectangle(rect.x + 8, rect.y + 8, rect.width, 50) + gui_label(label_rect, "factory reset", 48, font_weight=FontWeight.BOLD, + color=rl.Color(255, 255, 255, int(255 * 0.9))) + + text_rect = rl.Rectangle(rect.x + 8, rect.y + 56, rect.width - 8 * 2, rect.height - 80) + gui_text_box(text_rect, self._get_body_text(), 36, font_weight=FontWeight.ROMAN, line_scale=0.9) + + if self._reset_state != ResetState.RESETTING: + # fade out cancel button as slider is moved, set visible to prevent pressing invisible cancel + self._cancel_button.set_opacity(1.0 - self._confirm_slider.slider_percentage) + self._cancel_button.set_visible(self._confirm_slider.slider_percentage < 0.8) + + if self._mode == ResetMode.RECOVER: + self._cancel_button.set_text("reboot") + self._cancel_button.render(rl.Rectangle( + rect.x + 8, + rect.y + rect.height - self._cancel_button.rect.height, + self._cancel_button.rect.width, + self._cancel_button.rect.height)) + elif self._mode == ResetMode.USER_RESET and self._reset_state != ResetState.FAILED: + self._cancel_button.render(rl.Rectangle( + rect.x + 8, + rect.y + rect.height - self._cancel_button.rect.height, + self._cancel_button.rect.width, + self._cancel_button.rect.height)) + + if self._reset_state != ResetState.FAILED: + self._confirm_slider.render(rl.Rectangle( + rect.x + rect.width - self._confirm_slider.rect.width, + rect.y + rect.height - self._confirm_slider.rect.height, + self._confirm_slider.rect.width, + self._confirm_slider.rect.height)) + else: + self._reboot_button.render(rl.Rectangle( + rect.x + 8, + rect.y + rect.height - self._reboot_button.rect.height, + self._reboot_button.rect.width, + self._reboot_button.rect.height)) + + return self._render_status + + def _confirm(self): + self.start_reset() + + def _get_body_text(self): + if self._reset_state == ResetState.RESETTING: + return "Resetting device... This may take up to a minute." + if self._reset_state == ResetState.FAILED: + return "Reset failed. Reboot to try again." + if self._mode == ResetMode.RECOVER: + return "Unable to mount data partition. Partition may be corrupted." + return "All content and settings will be erased." + + +def main(): + mode = ResetMode.USER_RESET + if len(sys.argv) > 1: + if sys.argv[1] == '--recover': + mode = ResetMode.RECOVER + elif sys.argv[1] == "--format": + mode = ResetMode.FORMAT + + gui_app.init_window("System Reset") + reset = Reset(mode) + + if mode == ResetMode.FORMAT: + reset.start_reset() + + for should_render in gui_app.render(): + if should_render: + if not reset.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)): + break + + +if __name__ == "__main__": + main() diff --git a/system/ui/mici_setup.py b/system/ui/mici_setup.py new file mode 100755 index 0000000000..4afd69bfa7 --- /dev/null +++ b/system/ui/mici_setup.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +from abc import abstractmethod +import os +import re +import threading +import time +import urllib.request +import urllib.error +from urllib.parse import urlparse +from enum import IntEnum +import shutil +from collections.abc import Callable + +import pyray as rl + +from cereal import log +from openpilot.common.utils import run_cmd +from openpilot.system.hardware import HARDWARE +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.wifi_manager import WifiManager +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 +from openpilot.system.ui.widgets import Widget, DialogResult +from openpilot.system.ui.widgets.button import (IconButton, SmallButton, WideRoundedButton, SmallerRoundedButton, + SmallCircleIconButton, WidishRoundedButton, SmallRedPillButton, + FullRoundedButton) +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.widgets.slider import LargerSlider +from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiUIMici +from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog + +NetworkType = log.DeviceState.NetworkType + +OPENPILOT_URL = "https://openpilot.comma.ai" +USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}" + +CONTINUE_PATH = "/data/continue.sh" +TMP_CONTINUE_PATH = "/data/continue.sh.new" +INSTALL_PATH = "/data/openpilot" +VALID_CACHE_PATH = "/data/.openpilot_cache" +INSTALLER_SOURCE_PATH = "/usr/comma/installer" +INSTALLER_DESTINATION_PATH = "/tmp/installer" +INSTALLER_URL_PATH = "/tmp/installer_url" + +CONTINUE = """#!/usr/bin/env bash + +cd /data/openpilot +exec ./launch_openpilot.sh +""" + + +class NetworkConnectivityMonitor: + def __init__(self, should_check: Callable[[], bool] | None = None, check_interval: float = 0.5): + self.network_connected = threading.Event() + self.wifi_connected = threading.Event() + self._should_check = should_check or (lambda: True) + self._check_interval = check_interval + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + + def start(self): + self._stop_event.clear() + if self._thread is None or not self._thread.is_alive(): + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self): + if self._thread is not None: + self._stop_event.set() + self._thread.join() + self._thread = None + + def reset(self): + self.network_connected.clear() + self.wifi_connected.clear() + + def _run(self): + while not self._stop_event.is_set(): + if self._should_check(): + try: + request = urllib.request.Request(OPENPILOT_URL, method="HEAD") + urllib.request.urlopen(request, timeout=0.5) + self.network_connected.set() + if HARDWARE.get_network_type() == NetworkType.wifi: + self.wifi_connected.set() + except Exception: + self.reset() + else: + self.reset() + + if self._stop_event.wait(timeout=self._check_interval): + break + + +class SetupState(IntEnum): + GETTING_STARTED = 0 + NETWORK_SETUP = 1 + NETWORK_SETUP_CUSTOM_SOFTWARE = 8 + SOFTWARE_SELECTION = 2 + CUSTOM_SOFTWARE = 3 + DOWNLOADING = 4 + DOWNLOAD_FAILED = 5 + CUSTOM_SOFTWARE_WARNING = 6 + + +class StartPage(Widget): + def __init__(self): + super().__init__() + + self._title = UnifiedLabel("start", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), + font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + self._start_bg_txt = gui_app.texture("icons_mici/setup/green_button.png", 520, 224) + self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/green_button_pressed.png", 520, 224) + + def _render(self, rect: rl.Rectangle): + draw_x = rect.x + (rect.width - self._start_bg_txt.width) / 2 + draw_y = rect.y + (rect.height - self._start_bg_txt.height) / 2 + texture = self._start_bg_pressed_txt if self.is_pressed else self._start_bg_txt + rl.draw_texture(texture, int(draw_x), int(draw_y), rl.WHITE) + + self._title.render(rect) + + +class SoftwareSelectionPage(Widget): + def __init__(self, use_openpilot_callback: Callable, + use_custom_software_callback: Callable): + super().__init__() + + self._openpilot_slider = LargerSlider("slide to use\nopenpilot", use_openpilot_callback) + self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, green=False) + + def reset(self): + self._openpilot_slider.reset() + self._custom_software_slider.reset() + + def _render(self, rect: rl.Rectangle): + self._openpilot_slider.set_opacity(1.0 - self._custom_software_slider.slider_percentage) + self._custom_software_slider.set_opacity(1.0 - self._openpilot_slider.slider_percentage) + + openpilot_rect = rl.Rectangle( + rect.x + (rect.width - self._openpilot_slider.rect.width) / 2, + rect.y, + self._openpilot_slider.rect.width, + rect.height / 2, + ) + self._openpilot_slider.render(openpilot_rect) + + custom_software_rect = rl.Rectangle( + rect.x + (rect.width - self._custom_software_slider.rect.width) / 2, + rect.y + rect.height / 2, + self._custom_software_slider.rect.width, + rect.height / 2, + ) + self._custom_software_slider.render(custom_software_rect) + + +class TermsHeader(Widget): + def __init__(self, text: str, icon_texture: rl.Texture): + super().__init__() + + self._title = UnifiedLabel(text, 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), + font_weight=FontWeight.BOLD, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + line_height=0.8) + self._icon_texture = icon_texture + + self.set_rect(rl.Rectangle(0, 0, gui_app.width - 16 * 2, self._icon_texture.height)) + + def set_title(self, text: str): + self._title.set_text(text) + + def set_icon(self, icon_texture: rl.Texture): + self._icon_texture = icon_texture + + def _render(self, _): + rl.draw_texture_ex(self._icon_texture, rl.Vector2(self._rect.x, self._rect.y), + 0.0, 1.0, rl.WHITE) + + # May expand outside parent rect + title_content_height = self._title.get_content_height(int(self._rect.width - self._icon_texture.width - 16)) + title_rect = rl.Rectangle( + self._rect.x + self._icon_texture.width + 16, + self._rect.y + (self._rect.height - title_content_height) / 2, + self._rect.width - self._icon_texture.width - 16, + title_content_height, + ) + self._title.render(title_rect) + + +class TermsPage(Widget): + ITEM_SPACING = 20 + + def __init__(self, continue_callback: Callable, back_callback: Callable | None = None, + back_text: str = "back", continue_text: str = "accept"): + super().__init__() + + # TODO: use Scroller + self._scroll_panel = GuiScrollPanel2(horizontal=False) + + self._continue_text = continue_text + self._continue_button: WideRoundedButton | FullRoundedButton + if back_callback is not None: + self._continue_button = WideRoundedButton(continue_text) + else: + self._continue_button = FullRoundedButton(continue_text) + self._continue_button.set_enabled(False) + self._continue_button.set_opacity(0.0) + self._continue_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid) + self._continue_button.set_click_callback(continue_callback) + + self._enable_back = back_callback is not None + self._back_button = SmallButton(back_text) + self._back_button.set_opacity(0.0) + self._back_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid) + self._back_button.set_click_callback(back_callback) + + self._scroll_down_indicator = IconButton(gui_app.texture("icons_mici/setup/scroll_down_indicator.png", 64, 78)) + self._scroll_down_indicator.set_enabled(False) + + def reset(self): + self._scroll_panel.set_offset(0) + self._continue_button.set_enabled(False) + self._continue_button.set_opacity(0.0) + self._back_button.set_enabled(False) + self._back_button.set_opacity(0.0) + self._scroll_down_indicator.set_opacity(1.0) + + @property + @abstractmethod + def _content_height(self): + pass + + @property + def _scrolled_down_offset(self): + return -self._content_height + (self._continue_button.rect.height + 16 + 30) + + @abstractmethod + def _render_content(self, scroll_offset): + pass + + def _render(self, _): + scroll_offset = self._scroll_panel.update(self._rect, self._content_height + self._continue_button.rect.height + 16) + + if scroll_offset <= self._scrolled_down_offset: + # don't show back if not enabled + if self._enable_back: + self._back_button.set_enabled(True) + self._back_button.set_opacity(1.0, smooth=True) + self._continue_button.set_enabled(True) + self._continue_button.set_opacity(1.0, smooth=True) + self._scroll_down_indicator.set_opacity(0.0, smooth=True) + else: + self._back_button.set_enabled(False) + self._back_button.set_opacity(0.0, smooth=True) + self._continue_button.set_enabled(False) + self._continue_button.set_opacity(0.0, smooth=True) + self._scroll_down_indicator.set_opacity(1.0, smooth=True) + + # Render content + self._render_content(scroll_offset) + + # black gradient at top and bottom for scrolling content + rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y), + int(self._rect.width), 20, rl.BLACK, rl.BLANK) + rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - 20), + int(self._rect.width), 20, rl.BLANK, rl.BLACK) + + self._back_button.render(rl.Rectangle( + self._rect.x + 8, + self._rect.y + self._rect.height - self._back_button.rect.height, + self._back_button.rect.width, + self._back_button.rect.height, + )) + + continue_x = self._rect.x + 8 + if self._enable_back: + continue_x = self._rect.x + self._rect.width - self._continue_button.rect.width - 8 + self._continue_button.render(rl.Rectangle( + continue_x, + self._rect.y + self._rect.height - self._continue_button.rect.height, + self._continue_button.rect.width, + self._continue_button.rect.height, + )) + + self._scroll_down_indicator.render(rl.Rectangle( + self._rect.x + self._rect.width - self._scroll_down_indicator.rect.width - 8, + self._rect.y + self._rect.height - self._scroll_down_indicator.rect.height - 8, + self._scroll_down_indicator.rect.width, + self._scroll_down_indicator.rect.height, + )) + + +class CustomSoftwareWarningPage(TermsPage): + def __init__(self, continue_callback: Callable, back_callback: Callable): + super().__init__(continue_callback, back_callback) + + self._title_header = TermsHeader("use caution installing\n3rd party software", + gui_app.texture("icons_mici/setup/warning.png", 66, 60)) + self._body = UnifiedLabel("• It has not been tested by comma.\n" + + "• It may not comply with relevant safety standards.\n" + + "• It may cause damage to your device and/or vehicle.\n", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), + font_weight=FontWeight.ROMAN) + + self._restore_header = TermsHeader("how to backup &\nrestore", gui_app.texture("icons_mici/setup/restore.png", 60, 60)) + self._restore_body = UnifiedLabel("To restore your device to a factory state later, use https://flash.comma.ai", + 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), + font_weight=FontWeight.ROMAN) + + @property + def _content_height(self): + return self._restore_body.rect.y + self._restore_body.rect.height - self._scroll_panel.get_offset() + + def _render_content(self, scroll_offset): + self._title_header.set_position(self._rect.x + 16, self._rect.y + 8 + scroll_offset) + self._title_header.render() + + body_rect = rl.Rectangle( + self._rect.x + 8, + self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING, + self._rect.width - 50, + self._body.get_content_height(int(self._rect.width - 50)), + ) + self._body.render(body_rect) + + self._restore_header.set_position(self._rect.x + 16, self._body.rect.y + self._body.rect.height + self.ITEM_SPACING) + self._restore_header.render() + + self._restore_body.render(rl.Rectangle( + self._rect.x + 8, + self._restore_header.rect.y + self._restore_header.rect.height + self.ITEM_SPACING, + self._rect.width - 50, + self._restore_body.get_content_height(int(self._rect.width - 50)), + )) + + +class DownloadingPage(Widget): + def __init__(self): + super().__init__() + + self._title_label = UnifiedLabel("downloading", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), + font_weight=FontWeight.DISPLAY) + self._progress_label = UnifiedLabel("", 128, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35)), + font_weight=FontWeight.ROMAN, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + self._progress = 0 + + def set_progress(self, progress: int): + self._progress = progress + self._progress_label.set_text(f"{progress}%") + + def _render(self, rect: rl.Rectangle): + self._title_label.render(rl.Rectangle( + rect.x + 20, + rect.y + 10, + rect.width, + 64, + )) + + self._progress_label.render(rl.Rectangle( + rect.x + 20, + rect.y + 20, + rect.width, + rect.height, + )) + + +class FailedPage(Widget): + def __init__(self, reboot_callback: Callable, retry_callback: Callable, title: str = "download failed"): + super().__init__() + + self._title_label = UnifiedLabel(title, 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), + font_weight=FontWeight.DISPLAY) + self._reason_label = UnifiedLabel("", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)), + font_weight=FontWeight.ROMAN) + + self._reboot_button = SmallRedPillButton("reboot") + self._reboot_button.set_click_callback(reboot_callback) + + self._retry_button = WideRoundedButton("retry") + self._retry_button.set_click_callback(retry_callback) + + def set_reason(self, reason: str): + self._reason_label.set_text(reason) + + def _render(self, rect: rl.Rectangle): + self._title_label.render(rl.Rectangle( + rect.x + 8, + rect.y + 10, + rect.width, + 64, + )) + + self._reason_label.render(rl.Rectangle( + rect.x + 8, + rect.y + 10 + 64, + rect.width, + 36, + )) + + self._reboot_button.render(rl.Rectangle( + rect.x + 8, + rect.y + rect.height - self._reboot_button.rect.height, + self._reboot_button.rect.width, + self._reboot_button.rect.height, + )) + + self._retry_button.render(rl.Rectangle( + rect.x + 8 + self._reboot_button.rect.width + 8, + rect.y + rect.height - self._retry_button.rect.height, + self._retry_button.rect.width, + self._retry_button.rect.height, + )) + + +class NetworkSetupState(IntEnum): + MAIN = 0 + WIFI_PANEL = 1 + + +class NetworkSetupPage(Widget): + def __init__(self, wifi_manager, continue_callback: Callable, back_callback: Callable): + super().__init__() + self._wifi_ui = WifiUIMici(wifi_manager, back_callback=lambda: self.set_state(NetworkSetupState.MAIN)) + + self._no_wifi_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 58, 50) + self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 58, 50) + self._waiting_text = "waiting for internet..." + self._network_header = TermsHeader(self._waiting_text, self._no_wifi_txt) + + back_txt = gui_app.texture("icons_mici/setup/back_new.png", 37, 32) + self._back_button = SmallCircleIconButton(back_txt) + self._back_button.set_click_callback(back_callback) + + self._wifi_button = SmallerRoundedButton("wifi") + self._wifi_button.set_click_callback(lambda: self.set_state(NetworkSetupState.WIFI_PANEL)) + + self._continue_button = WidishRoundedButton("continue") + self._continue_button.set_enabled(False) + self._continue_button.set_click_callback(continue_callback) + + self._state = NetworkSetupState.MAIN + + def set_state(self, state: NetworkSetupState): + self._state = state + + def set_has_internet(self, has_internet: bool): + if has_internet: + self._network_header.set_title("connected to internet") + self._network_header.set_icon(self._wifi_full_txt) + self._continue_button.set_enabled(True) + else: + self._network_header.set_title(self._waiting_text) + self._network_header.set_icon(self._no_wifi_txt) + self._continue_button.set_enabled(False) + + def show_event(self): + super().show_event() + self._state = NetworkSetupState.MAIN + self._wifi_ui.show_event() + + def hide_event(self): + super().hide_event() + self._wifi_ui.hide_event() + + def _render(self, _): + if self._state == NetworkSetupState.MAIN: + self._network_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16, + self._rect.width - 32, + self._network_header.rect.height, + )) + + self._back_button.render(rl.Rectangle( + self._rect.x + 8, + self._rect.y + self._rect.height - self._back_button.rect.height, + self._back_button.rect.width, + self._back_button.rect.height, + )) + + self._wifi_button.render(rl.Rectangle( + self._rect.x + 8 + self._back_button.rect.width + 10, + self._rect.y + self._rect.height - self._wifi_button.rect.height, + self._wifi_button.rect.width, + self._wifi_button.rect.height, + )) + + self._continue_button.render(rl.Rectangle( + self._rect.x + self._rect.width - self._continue_button.rect.width - 8, + self._rect.y + self._rect.height - self._continue_button.rect.height, + self._continue_button.rect.width, + self._continue_button.rect.height, + )) + else: + self._wifi_ui.render(self._rect) + + +class Setup(Widget): + def __init__(self): + super().__init__() + self.state = SetupState.GETTING_STARTED + self.failed_url = "" + self.failed_reason = "" + self.download_url = "" + self.download_progress = 0 + self.download_thread = None + self._wifi_manager = WifiManager() + self._wifi_manager.set_active(True) + self._network_monitor = NetworkConnectivityMonitor( + lambda: self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE) + ) + + self._start_page = StartPage() + self._start_page.set_click_callback(self._getting_started_button_callback) + + self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_button_callback, + self._network_setup_back_button_callback) + + self._software_selection_page = SoftwareSelectionPage(self._software_selection_continue_button_callback, + self._software_selection_custom_software_button_callback) + + self._download_failed_page = FailedPage(HARDWARE.reboot, self._download_failed_startover_button_callback) + + self._custom_software_warning_page = CustomSoftwareWarningPage(self._software_selection_custom_software_continue, + self._custom_software_warning_back_button_callback) + + self._downloading_page = DownloadingPage() + + def _update_state(self): + self._wifi_manager.process_callbacks() + + def _set_state(self, state: SetupState): + self.state = state + if self.state == SetupState.SOFTWARE_SELECTION: + self._software_selection_page.reset() + elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING: + self._custom_software_warning_page.reset() + + if self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE): + self._network_setup_page.show_event() + self._network_monitor.reset() + self._network_monitor.start() + else: + self._network_setup_page.hide_event() + self._network_monitor.stop() + + def _render(self, rect: rl.Rectangle): + if self.state == SetupState.GETTING_STARTED: + self._start_page.render(rect) + elif self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE): + self.render_network_setup(rect) + elif self.state == SetupState.SOFTWARE_SELECTION: + self._software_selection_page.render(rect) + elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING: + self._custom_software_warning_page.render(rect) + elif self.state == SetupState.CUSTOM_SOFTWARE: + self.render_custom_software() + elif self.state == SetupState.DOWNLOADING: + self.render_downloading(rect) + elif self.state == SetupState.DOWNLOAD_FAILED: + self._download_failed_page.render(rect) + + def _custom_software_warning_back_button_callback(self): + self._set_state(SetupState.SOFTWARE_SELECTION) + + def _custom_software_warning_continue_button_callback(self): + self._set_state(SetupState.CUSTOM_SOFTWARE) + + def _getting_started_button_callback(self): + self._set_state(SetupState.SOFTWARE_SELECTION) + + def _software_selection_back_button_callback(self): + self._set_state(SetupState.GETTING_STARTED) + + def _software_selection_continue_button_callback(self): + self.use_openpilot() + + def _software_selection_custom_software_button_callback(self): + self._set_state(SetupState.CUSTOM_SOFTWARE_WARNING) + + def _software_selection_custom_software_continue(self): + self._set_state(SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE) + + def _download_failed_startover_button_callback(self): + self._set_state(SetupState.GETTING_STARTED) + + def _network_setup_back_button_callback(self): + self._set_state(SetupState.SOFTWARE_SELECTION) + + def _network_setup_continue_button_callback(self): + self._network_monitor.stop() + if self.state == SetupState.NETWORK_SETUP: + self.download(OPENPILOT_URL) + elif self.state == SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE: + self._set_state(SetupState.CUSTOM_SOFTWARE) + + def close(self): + self._network_monitor.stop() + + def render_network_setup(self, rect: rl.Rectangle): + self._network_setup_page.render(rect) + self._network_setup_page.set_has_internet(self._network_monitor.network_connected.is_set()) + + def render_downloading(self, rect: rl.Rectangle): + self._downloading_page.set_progress(self.download_progress) + self._downloading_page.render(rect) + + def render_custom_software(self): + def handle_keyboard_result(text): + url = text.strip() + if url: + self.download(url) + + def handle_keyboard_exit(result): + if result == DialogResult.CANCEL: + self._set_state(SetupState.SOFTWARE_SELECTION) + + keyboard = BigInputDialog("custom software URL", confirm_callback=handle_keyboard_result) + gui_app.set_modal_overlay(keyboard, callback=handle_keyboard_exit) + + def use_openpilot(self): + if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH): + os.remove(VALID_CACHE_PATH) + with open(TMP_CONTINUE_PATH, "w") as f: + f.write(CONTINUE) + run_cmd(["chmod", "+x", TMP_CONTINUE_PATH]) + shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH) + shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH) + + # give time for installer UI to take over + time.sleep(0.1) + gui_app.request_close() + else: + self._set_state(SetupState.NETWORK_SETUP) + + def download(self, url: str): + # autocomplete incomplete URLs + if re.match("^([^/.]+)/([^/]+)$", url): + url = f"https://installer.comma.ai/{url}" + + parsed = urlparse(url, scheme='https') + self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl() + + self._set_state(SetupState.DOWNLOADING) + + self.download_thread = threading.Thread(target=self._download_thread, daemon=True) + self.download_thread.start() + + def _download_thread(self): + try: + import tempfile + + fd, tmpfile = tempfile.mkstemp(prefix="installer_") + + 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: + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 + block_size = 8192 + + while True: + buffer = response.read(block_size) + if not buffer: + break + + downloaded += len(buffer) + f.write(buffer) + + if total_size: + self.download_progress = int(downloaded * 100 / total_size) + self._downloading_page.set_progress(self.download_progress) + + is_elf = False + with open(tmpfile, 'rb') as f: + header = f.read(4) + is_elf = header == b'\x7fELF' + + if not is_elf: + self.download_failed(self.download_url, "No custom software found at this URL.") + return + + # AGNOS might try to execute the installer before this process exits. + # Therefore, important to close the fd before renaming the installer. + os.close(fd) + os.rename(tmpfile, INSTALLER_DESTINATION_PATH) + + with open(INSTALLER_URL_PATH, "w") as f: + f.write(self.download_url) + + # give time for installer UI to take over + time.sleep(0.1) + gui_app.request_close() + + except urllib.error.HTTPError as e: + if e.code == 409: + error_msg = "Incompatible openpilot version" + self.download_failed(self.download_url, error_msg) + except Exception: + error_msg = "Invalid URL" + self.download_failed(self.download_url, error_msg) + + def download_failed(self, url: str, reason: str): + self.failed_url = url + self.failed_reason = reason + self._download_failed_page.set_reason(reason) + self._set_state(SetupState.DOWNLOAD_FAILED) + + +def main(): + try: + gui_app.init_window("Setup") + setup = Setup() + for should_render in gui_app.render(): + if should_render: + setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + setup.close() + except Exception as e: + print(f"Setup error: {e}") + finally: + gui_app.close() + + +if __name__ == "__main__": + main() diff --git a/system/ui/mici_updater.py b/system/ui/mici_updater.py new file mode 100755 index 0000000000..2ae2f7cc19 --- /dev/null +++ b/system/ui/mici_updater.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +import sys +import subprocess +import threading +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.text_measure import measure_text_cached +from openpilot.system.ui.lib.wifi_manager import WifiManager, Network +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import gui_text_box, gui_label, UnifiedLabel +from openpilot.system.ui.widgets.button import FullRoundedButton +from openpilot.system.ui.mici_setup import NetworkSetupPage, FailedPage, NetworkConnectivityMonitor + + +class Screen(IntEnum): + PROMPT = 0 + WIFI = 1 + PROGRESS = 2 + FAILED = 3 + + +class Updater(Widget): + def __init__(self, updater_path, manifest_path): + super().__init__() + self.updater = updater_path + self.manifest = manifest_path + self.current_screen = Screen.PROMPT + self._current_network_strength = -1 + + self.progress_value = 0 + self.progress_text = "loading" + self.process = None + self.update_thread = None + self._wifi_manager = WifiManager() + self._wifi_manager.set_active(True) + + self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_callback, + self._network_setup_back_callback) + + self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated) + self._network_monitor = NetworkConnectivityMonitor() + self._network_monitor.start() + + # Buttons + self._continue_button = FullRoundedButton("continue") + self._continue_button.set_click_callback(lambda: self.set_current_screen(Screen.WIFI)) + + self._title_label = UnifiedLabel("update required", 48, text_color=rl.Color(255, 115, 0, 255), + font_weight=FontWeight.DISPLAY) + self._subtitle_label = UnifiedLabel("The download size is approximately 1GB.", 36, + text_color=rl.Color(255, 255, 255, int(255 * 0.9)), + font_weight=FontWeight.ROMAN) + + self._update_failed_page = FailedPage(HARDWARE.reboot, self._update_failed_retry_callback, + title="update failed") + + def _network_setup_back_callback(self): + self.set_current_screen(Screen.PROMPT) + + def _network_setup_continue_callback(self): + self.install_update() + + def _update_failed_retry_callback(self): + self.set_current_screen(Screen.PROMPT) + + def _on_network_updated(self, networks: list[Network]): + self._current_network_strength = next((net.strength for net in networks if net.is_connected), -1) + + def set_current_screen(self, screen: Screen): + if self.current_screen != screen: + if screen == Screen.PROGRESS: + if self._network_setup_page: + self._network_setup_page.hide_event() + elif screen == Screen.WIFI: + if self._network_setup_page: + self._network_setup_page.show_event() + elif screen == Screen.PROMPT: + if self._network_setup_page: + self._network_setup_page.hide_event() + elif screen == Screen.FAILED: + if self._network_setup_page: + self._network_setup_page.hide_event() + + self.current_screen = screen + + def install_update(self): + self.set_current_screen(Screen.PROGRESS) + self.progress_value = 0 + self.progress_text = "downloading" + + # Start the update process in a separate thread + self.update_thread = threading.Thread(target=self._run_update_process) + self.update_thread.daemon = True + self.update_thread.start() + + def _run_update_process(self): + # TODO: just import it and run in a thread without a subprocess + cmd = [self.updater, "--swap", self.manifest] + self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, bufsize=1, universal_newlines=True) + + for line in self.process.stdout: + parts = line.strip().split(":") + if len(parts) == 2: + self.progress_text = parts[0].lower() + try: + self.progress_value = int(float(parts[1])) + except ValueError: + pass + + exit_code = self.process.wait() + if exit_code == 0: + HARDWARE.reboot() + else: + self.set_current_screen(Screen.FAILED) + + def render_prompt_screen(self, rect: rl.Rectangle): + self._title_label.render(rl.Rectangle( + rect.x + 8, + rect.y - 5, + rect.width, + 48, + )) + + subtitle_width = rect.width - 16 + subtitle_height = self._subtitle_label.get_content_height(int(subtitle_width)) + self._subtitle_label.render(rl.Rectangle( + rect.x + 8, + rect.y + 48, + subtitle_width, + subtitle_height, + )) + + self._continue_button.render(rl.Rectangle( + rect.x + 8, + rect.y + rect.height - self._continue_button.rect.height, + self._continue_button.rect.width, + self._continue_button.rect.height, + )) + + def render_progress_screen(self, rect: rl.Rectangle): + title_rect = rl.Rectangle(self._rect.x + 6, self._rect.y - 5, self._rect.width - 12, self._rect.height - 8) + if ' ' in self.progress_text: + font_size = 62 + else: + font_size = 82 + gui_text_box(title_rect, self.progress_text, font_size, font_weight=FontWeight.DISPLAY, + color=rl.Color(255, 255, 255, int(255 * 0.9))) + + progress_value = f"{self.progress_value}%" + text_height = measure_text_cached(gui_app.font(FontWeight.ROMAN), progress_value, 128).y + progress_rect = rl.Rectangle(self._rect.x + 6, self._rect.y + self._rect.height - text_height + 18, + self._rect.width - 12, text_height) + gui_label(progress_rect, progress_value, 128, font_weight=FontWeight.ROMAN, + color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35))) + + def _update_state(self): + self._wifi_manager.process_callbacks() + + def _render(self, rect: rl.Rectangle): + if self.current_screen == Screen.PROMPT: + self.render_prompt_screen(rect) + elif self.current_screen == Screen.WIFI: + self._network_setup_page.set_has_internet(self._network_monitor.network_connected.is_set()) + self._network_setup_page.render(rect) + elif self.current_screen == Screen.PROGRESS: + self.render_progress_screen(rect) + elif self.current_screen == Screen.FAILED: + self._update_failed_page.render(rect) + + def close(self): + self._network_monitor.stop() + + +def main(): + if len(sys.argv) < 3: + print("Usage: updater.py ") + sys.exit(1) + + updater_path = sys.argv[1] + manifest_path = sys.argv[2] + + try: + gui_app.init_window("System Update") + updater = Updater(updater_path, manifest_path) + for should_render in gui_app.render(): + if should_render: + updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + updater.close() + except Exception as e: + print(f"Updater error: {e}") + finally: + gui_app.close() + + +if __name__ == "__main__": + main() diff --git a/system/ui/reset.py b/system/ui/reset.py index 3922c27aac..c32504a5b8 100755 --- a/system/ui/reset.py +++ b/system/ui/reset.py @@ -1,135 +1,14 @@ #!/usr/bin/env python3 -import os -import sys -import threading -import time -from enum import IntEnum - -import pyray as rl - -from openpilot.system.hardware import PC -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 - -USERDATA = "/dev/disk/by-partlabel/userdata" -TIMEOUT = 3*60 - - -class ResetMode(IntEnum): - USER_RESET = 0 # user initiated a factory reset from openpilot - RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover - FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata - - -class ResetState(IntEnum): - NONE = 0 - CONFIRM = 1 - RESETTING = 2 - FAILED = 3 - - -class Reset(Widget): - def __init__(self, mode): - super().__init__() - self._mode = mode - self._previous_reset_state = None - self._reset_state = ResetState.NONE - self._cancel_button = Button("Cancel", self._cancel_callback) - self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY) - self._reboot_button = Button("Reboot", lambda: os.system("sudo reboot")) - self._render_status = True - - def _cancel_callback(self): - self._render_status = False - - def _do_erase(self): - if PC: - return - - # Removing data and formatting - rm = os.system("sudo rm -rf /data/*") - os.system(f"sudo umount {USERDATA}") - fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}") - - if rm == 0 or fmt == 0: - os.system("sudo reboot") - else: - self._reset_state = ResetState.FAILED - - def start_reset(self): - self._reset_state = ResetState.RESETTING - threading.Timer(0.1, self._do_erase).start() - - def _update_state(self): - if self._reset_state != self._previous_reset_state: - self._previous_reset_state = self._reset_state - self._timeout_st = time.monotonic() - elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT: - exit(0) - - def _render(self, rect: rl.Rectangle): - 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 * FONT_SCALE) - gui_text_box(text_rect, self._get_body_text(), 90) - - button_height = 160 - button_spacing = 50 - button_top = rect.y + rect.height - button_height - button_width = (rect.width - button_spacing) / 2.0 - - if self._reset_state != ResetState.RESETTING: - if self._mode == ResetMode.RECOVER: - self._reboot_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height)) - elif self._mode == ResetMode.USER_RESET: - self._cancel_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height)) - - if self._reset_state != ResetState.FAILED: - self._confirm_button.render(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height)) - else: - self._reboot_button.render(rl.Rectangle(rect.x, button_top, rect.width, button_height)) - - return self._render_status - - def _confirm(self): - if self._reset_state == ResetState.CONFIRM: - self.start_reset() - else: - self._reset_state = ResetState.CONFIRM - - def _get_body_text(self): - if self._reset_state == ResetState.CONFIRM: - return "Are you sure you want to reset your device?" - if self._reset_state == ResetState.RESETTING: - return "Resetting device...\nThis may take up to a minute." - if self._reset_state == ResetState.FAILED: - return "Reset failed. Reboot to try again." - if self._mode == ResetMode.RECOVER: - return "Unable to mount data partition. Partition may be corrupted. Press confirm to erase and reset your device." - return "System reset triggered. Press confirm to erase all content and settings. Press cancel to resume boot." +from openpilot.system.ui.lib.application import gui_app +import openpilot.system.ui.tici_reset as tici_reset +import openpilot.system.ui.mici_reset as mici_reset def main(): - mode = ResetMode.USER_RESET - if len(sys.argv) > 1: - if sys.argv[1] == '--recover': - mode = ResetMode.RECOVER - elif sys.argv[1] == "--format": - mode = ResetMode.FORMAT - - gui_app.init_window("System Reset", 20) - reset = Reset(mode) - - if mode == ResetMode.FORMAT: - reset.start_reset() - - for should_render in gui_app.render(): - if should_render: - if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)): - break + if gui_app.big_ui(): + tici_reset.main() + else: + mici_reset.main() if __name__ == "__main__": diff --git a/system/ui/setup.py b/system/ui/setup.py index 0045b45417..23ffc26aa2 100755 --- a/system/ui/setup.py +++ b/system/ui/setup.py @@ -1,450 +1,14 @@ #!/usr/bin/env python3 -import os -import re -import threading -import time -import urllib.request -import urllib.error -from urllib.parse import urlparse -from enum import IntEnum -import shutil - -import pyray as rl - -from cereal import log -from openpilot.common.utils 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, 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 -from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManager - -NetworkType = log.DeviceState.NetworkType - -MARGIN = 50 -TITLE_FONT_SIZE = 90 -TITLE_FONT_WEIGHT = FontWeight.MEDIUM -NEXT_BUTTON_WIDTH = 310 -BODY_FONT_SIZE = 80 -BUTTON_HEIGHT = 160 -BUTTON_SPACING = 50 - -OPENPILOT_URL = "https://openpilot.comma.ai" -USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}" - -CONTINUE_PATH = "/data/continue.sh" -TMP_CONTINUE_PATH = "/data/continue.sh.new" -INSTALL_PATH = "/data/openpilot" -VALID_CACHE_PATH = "/data/.openpilot_cache" -INSTALLER_SOURCE_PATH = "/usr/comma/installer" -INSTALLER_DESTINATION_PATH = "/tmp/installer" -INSTALLER_URL_PATH = "/tmp/installer_url" - -CONTINUE = """#!/usr/bin/env bash - -cd /data/openpilot -exec ./launch_openpilot.sh -""" - - -class SetupState(IntEnum): - LOW_VOLTAGE = 0 - GETTING_STARTED = 1 - NETWORK_SETUP = 2 - SOFTWARE_SELECTION = 3 - CUSTOM_SOFTWARE = 4 - DOWNLOADING = 5 - DOWNLOAD_FAILED = 6 - CUSTOM_SOFTWARE_WARNING = 7 - - -class Setup(Widget): - def __init__(self): - super().__init__() - self.state = SetupState.GETTING_STARTED - self.network_check_thread = None - self.network_connected = threading.Event() - self.wifi_connected = threading.Event() - self.stop_network_check_thread = threading.Event() - self.failed_url = "" - self.failed_reason = "" - self.download_url = "" - self.download_progress = 0 - self.download_thread = None - self.wifi_ui = WifiManagerUI(WifiManager()) - self.keyboard = Keyboard() - self.selected_radio = None - 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, 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=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, 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=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) - self._software_selection_continue_button = Button("Continue", self._software_selection_continue_button_callback, - 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, 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, 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, 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", 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.", - 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, text_padding=20) - - try: - with open("/sys/class/hwmon/hwmon1/in1_input") as f: - voltage = float(f.read().strip()) / 1000.0 - if voltage < 7: - self.state = SetupState.LOW_VOLTAGE - except (FileNotFoundError, ValueError): - self.state = SetupState.LOW_VOLTAGE - - def _render(self, rect: rl.Rectangle): - if self.state == SetupState.LOW_VOLTAGE: - self.render_low_voltage(rect) - elif self.state == SetupState.GETTING_STARTED: - self.render_getting_started(rect) - elif self.state == SetupState.NETWORK_SETUP: - self.render_network_setup(rect) - elif self.state == SetupState.SOFTWARE_SELECTION: - self.render_software_selection(rect) - elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING: - self.render_custom_software_warning(rect) - elif self.state == SetupState.CUSTOM_SOFTWARE: - self.render_custom_software() - elif self.state == SetupState.DOWNLOADING: - self.render_downloading(rect) - elif self.state == SetupState.DOWNLOAD_FAILED: - self.render_download_failed(rect) - - def _low_voltage_continue_button_callback(self): - self.state = SetupState.GETTING_STARTED - - def _custom_software_warning_back_button_callback(self): - self.state = SetupState.SOFTWARE_SELECTION - - def _custom_software_warning_continue_button_callback(self): - self.state = SetupState.NETWORK_SETUP - self.stop_network_check_thread.clear() - self.start_network_check() - - def _getting_started_button_callback(self): - self.state = SetupState.SOFTWARE_SELECTION - - def _software_selection_back_button_callback(self): - self.state = SetupState.GETTING_STARTED - - def _software_selection_continue_button_callback(self): - if self._software_selection_openpilot_button.selected: - self.use_openpilot() - else: - self.state = SetupState.CUSTOM_SOFTWARE_WARNING - - def _download_failed_startover_button_callback(self): - self.state = SetupState.GETTING_STARTED - - def _network_setup_back_button_callback(self): - self.state = SetupState.SOFTWARE_SELECTION - - def _network_setup_continue_button_callback(self): - self.stop_network_check_thread.set() - if self._software_selection_openpilot_button.selected: - self.download(OPENPILOT_URL) - else: - self.state = SetupState.CUSTOM_SOFTWARE - - 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 * 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 - self._low_voltage_poweroff_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT)) - 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 * 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) - triangle = gui_app.texture("images/button_continue_triangle.png", 54, int(btn_rect.height)) - rl.draw_texture_v(triangle, rl.Vector2(btn_rect.x + btn_rect.width / 2 - triangle.width / 2, btn_rect.height / 2 - triangle.height / 2), rl.WHITE) - - def check_network_connectivity(self): - while not self.stop_network_check_thread.is_set(): - if self.state == SetupState.NETWORK_SETUP: - try: - urllib.request.urlopen(OPENPILOT_URL, timeout=2) - self.network_connected.set() - if HARDWARE.get_network_type() == NetworkType.wifi: - self.wifi_connected.set() - else: - self.wifi_connected.clear() - except Exception: - self.network_connected.clear() - time.sleep(1) - - def start_network_check(self): - if self.network_check_thread is None or not self.network_check_thread.is_alive(): - self.network_check_thread = threading.Thread(target=self.check_network_connectivity, daemon=True) - self.network_check_thread.start() - - def close(self): - if self.network_check_thread is not None: - self.stop_network_check_thread.set() - 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 * FONT_SCALE)) - - 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) - - button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2 - button_y = rect.height - BUTTON_HEIGHT - MARGIN - - self._network_setup_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT)) - - # Check network connectivity status - continue_enabled = self.network_connected.is_set() - self._network_setup_continue_button.set_enabled(continue_enabled) - continue_text = ("Continue" if self.wifi_connected.is_set() else "Continue without Wi-Fi") if continue_enabled else "Waiting for internet" - self._network_setup_continue_button.set_text(continue_text) - 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 * 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 * 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 * 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: - self._software_selection_continue_button.set_enabled(True) - self._software_selection_openpilot_button.selected = False - - button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2 - button_y = rect.height - BUTTON_HEIGHT - MARGIN - - self._software_selection_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT)) - 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 * 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 * 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 * 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)) - - button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2 - button_y = rect.height - BUTTON_HEIGHT - MARGIN - self._download_failed_reboot_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT)) - self._download_failed_startover_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT)) - - 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.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 * 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 < (rect.height - warn_rect.height): - self._custom_software_warning_continue_button.set_enabled(True) - self._custom_software_warning_continue_button.set_text("Continue") - - def render_custom_software(self): - def handle_keyboard_result(result): - # Enter pressed - if result == 1: - url = self.keyboard.text - self.keyboard.clear() - if url: - self.download(url) - - # Cancel pressed - elif result == 0: - self.state = SetupState.SOFTWARE_SELECTION - - 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) - - def use_openpilot(self): - if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH): - os.remove(VALID_CACHE_PATH) - with open(TMP_CONTINUE_PATH, "w") as f: - f.write(CONTINUE) - run_cmd(["chmod", "+x", TMP_CONTINUE_PATH]) - shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH) - shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH) - - # give time for installer UI to take over - time.sleep(0.1) - gui_app.request_close() - else: - self.state = SetupState.NETWORK_SETUP - self.stop_network_check_thread.clear() - self.start_network_check() - - def download(self, url: str): - # autocomplete incomplete URLs - if re.match("^([^/.]+)/([^/]+)$", url): - url = f"https://installer.comma.ai/{url}" - - parsed = urlparse(url, scheme='https') - self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl() - - self.state = SetupState.DOWNLOADING - - self.download_thread = threading.Thread(target=self._download_thread, daemon=True) - self.download_thread.start() - - def _download_thread(self): - try: - import tempfile - - fd, tmpfile = tempfile.mkstemp(prefix="installer_") - - 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: - total_size = int(response.headers.get('content-length', 0)) - downloaded = 0 - block_size = 8192 - - while True: - buffer = response.read(block_size) - if not buffer: - break - - downloaded += len(buffer) - f.write(buffer) - - if total_size: - self.download_progress = int(downloaded * 100 / total_size) - - is_elf = False - with open(tmpfile, 'rb') as f: - header = f.read(4) - is_elf = header == b'\x7fELF' - - if not is_elf: - self.download_failed(self.download_url, "No custom software found at this URL.") - return - - # AGNOS might try to execute the installer before this process exits. - # Therefore, important to close the fd before renaming the installer. - os.close(fd) - os.rename(tmpfile, INSTALLER_DESTINATION_PATH) - - with open(INSTALLER_URL_PATH, "w") as f: - f.write(self.download_url) - - # give time for installer UI to take over - time.sleep(0.1) - gui_app.request_close() - - except urllib.error.HTTPError as e: - if e.code == 409: - error_msg = e.read().decode("utf-8") - self.download_failed(self.download_url, error_msg) - except Exception: - error_msg = "Ensure the entered URL is valid, and the device's internet connection is good." - self.download_failed(self.download_url, error_msg) - - def download_failed(self, url: str, reason: str): - self.failed_url = url - self.failed_reason = reason - self.state = SetupState.DOWNLOAD_FAILED +from openpilot.system.ui.lib.application import gui_app +import openpilot.system.ui.tici_setup as tici_setup +import openpilot.system.ui.mici_setup as mici_setup def main(): - try: - gui_app.init_window("Setup", 20) - setup = Setup() - for should_render in gui_app.render(): - if should_render: - setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - setup.close() - except Exception as e: - print(f"Setup error: {e}") - finally: - gui_app.close() + if gui_app.big_ui(): + tici_setup.main() + else: + mici_setup.main() if __name__ == "__main__": diff --git a/system/ui/spinner.py b/system/ui/spinner.py index eb33f08349..2a48b3889b 100755 --- a/system/ui/spinner.py +++ b/system/ui/spinner.py @@ -9,11 +9,20 @@ from openpilot.system.ui.text import wrap_text from openpilot.system.ui.widgets import Widget # Constants -PROGRESS_BAR_WIDTH = 1000 -PROGRESS_BAR_HEIGHT = 20 +if gui_app.big_ui(): + PROGRESS_BAR_WIDTH = 1000 + PROGRESS_BAR_HEIGHT = 20 + TEXTURE_SIZE = 360 + WRAPPED_SPACING = 50 + CENTERED_SPACING = 150 +else: + PROGRESS_BAR_WIDTH = 268 + PROGRESS_BAR_HEIGHT = 10 + TEXTURE_SIZE = 140 + WRAPPED_SPACING = 10 + CENTERED_SPACING = 20 DEGREES_PER_SECOND = 360.0 # one full rotation per second MARGIN_H = 100 -TEXTURE_SIZE = 360 FONT_SIZE = 96 LINE_HEIGHT = 104 DARKGRAY = (55, 55, 55, 255) @@ -43,12 +52,12 @@ class Spinner(Widget): def _render(self, rect: rl.Rectangle): if self._wrapped_lines: # Calculate total height required for spinner and text - spacing = 50 + spacing = WRAPPED_SPACING total_height = TEXTURE_SIZE + spacing + len(self._wrapped_lines) * LINE_HEIGHT center_y = (rect.height - total_height) / 2.0 + TEXTURE_SIZE / 2.0 else: # Center spinner vertically - spacing = 150 + spacing = CENTERED_SPACING center_y = rect.height / 2.0 y_pos = center_y + TEXTURE_SIZE / 2.0 + spacing diff --git a/system/ui/text.py b/system/ui/text.py index 707b30983b..17e8a507cb 100755 --- a/system/ui/text.py +++ b/system/ui/text.py @@ -3,17 +3,24 @@ import re import sys import pyray as rl from openpilot.system.hardware import HARDWARE, PC -from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.application import BIG_UI, 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 Button, ButtonStyle -MARGIN = 50 -SPACING = 40 -FONT_SIZE = 72 -LINE_HEIGHT = 80 -BUTTON_SIZE = rl.Vector2(310, 160) +if BIG_UI: + MARGIN = 50 + SPACING = 40 + FONT_SIZE = 72 + LINE_HEIGHT = 80 + BUTTON_SIZE = rl.Vector2(310, 160) +else: + MARGIN = 20 + SPACING = 30 + FONT_SIZE = 25 + LINE_HEIGHT = 25 + BUTTON_SIZE = rl.Vector2(150, 80) DEMO_TEXT = """This is a sample text that will be wrapped and scrolled if necessary. The text is long enough to demonstrate scrolling and word wrapping.""" * 30 @@ -31,7 +38,7 @@ def wrap_text(text, font_size, max_width): continue indent = re.match(r"^\s*", paragraph).group() current_line = indent - words = re.split(r"(\s+)", paragraph[len(indent):]) + words = re.split(r"(\s+|-)", paragraph[len(indent):]) while len(words): word = words.pop(0) test_line = current_line + word + (words.pop(0) if words else "") @@ -57,7 +64,7 @@ class TextWindow(Widget): 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) + self._button = Button(button_text, click_callback=self._on_button_clicked, button_style=ButtonStyle.TRANSPARENT_WHITE_BORDER, font_size=FONT_SIZE) @staticmethod def _on_button_clicked(): diff --git a/system/ui/tici_reset.py b/system/ui/tici_reset.py new file mode 100755 index 0000000000..3922c27aac --- /dev/null +++ b/system/ui/tici_reset.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +import os +import sys +import threading +import time +from enum import IntEnum + +import pyray as rl + +from openpilot.system.hardware import PC +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 + +USERDATA = "/dev/disk/by-partlabel/userdata" +TIMEOUT = 3*60 + + +class ResetMode(IntEnum): + USER_RESET = 0 # user initiated a factory reset from openpilot + RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover + FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata + + +class ResetState(IntEnum): + NONE = 0 + CONFIRM = 1 + RESETTING = 2 + FAILED = 3 + + +class Reset(Widget): + def __init__(self, mode): + super().__init__() + self._mode = mode + self._previous_reset_state = None + self._reset_state = ResetState.NONE + self._cancel_button = Button("Cancel", self._cancel_callback) + self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY) + self._reboot_button = Button("Reboot", lambda: os.system("sudo reboot")) + self._render_status = True + + def _cancel_callback(self): + self._render_status = False + + def _do_erase(self): + if PC: + return + + # Removing data and formatting + rm = os.system("sudo rm -rf /data/*") + os.system(f"sudo umount {USERDATA}") + fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}") + + if rm == 0 or fmt == 0: + os.system("sudo reboot") + else: + self._reset_state = ResetState.FAILED + + def start_reset(self): + self._reset_state = ResetState.RESETTING + threading.Timer(0.1, self._do_erase).start() + + def _update_state(self): + if self._reset_state != self._previous_reset_state: + self._previous_reset_state = self._reset_state + self._timeout_st = time.monotonic() + elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT: + exit(0) + + def _render(self, rect: rl.Rectangle): + 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 * FONT_SCALE) + gui_text_box(text_rect, self._get_body_text(), 90) + + button_height = 160 + button_spacing = 50 + button_top = rect.y + rect.height - button_height + button_width = (rect.width - button_spacing) / 2.0 + + if self._reset_state != ResetState.RESETTING: + if self._mode == ResetMode.RECOVER: + self._reboot_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height)) + elif self._mode == ResetMode.USER_RESET: + self._cancel_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height)) + + if self._reset_state != ResetState.FAILED: + self._confirm_button.render(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height)) + else: + self._reboot_button.render(rl.Rectangle(rect.x, button_top, rect.width, button_height)) + + return self._render_status + + def _confirm(self): + if self._reset_state == ResetState.CONFIRM: + self.start_reset() + else: + self._reset_state = ResetState.CONFIRM + + def _get_body_text(self): + if self._reset_state == ResetState.CONFIRM: + return "Are you sure you want to reset your device?" + if self._reset_state == ResetState.RESETTING: + return "Resetting device...\nThis may take up to a minute." + if self._reset_state == ResetState.FAILED: + return "Reset failed. Reboot to try again." + if self._mode == ResetMode.RECOVER: + return "Unable to mount data partition. Partition may be corrupted. Press confirm to erase and reset your device." + return "System reset triggered. Press confirm to erase all content and settings. Press cancel to resume boot." + + +def main(): + mode = ResetMode.USER_RESET + if len(sys.argv) > 1: + if sys.argv[1] == '--recover': + mode = ResetMode.RECOVER + elif sys.argv[1] == "--format": + mode = ResetMode.FORMAT + + gui_app.init_window("System Reset", 20) + reset = Reset(mode) + + if mode == ResetMode.FORMAT: + reset.start_reset() + + for should_render in gui_app.render(): + if should_render: + if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)): + break + + +if __name__ == "__main__": + main() diff --git a/system/ui/tici_setup.py b/system/ui/tici_setup.py new file mode 100755 index 0000000000..bf64361bed --- /dev/null +++ b/system/ui/tici_setup.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +import os +import re +import threading +import time +import urllib.request +import urllib.error +from urllib.parse import urlparse +from enum import IntEnum +import shutil + +import pyray as rl + +from cereal import log +from openpilot.common.utils 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, 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 +from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManager + +NetworkType = log.DeviceState.NetworkType + +MARGIN = 50 +TITLE_FONT_SIZE = 90 +TITLE_FONT_WEIGHT = FontWeight.MEDIUM +NEXT_BUTTON_WIDTH = 310 +BODY_FONT_SIZE = 80 +BUTTON_HEIGHT = 160 +BUTTON_SPACING = 50 + +OPENPILOT_URL = "https://openpilot.comma.ai" +USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}" + +CONTINUE_PATH = "/data/continue.sh" +TMP_CONTINUE_PATH = "/data/continue.sh.new" +INSTALL_PATH = "/data/openpilot" +VALID_CACHE_PATH = "/data/.openpilot_cache" +INSTALLER_SOURCE_PATH = "/usr/comma/installer" +INSTALLER_DESTINATION_PATH = "/tmp/installer" +INSTALLER_URL_PATH = "/tmp/installer_url" + +CONTINUE = """#!/usr/bin/env bash + +cd /data/openpilot +exec ./launch_openpilot.sh +""" + + +class SetupState(IntEnum): + LOW_VOLTAGE = 0 + GETTING_STARTED = 1 + NETWORK_SETUP = 2 + SOFTWARE_SELECTION = 3 + CUSTOM_SOFTWARE = 4 + DOWNLOADING = 5 + DOWNLOAD_FAILED = 6 + CUSTOM_SOFTWARE_WARNING = 7 + + +class Setup(Widget): + def __init__(self): + super().__init__() + self.state = SetupState.GETTING_STARTED + self.network_check_thread = None + self.network_connected = threading.Event() + self.wifi_connected = threading.Event() + self.stop_network_check_thread = threading.Event() + self.failed_url = "" + self.failed_reason = "" + self.download_url = "" + self.download_progress = 0 + self.download_thread = None + self.wifi_ui = WifiManagerUI(WifiManager()) + self.keyboard = Keyboard() + self.selected_radio = None + 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, 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=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, 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=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) + self._software_selection_continue_button = Button("Continue", self._software_selection_continue_button_callback, + 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, 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, 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, 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", 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.", + 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, text_padding=20) + + try: + with open("/sys/class/hwmon/hwmon1/in1_input") as f: + voltage = float(f.read().strip()) / 1000.0 + if voltage < 7: + self.state = SetupState.LOW_VOLTAGE + except (FileNotFoundError, ValueError): + self.state = SetupState.LOW_VOLTAGE + + def _render(self, rect: rl.Rectangle): + if self.state == SetupState.LOW_VOLTAGE: + self.render_low_voltage(rect) + elif self.state == SetupState.GETTING_STARTED: + self.render_getting_started(rect) + elif self.state == SetupState.NETWORK_SETUP: + self.render_network_setup(rect) + elif self.state == SetupState.SOFTWARE_SELECTION: + self.render_software_selection(rect) + elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING: + self.render_custom_software_warning(rect) + elif self.state == SetupState.CUSTOM_SOFTWARE: + self.render_custom_software() + elif self.state == SetupState.DOWNLOADING: + self.render_downloading(rect) + elif self.state == SetupState.DOWNLOAD_FAILED: + self.render_download_failed(rect) + + def _low_voltage_continue_button_callback(self): + self.state = SetupState.GETTING_STARTED + + def _custom_software_warning_back_button_callback(self): + self.state = SetupState.SOFTWARE_SELECTION + + def _custom_software_warning_continue_button_callback(self): + self.state = SetupState.NETWORK_SETUP + self.stop_network_check_thread.clear() + self.start_network_check() + + def _getting_started_button_callback(self): + self.state = SetupState.SOFTWARE_SELECTION + + def _software_selection_back_button_callback(self): + self.state = SetupState.GETTING_STARTED + + def _software_selection_continue_button_callback(self): + if self._software_selection_openpilot_button.selected: + self.use_openpilot() + else: + self.state = SetupState.CUSTOM_SOFTWARE_WARNING + + def _download_failed_startover_button_callback(self): + self.state = SetupState.GETTING_STARTED + + def _network_setup_back_button_callback(self): + self.state = SetupState.SOFTWARE_SELECTION + + def _network_setup_continue_button_callback(self): + self.stop_network_check_thread.set() + if self._software_selection_openpilot_button.selected: + self.download(OPENPILOT_URL) + else: + self.state = SetupState.CUSTOM_SOFTWARE + + 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 * 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 + self._low_voltage_poweroff_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT)) + 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 * 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) + triangle = gui_app.texture("images/button_continue_triangle.png", 54, int(btn_rect.height)) + rl.draw_texture_v(triangle, rl.Vector2(btn_rect.x + btn_rect.width / 2 - triangle.width / 2, btn_rect.height / 2 - triangle.height / 2), rl.WHITE) + + def check_network_connectivity(self): + while not self.stop_network_check_thread.is_set(): + if self.state == SetupState.NETWORK_SETUP: + try: + urllib.request.urlopen(OPENPILOT_URL, timeout=2) + self.network_connected.set() + if HARDWARE.get_network_type() == NetworkType.wifi: + self.wifi_connected.set() + else: + self.wifi_connected.clear() + except Exception: + self.network_connected.clear() + time.sleep(1) + + def start_network_check(self): + if self.network_check_thread is None or not self.network_check_thread.is_alive(): + self.network_check_thread = threading.Thread(target=self.check_network_connectivity, daemon=True) + self.network_check_thread.start() + + def close(self): + if self.network_check_thread is not None: + self.stop_network_check_thread.set() + 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 * FONT_SCALE)) + + 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) + + button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2 + button_y = rect.height - BUTTON_HEIGHT - MARGIN + + self._network_setup_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT)) + + # Check network connectivity status + continue_enabled = self.network_connected.is_set() + self._network_setup_continue_button.set_enabled(continue_enabled) + continue_text = ("Continue" if self.wifi_connected.is_set() else "Continue without Wi-Fi") if continue_enabled else "Waiting for internet" + self._network_setup_continue_button.set_text(continue_text) + 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 * 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 * 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 * 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: + self._software_selection_continue_button.set_enabled(True) + self._software_selection_openpilot_button.selected = False + + button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2 + button_y = rect.height - BUTTON_HEIGHT - MARGIN + + self._software_selection_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT)) + 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 * 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 * 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 * 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)) + + button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2 + button_y = rect.height - BUTTON_HEIGHT - MARGIN + self._download_failed_reboot_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT)) + self._download_failed_startover_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT)) + + 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.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 * 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 + 400, 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 < (rect.height - warn_rect.height): + self._custom_software_warning_continue_button.set_enabled(True) + self._custom_software_warning_continue_button.set_text("Continue") + + def render_custom_software(self): + def handle_keyboard_result(result): + # Enter pressed + if result == 1: + url = self.keyboard.text + self.keyboard.clear() + if url: + self.download(url) + + # Cancel pressed + elif result == 0: + self.state = SetupState.SOFTWARE_SELECTION + + 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) + + def use_openpilot(self): + if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH): + os.remove(VALID_CACHE_PATH) + with open(TMP_CONTINUE_PATH, "w") as f: + f.write(CONTINUE) + run_cmd(["chmod", "+x", TMP_CONTINUE_PATH]) + shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH) + shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH) + + # give time for installer UI to take over + time.sleep(0.1) + gui_app.request_close() + else: + self.state = SetupState.NETWORK_SETUP + self.stop_network_check_thread.clear() + self.start_network_check() + + def download(self, url: str): + # autocomplete incomplete URLs + if re.match("^([^/.]+)/([^/]+)$", url): + url = f"https://installer.comma.ai/{url}" + + parsed = urlparse(url, scheme='https') + self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl() + + self.state = SetupState.DOWNLOADING + + self.download_thread = threading.Thread(target=self._download_thread, daemon=True) + self.download_thread.start() + + def _download_thread(self): + try: + import tempfile + + fd, tmpfile = tempfile.mkstemp(prefix="installer_") + + 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: + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 + block_size = 8192 + + while True: + buffer = response.read(block_size) + if not buffer: + break + + downloaded += len(buffer) + f.write(buffer) + + if total_size: + self.download_progress = int(downloaded * 100 / total_size) + + is_elf = False + with open(tmpfile, 'rb') as f: + header = f.read(4) + is_elf = header == b'\x7fELF' + + if not is_elf: + self.download_failed(self.download_url, "No custom software found at this URL.") + return + + # AGNOS might try to execute the installer before this process exits. + # Therefore, important to close the fd before renaming the installer. + os.close(fd) + os.rename(tmpfile, INSTALLER_DESTINATION_PATH) + + with open(INSTALLER_URL_PATH, "w") as f: + f.write(self.download_url) + + # give time for installer UI to take over + time.sleep(0.1) + gui_app.request_close() + + except urllib.error.HTTPError as e: + if e.code == 409: + error_msg = e.read().decode("utf-8") + self.download_failed(self.download_url, error_msg) + except Exception: + error_msg = "Ensure the entered URL is valid, and the device's internet connection is good." + self.download_failed(self.download_url, error_msg) + + def download_failed(self, url: str, reason: str): + self.failed_url = url + self.failed_reason = reason + self.state = SetupState.DOWNLOAD_FAILED + + +def main(): + try: + gui_app.init_window("Setup", 20) + setup = Setup() + for should_render in gui_app.render(): + if should_render: + setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + setup.close() + except Exception as e: + print(f"Setup error: {e}") + finally: + gui_app.close() + + +if __name__ == "__main__": + main() diff --git a/system/ui/tici_updater.py b/system/ui/tici_updater.py new file mode 100755 index 0000000000..2e1a8687e1 --- /dev/null +++ b/system/ui/tici_updater.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +import sys +import subprocess +import threading +import pyray as rl +from enum import IntEnum + +from openpilot.system.hardware import HARDWARE +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 Button, ButtonStyle +from openpilot.system.ui.widgets.label import gui_text_box, gui_label +from openpilot.system.ui.widgets.network import WifiManagerUI + +# Constants +MARGIN = 50 +BUTTON_HEIGHT = 160 +BUTTON_WIDTH = 400 +PROGRESS_BAR_HEIGHT = 72 +TITLE_FONT_SIZE = 80 +BODY_FONT_SIZE = 65 +BACKGROUND_COLOR = rl.BLACK +PROGRESS_BG_COLOR = rl.Color(41, 41, 41, 255) +PROGRESS_COLOR = rl.Color(54, 77, 239, 255) + + +class Screen(IntEnum): + PROMPT = 0 + WIFI = 1 + PROGRESS = 2 + + +class Updater(Widget): + def __init__(self, updater_path, manifest_path): + super().__init__() + self.updater = updater_path + self.manifest = manifest_path + self.current_screen = Screen.PROMPT + + self.progress_value = 0 + self.progress_text = "Loading..." + self.show_reboot_button = False + self.process = None + 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.set_current_screen(Screen.PROGRESS) + self.progress_value = 0 + self.progress_text = "Downloading..." + self.show_reboot_button = False + + # Start the update process in a separate thread + self.update_thread = threading.Thread(target=self._run_update_process) + self.update_thread.daemon = True + self.update_thread.start() + + def _run_update_process(self): + # TODO: just import it and run in a thread without a subprocess + cmd = [self.updater, "--swap", self.manifest] + self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, bufsize=1, universal_newlines=True) + + for line in self.process.stdout: + parts = line.strip().split(":") + if len(parts) == 2: + self.progress_text = parts[0] + try: + self.progress_value = int(float(parts[1])) + except ValueError: + pass + + exit_code = self.process.wait() + if exit_code == 0: + HARDWARE.reboot() + else: + self.progress_text = "Update failed" + self.show_reboot_button = True + + def render_prompt_screen(self, rect: rl.Rectangle): + # Title + 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 * 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 + button_y = rect.height - MARGIN - BUTTON_HEIGHT + button_width = (rect.width - MARGIN * 3) // 2 + + # WiFi button + wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT) + self._wifi_button.render(wifi_button_rect) + + # Install button + install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT) + 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(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) + 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) + gui_label(title_rect, self.progress_text, 90, font_weight=FontWeight.SEMI_BOLD) + + # Progress bar + bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, rect.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT) + rl.draw_rectangle_rounded(bar_rect, 0.5, 10, PROGRESS_BG_COLOR) + + # Calculate the width of the progress chunk + progress_width = (bar_rect.width * self.progress_value) / 100 + if progress_width > 0: + progress_rect = rl.Rectangle(bar_rect.x, bar_rect.y, progress_width, bar_rect.height) + rl.draw_rectangle_rounded(progress_rect, 0.5, 10, PROGRESS_COLOR) + + # 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) + self._reboot_button.render(reboot_rect) + + def _render(self, rect: rl.Rectangle): + if self.current_screen == Screen.PROMPT: + self.render_prompt_screen(rect) + elif self.current_screen == Screen.WIFI: + self.render_wifi_screen(rect) + elif self.current_screen == Screen.PROGRESS: + self.render_progress_screen(rect) + + +def main(): + if len(sys.argv) < 3: + print("Usage: updater.py ") + sys.exit(1) + + updater_path = sys.argv[1] + manifest_path = sys.argv[2] + + try: + gui_app.init_window("System Update") + updater = Updater(updater_path, manifest_path) + for should_render in gui_app.render(): + if should_render: + updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + finally: + # Make sure we clean up even if there's an error + gui_app.close() + + +if __name__ == "__main__": + main() diff --git a/system/ui/updater.py b/system/ui/updater.py index 2e1a8687e1..42d12d9090 100755 --- a/system/ui/updater.py +++ b/system/ui/updater.py @@ -1,172 +1,14 @@ #!/usr/bin/env python3 -import sys -import subprocess -import threading -import pyray as rl -from enum import IntEnum - -from openpilot.system.hardware import HARDWARE -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 Button, ButtonStyle -from openpilot.system.ui.widgets.label import gui_text_box, gui_label -from openpilot.system.ui.widgets.network import WifiManagerUI - -# Constants -MARGIN = 50 -BUTTON_HEIGHT = 160 -BUTTON_WIDTH = 400 -PROGRESS_BAR_HEIGHT = 72 -TITLE_FONT_SIZE = 80 -BODY_FONT_SIZE = 65 -BACKGROUND_COLOR = rl.BLACK -PROGRESS_BG_COLOR = rl.Color(41, 41, 41, 255) -PROGRESS_COLOR = rl.Color(54, 77, 239, 255) - - -class Screen(IntEnum): - PROMPT = 0 - WIFI = 1 - PROGRESS = 2 - - -class Updater(Widget): - def __init__(self, updater_path, manifest_path): - super().__init__() - self.updater = updater_path - self.manifest = manifest_path - self.current_screen = Screen.PROMPT - - self.progress_value = 0 - self.progress_text = "Loading..." - self.show_reboot_button = False - self.process = None - 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.set_current_screen(Screen.PROGRESS) - self.progress_value = 0 - self.progress_text = "Downloading..." - self.show_reboot_button = False - - # Start the update process in a separate thread - self.update_thread = threading.Thread(target=self._run_update_process) - self.update_thread.daemon = True - self.update_thread.start() - - def _run_update_process(self): - # TODO: just import it and run in a thread without a subprocess - cmd = [self.updater, "--swap", self.manifest] - self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, bufsize=1, universal_newlines=True) - - for line in self.process.stdout: - parts = line.strip().split(":") - if len(parts) == 2: - self.progress_text = parts[0] - try: - self.progress_value = int(float(parts[1])) - except ValueError: - pass - - exit_code = self.process.wait() - if exit_code == 0: - HARDWARE.reboot() - else: - self.progress_text = "Update failed" - self.show_reboot_button = True - - def render_prompt_screen(self, rect: rl.Rectangle): - # Title - 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 * 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 - button_y = rect.height - MARGIN - BUTTON_HEIGHT - button_width = (rect.width - MARGIN * 3) // 2 - - # WiFi button - wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT) - self._wifi_button.render(wifi_button_rect) - - # Install button - install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT) - 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(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) - 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) - gui_label(title_rect, self.progress_text, 90, font_weight=FontWeight.SEMI_BOLD) - - # Progress bar - bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, rect.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT) - rl.draw_rectangle_rounded(bar_rect, 0.5, 10, PROGRESS_BG_COLOR) - - # Calculate the width of the progress chunk - progress_width = (bar_rect.width * self.progress_value) / 100 - if progress_width > 0: - progress_rect = rl.Rectangle(bar_rect.x, bar_rect.y, progress_width, bar_rect.height) - rl.draw_rectangle_rounded(progress_rect, 0.5, 10, PROGRESS_COLOR) - - # 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) - self._reboot_button.render(reboot_rect) - - def _render(self, rect: rl.Rectangle): - if self.current_screen == Screen.PROMPT: - self.render_prompt_screen(rect) - elif self.current_screen == Screen.WIFI: - self.render_wifi_screen(rect) - elif self.current_screen == Screen.PROGRESS: - self.render_progress_screen(rect) +from openpilot.system.ui.lib.application import gui_app +import openpilot.system.ui.tici_updater as tici_updater +import openpilot.system.ui.mici_updater as mici_updater def main(): - if len(sys.argv) < 3: - print("Usage: updater.py ") - sys.exit(1) - - updater_path = sys.argv[1] - manifest_path = sys.argv[2] - - try: - gui_app.init_window("System Update") - updater = Updater(updater_path, manifest_path) - for should_render in gui_app.render(): - if should_render: - updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - finally: - # Make sure we clean up even if there's an error - gui_app.close() + if gui_app.big_ui(): + tici_updater.main() + else: + mici_updater.main() if __name__ == "__main__": diff --git a/system/ui/widgets/__init__.py b/system/ui/widgets/__init__.py index 562cde39e1..95858ec1b3 100644 --- a/system/ui/widgets/__init__.py +++ b/system/ui/widgets/__init__.py @@ -2,7 +2,15 @@ import abc import pyray as rl from enum import IntEnum from collections.abc import Callable -from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS +from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter +from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent + +try: + from openpilot.selfdrive.ui.ui_state import device +except ImportError: + class Device: + awake = True + device = Device() # type: ignore class DialogResult(IntEnum): @@ -23,6 +31,7 @@ class Widget(abc.ABC): self._touch_valid_callback: Callable[[], bool] | None = None self._click_callback: Callable[[], None] | None = None self._multi_touch = False + self.__was_awake = True @property def rect(self) -> rl.Rectangle: @@ -71,7 +80,7 @@ class Widget(abc.ABC): def set_position(self, x: float, y: float) -> None: changed = (self._rect.x != x or self._rect.y != y) - self._rect.x, self._rect.y = x, y + self._rect = rl.Rectangle(x, y, self._rect.width, self._rect.height) if changed: self._update_layout_rects() @@ -94,7 +103,7 @@ class Widget(abc.ABC): ret = self._render(self._rect) # Keep track of whether mouse down started within the widget's rectangle - if self.enabled: + if self.enabled and self.__was_awake: for mouse_event in gui_app.mouse_events: if not self._multi_touch and mouse_event.slot != 0: continue @@ -106,6 +115,7 @@ class Widget(abc.ABC): self._handle_mouse_press(mouse_event.pos) self.__is_pressed[mouse_event.slot] = True self.__tracking_is_pressed[mouse_event.slot] = True + self._handle_mouse_event(mouse_event) # Callback such as scroll panel signifies user is scrolling elif not self._touch_valid(): @@ -113,6 +123,7 @@ class Widget(abc.ABC): self.__tracking_is_pressed[mouse_event.slot] = False elif mouse_event.left_released: + self._handle_mouse_event(mouse_event) if self.__is_pressed[mouse_event.slot] and rl.check_collision_point_rec(mouse_event.pos, self._hit_rect): self._handle_mouse_release(mouse_event.pos) self.__is_pressed[mouse_event.slot] = False @@ -122,10 +133,14 @@ class Widget(abc.ABC): elif rl.check_collision_point_rec(mouse_event.pos, self._hit_rect): if self.__tracking_is_pressed[mouse_event.slot]: self.__is_pressed[mouse_event.slot] = True + self._handle_mouse_event(mouse_event) # Mouse/touch left our rect but may come back into focus later elif not rl.check_collision_point_rec(mouse_event.pos, self._hit_rect): self.__is_pressed[mouse_event.slot] = False + self._handle_mouse_event(mouse_event) + + self.__was_awake = device.awake return ret @@ -149,9 +164,206 @@ class Widget(abc.ABC): self._click_callback() return False + def _handle_mouse_event(self, mouse_event: MouseEvent) -> None: + """Optionally handle mouse events. This is called before rendering.""" + # Default implementation does nothing, can be overridden by subclasses + def show_event(self): """Optionally handle show event. Parent must manually call this""" def hide_event(self): """Optionally handle hide event. Parent must manually call this""" + +SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing +START_DISMISSING_THRESHOLD = 40 # px to start dismissing while dragging +BLOCK_SWIPE_AWAY_THRESHOLD = 60 # px horizontal movement to block swipe away + +NAV_BAR_MARGIN = 6 +NAV_BAR_WIDTH = 205 +NAV_BAR_HEIGHT = 8 + +DISMISS_PUSH_OFFSET = 50 + NAV_BAR_MARGIN + NAV_BAR_HEIGHT # px extra to push down when dismissing +DISMISS_TIME_SECONDS = 1.5 + + +class NavBar(Widget): + def __init__(self): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, NAV_BAR_WIDTH, NAV_BAR_HEIGHT)) + self._alpha = 1.0 + self._alpha_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps) + self._fade_time = 0.0 + + def set_alpha(self, alpha: float) -> None: + self._alpha = alpha + self._fade_time = rl.get_time() + + def show_event(self): + super().show_event() + self._alpha = 1.0 + self._alpha_filter.x = 1.0 + self._fade_time = rl.get_time() + + def _render(self, _): + if rl.get_time() - self._fade_time > DISMISS_TIME_SECONDS: + self._alpha = 0.0 + alpha = self._alpha_filter.update(self._alpha) + + # white bar with black border + rl.draw_rectangle_rounded(self._rect, 1.0, 6, rl.Color(255, 255, 255, int(255 * 0.9 * alpha))) + rl.draw_rectangle_rounded_lines_ex(self._rect, 1.0, 6, 2, rl.Color(0, 0, 0, int(255 * 0.3 * alpha))) + + +class NavWidget(Widget, abc.ABC): + """ + A full screen widget that supports back navigation by swiping down from the top. + """ + BACK_TOUCH_AREA_PERCENTAGE = 0.65 + + def __init__(self): + super().__init__() + self._back_callback: Callable[[], None] | None = None + self._back_button_start_pos: MousePos | None = None + self._swiping_away = False # currently swiping away + self._can_swipe_away = True # swipe away is blocked after certain horizontal movement + + self._pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1) + self._playing_dismiss_animation = False + self._trigger_animate_in = False + self._back_enabled: bool | Callable[[], bool] = True + self._nav_bar = NavBar() + + self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + + self._set_up = False + + @property + def back_enabled(self) -> bool: + return self._back_enabled() if callable(self._back_enabled) else self._back_enabled + + def set_back_enabled(self, enabled: bool | Callable[[], bool]) -> None: + self._back_enabled = enabled + + def set_back_callback(self, callback: Callable[[], None]) -> None: + self._back_callback = callback + + def _handle_mouse_event(self, mouse_event: MouseEvent) -> None: + super()._handle_mouse_event(mouse_event) + + if not self.back_enabled: + self._back_button_start_pos = None + self._swiping_away = False + self._can_swipe_away = True + return + + if mouse_event.left_pressed: + # user is able to swipe away if starting near top of screen, or anywhere if scroller is at top + self._pos_filter.update_alpha(0.04) + in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE + + scroller_at_top = False + # TODO: -20? snapping in WiFi dialog can make offset not be positive at the top + if hasattr(self, '_scroller'): + scroller_at_top = self._scroller.scroll_panel.get_offset() >= -20 and not self._scroller._horizontal + elif hasattr(self, '_scroll_panel'): + scroller_at_top = self._scroll_panel.get_offset() >= -20 and not self._scroll_panel._horizontal + + if in_dismiss_area or scroller_at_top: + self._can_swipe_away = True + self._back_button_start_pos = mouse_event.pos + + elif mouse_event.left_down: + if self._back_button_start_pos is not None: + # block swiping away if too much horizontal or upward movement + horizontal_movement = abs(mouse_event.pos.x - self._back_button_start_pos.x) > BLOCK_SWIPE_AWAY_THRESHOLD + upward_movement = mouse_event.pos.y - self._back_button_start_pos.y < -BLOCK_SWIPE_AWAY_THRESHOLD + if not self._swiping_away and (horizontal_movement or upward_movement): + self._can_swipe_away = False + self._back_button_start_pos = None + + # block horizontal swiping if now swiping away + if self._can_swipe_away: + if mouse_event.pos.y - self._back_button_start_pos.y > START_DISMISSING_THRESHOLD: # type: ignore + self._swiping_away = True + + elif mouse_event.left_released: + self._pos_filter.update_alpha(0.1) + # if far enough, trigger back navigation callback + if self._back_button_start_pos is not None: + if mouse_event.pos.y - self._back_button_start_pos.y > SWIPE_AWAY_THRESHOLD: + self._playing_dismiss_animation = True + + self._back_button_start_pos = None + self._swiping_away = False + + def _update_state(self): + super()._update_state() + + # Disable self's scroller while swiping away + if not self._set_up: + self._set_up = True + if hasattr(self, '_scroller'): + original_enabled = self._scroller._enabled + self._scroller.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else + original_enabled)) + elif hasattr(self, '_scroll_panel'): + original_enabled = self._scroll_panel.enabled + self._scroll_panel.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else + original_enabled)) + + if self._trigger_animate_in: + self._pos_filter.x = self._rect.height + self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT + self._trigger_animate_in = False + + new_y = 0.0 + + if self._back_button_start_pos is not None: + last_mouse_event = gui_app.last_mouse_event + # push entire widget as user drags it away + new_y = max(last_mouse_event.pos.y - self._back_button_start_pos.y, 0) + if new_y < SWIPE_AWAY_THRESHOLD: + new_y /= 2 # resistance until mouse release would dismiss widget + + if self._swiping_away: + self._nav_bar.set_alpha(1.0) + + if self._playing_dismiss_animation: + new_y = self._rect.height + DISMISS_PUSH_OFFSET + + new_y = round(self._pos_filter.update(new_y)) + if abs(new_y) < 1 and self._pos_filter.velocity.x == 0.0: + new_y = self._pos_filter.x = 0.0 + + if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10: + if self._back_callback is not None: + self._back_callback() + + self._playing_dismiss_animation = False + self._back_button_start_pos = None + self._swiping_away = False + + self.set_position(self._rect.x, new_y) + + def render(self, rect: rl.Rectangle = None) -> bool | int | None: + ret = super().render(rect) + + if self.back_enabled: + bar_x = self._rect.x + (self._rect.width - self._nav_bar.rect.width) / 2 + if self._back_button_start_pos is not None or self._playing_dismiss_animation: + self._nav_bar_y_filter.x = NAV_BAR_MARGIN + self._pos_filter.x + else: + self._nav_bar_y_filter.update(NAV_BAR_MARGIN) + + self._nav_bar.set_position(bar_x, round(self._nav_bar_y_filter.x)) + self._nav_bar.render() + + return ret + + def show_event(self): + super().show_event() + # FIXME: we don't know the height of the rect at first show_event since it's before the first render :( + # so we need this hacky bool for now + self._trigger_animate_in = True + self._nav_bar.show_event() diff --git a/system/ui/widgets/button.py b/system/ui/widgets/button.py index df7a52d1c0..34b2a51a42 100644 --- a/system/ui/widgets/button.py +++ b/system/ui/widgets/button.py @@ -3,9 +3,10 @@ from enum import IntEnum import pyray as rl -from openpilot.system.ui.lib.application import FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.label import Label +from openpilot.system.ui.widgets.label import Label, UnifiedLabel +from openpilot.common.filter_simple import FirstOrderFilter class ButtonStyle(IntEnum): @@ -175,9 +176,121 @@ class IconButton(Widget): def __init__(self, texture: rl.Texture): super().__init__() self._texture = texture + self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps) + self.set_rect(rl.Rectangle(0, 0, self._texture.width, self._texture.height)) + + def set_opacity(self, opacity: float, smooth: bool = False): + if smooth: + self._opacity_filter.update(opacity) + else: + self._opacity_filter.x = opacity def _render(self, rect: rl.Rectangle): - color = rl.Color(180, 180, 180, 150) if self.is_pressed else rl.WHITE + color = rl.Color(180, 180, 180, int(150 * self._opacity_filter.x)) if self.is_pressed else rl.WHITE + if not self.enabled: + color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.35 * self._opacity_filter.x)) 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 SmallCircleIconButton(Widget): + def __init__(self, icon_txt: rl.Texture): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, 100, 100)) + self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps) + self._icon_bg_txt = gui_app.texture("icons_mici/setup/small_button.png", 100, 100) + self._icon_bg_pressed_txt = gui_app.texture("icons_mici/setup/small_button_pressed.png", 100, 100) + self._icon_txt = icon_txt + + def set_opacity(self, opacity: float, smooth: bool = False): + if smooth: + self._opacity_filter.update(opacity) + else: + self._opacity_filter.x = opacity + + def _render(self, _): + bg_txt = self._icon_bg_pressed_txt if self.is_pressed else self._icon_bg_txt + white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)) + rl.draw_texture(bg_txt, int(self.rect.x), int(self.rect.y), white) + icon_x = self.rect.x + (self.rect.width - self._icon_txt.width) / 2 + icon_y = self.rect.y + (self.rect.height - self._icon_txt.height) / 2 + rl.draw_texture(self._icon_txt, int(icon_x), int(icon_y), white) + + +class SmallButton(Widget): + def __init__(self, text: str): + super().__init__() + self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps) + + self._load_assets() + + self._label = UnifiedLabel(text, 36, font_weight=FontWeight.MEDIUM, + text_color=rl.Color(255, 255, 255, int(255 * 0.9)), + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + self._bg_disabled_txt = None + + def _load_assets(self): + self.set_rect(rl.Rectangle(0, 0, 194, 100)) + self._bg_txt = gui_app.texture("icons_mici/setup/reset/small_button.png", 194, 100) + self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/small_button_pressed.png", 194, 100) + + def set_text(self, text: str): + self._label.set_text(text) + + def set_opacity(self, opacity: float, smooth: bool = False): + if smooth: + self._opacity_filter.update(opacity) + else: + self._opacity_filter.x = opacity + + def _render(self, _): + if not self.enabled and self._bg_disabled_txt is not None: + rl.draw_texture(self._bg_disabled_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))) + elif self.is_pressed: + rl.draw_texture(self._bg_pressed_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))) + else: + rl.draw_texture(self._bg_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))) + + opacity = 0.9 if self.enabled else 0.35 + self._label.set_color(rl.Color(255, 255, 255, int(255 * opacity * self._opacity_filter.x))) + self._label.render(self._rect) + + +class SmallRedPillButton(SmallButton): + def _load_assets(self): + self.set_rect(rl.Rectangle(0, 0, 194, 100)) + self._bg_txt = gui_app.texture("icons_mici/setup/small_red_pill.png", 194, 100) + self._bg_pressed_txt = gui_app.texture("icons_mici/setup/small_red_pill_pressed.png", 194, 100) + + +class SmallerRoundedButton(SmallButton): + def _load_assets(self): + self.set_rect(rl.Rectangle(0, 0, 150, 100)) + self._bg_txt = gui_app.texture("icons_mici/setup/smaller_button.png", 150, 100) + self._bg_disabled_txt = gui_app.texture("icons_mici/setup/smaller_button_disabled.png", 150, 100) + self._bg_pressed_txt = gui_app.texture("icons_mici/setup/smaller_button_pressed.png", 150, 100) + + +class WideRoundedButton(SmallButton): + def _load_assets(self): + self.set_rect(rl.Rectangle(0, 0, 316, 100)) + self._bg_txt = gui_app.texture("icons_mici/setup/medium_button_bg.png", 316, 100) + self._bg_pressed_txt = gui_app.texture("icons_mici/setup/medium_button_pressed_bg.png", 316, 100) + + +class WidishRoundedButton(SmallButton): + def _load_assets(self): + self.set_rect(rl.Rectangle(0, 0, 250, 100)) + self._bg_txt = gui_app.texture("icons_mici/setup/widish_button.png", 250, 100) + self._bg_pressed_txt = gui_app.texture("icons_mici/setup/widish_button_pressed.png", 250, 100) + self._bg_disabled_txt = gui_app.texture("icons_mici/setup/widish_button_disabled.png", 250, 100) + + +class FullRoundedButton(SmallButton): + def _load_assets(self): + self.set_rect(rl.Rectangle(0, 0, 520, 100)) + self._bg_txt = gui_app.texture("icons_mici/setup/reset/wide_button.png", 520, 100) + self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/wide_button_pressed.png", 520, 100) diff --git a/system/ui/widgets/confirm_dialog.py b/system/ui/widgets/confirm_dialog.py index 8c5ae0aa01..97618660bd 100644 --- a/system/ui/widgets/confirm_dialog.py +++ b/system/ui/widgets/confirm_dialog.py @@ -6,7 +6,7 @@ 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 +from openpilot.system.ui.widgets.scroller_tici import Scroller OUTER_MARGIN = 200 RICH_OUTER_MARGIN = 100 diff --git a/system/ui/widgets/label.py b/system/ui/widgets/label.py index 8d33ac2fd0..35e2708e62 100644 --- a/system/ui/widgets/label.py +++ b/system/ui/widgets/label.py @@ -1,14 +1,15 @@ +from enum import IntEnum from collections.abc import Callable 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, FONT_SCALE +from openpilot.system.ui.widgets import Widget 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 from openpilot.system.ui.lib.wrap_text import wrap_text -from openpilot.system.ui.widgets import Widget ICON_PADDING = 15 @@ -20,6 +21,171 @@ def _resolve_value(value, default=""): return value if value is not None else default +class ScrollState(IntEnum): + STARTING = 0 + SCROLLING = 1 + + +# TODO: merge anything new here to master +class MiciLabel(Widget): + def __init__(self, + text: str, + font_size: int = DEFAULT_TEXT_SIZE, + width: int = None, + color: rl.Color = DEFAULT_TEXT_COLOR, + font_weight: FontWeight = FontWeight.NORMAL, + alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + spacing: int = 0, + line_height: int = None, + elide_right: bool = True, + wrap_text: bool = False, + scroll: bool = False): + super().__init__() + self.text = text + self.wrapped_text: list[str] = [] + self.font_size = font_size + self.width = width + self.color = color + self.font_weight = font_weight + self.alignment = alignment + self.alignment_vertical = alignment_vertical + self.spacing = spacing + self.line_height = line_height if line_height is not None else font_size + self.elide_right = elide_right + self.wrap_text = wrap_text + self._height = 0 + + # Scroll state + self.scroll = scroll + self._needs_scroll = False + self._scroll_offset = 0 + self._scroll_pause_t: float | None = None + self._scroll_state: ScrollState = ScrollState.STARTING + + assert not (self.scroll and self.wrap_text), "Cannot enable both scroll and wrap_text" + assert not (self.scroll and self.elide_right), "Cannot enable both scroll and elide_right" + + self.set_text(text) + + @property + def text_height(self): + return self._height + + def set_font_size(self, font_size: int): + self.font_size = font_size + self.set_text(self.text) + + def set_width(self, width: int): + self.width = width + self._rect.width = width + self.set_text(self.text) + + def set_text(self, txt: str): + self.text = txt + text_size = measure_text_cached(gui_app.font(self.font_weight), self.text, self.font_size, self.spacing) + if self.width is not None: + self._rect.width = self.width + else: + self._rect.width = text_size.x + + if self.wrap_text: + self.wrapped_text = wrap_text(gui_app.font(self.font_weight), self.text, self.font_size, int(self._rect.width)) + self._height = len(self.wrapped_text) * self.line_height + elif self.scroll: + self._needs_scroll = self.scroll and text_size.x > self._rect.width + self._rect.height = text_size.y + + def set_color(self, color: rl.Color): + self.color = color + + def set_font_weight(self, font_weight: FontWeight): + self.font_weight = font_weight + self.set_text(self.text) + + def _render(self, rect: rl.Rectangle): + # Only scissor when we know there is a single scrolling line + if self._needs_scroll: + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + + font = gui_app.font(self.font_weight) + + text_y_offset = 0 + # Draw the text in the specified rectangle + lines = self.wrapped_text or [self.text] + if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: + lines = lines[::-1] + + for display_text in lines: + text_size = measure_text_cached(font, display_text, self.font_size, self.spacing) + + # Elide text to fit within the rectangle + if self.elide_right and text_size.x > rect.width: + ellipsis = "..." + left, right = 0, len(display_text) + while left < right: + mid = (left + right) // 2 + candidate = display_text[:mid] + ellipsis + candidate_size = measure_text_cached(font, candidate, self.font_size, self.spacing) + if candidate_size.x <= rect.width: + left = mid + 1 + else: + right = mid + display_text = display_text[: left - 1] + ellipsis if left > 0 else ellipsis + text_size = measure_text_cached(font, display_text, self.font_size, self.spacing) + + # Handle scroll state + elif self.scroll and self._needs_scroll: + if self._scroll_state == ScrollState.STARTING: + if self._scroll_pause_t is None: + self._scroll_pause_t = rl.get_time() + 2.0 + if rl.get_time() >= self._scroll_pause_t: + self._scroll_state = ScrollState.SCROLLING + self._scroll_pause_t = None + + elif self._scroll_state == ScrollState.SCROLLING: + self._scroll_offset -= 0.8 / 60. * gui_app.target_fps + # don't fully hide + if self._scroll_offset <= -text_size.x - self._rect.width / 3: + self._scroll_offset = 0 + self._scroll_state = ScrollState.STARTING + self._scroll_pause_t = None + + # Calculate horizontal position based on alignment + text_x = rect.x + { + rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0, + rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2, + rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x, + }.get(self.alignment, 0) + self._scroll_offset + + # Calculate vertical position based on alignment + text_y = rect.y + { + rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0, + rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2, + rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y, + }.get(self.alignment_vertical, 0) + text_y += text_y_offset + + rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x), text_y), self.font_size, self.spacing, self.color) + # Draw 2nd instance for scrolling + if self._needs_scroll and self._scroll_state != ScrollState.STARTING: + text2_scroll_offset = text_size.x + self._rect.width / 3 + rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x + text2_scroll_offset), text_y), self.font_size, self.spacing, self.color) + if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: + text_y_offset -= self.line_height + else: + text_y_offset += self.line_height + + if self._needs_scroll: + # draw black fade on left and right + fade_width = 20 + rl.draw_rectangle_gradient_h(int(rect.x + rect.width - fade_width), int(rect.y), fade_width, int(rect.height), rl.Color(0, 0, 0, 0), rl.BLACK) + if self._scroll_state != ScrollState.STARTING: + rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), fade_width, int(rect.height), rl.BLACK, rl.Color(0, 0, 0, 0)) + + rl.end_scissor_mode() + + # TODO: This should be a Widget class def gui_label( rect: rl.Rectangle, @@ -65,6 +231,7 @@ def gui_label( }.get(alignment_vertical, 0) # Draw the text in the specified rectangle + # TODO: add wrapping and proper centering for multiline text rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color) @@ -76,11 +243,12 @@ def gui_text_box( alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, font_weight: FontWeight = FontWeight.NORMAL, + line_scale: float = 1.0, ): styles = [ (rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)), (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.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE * line_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) @@ -107,6 +275,7 @@ class Label(Widget): text_color: rl.Color = DEFAULT_TEXT_COLOR, icon: Union[rl.Texture, None] = None, elide_right: bool = False, + line_scale=1.0, ): super().__init__() @@ -119,6 +288,7 @@ class Label(Widget): self._text_color = text_color self._icon = icon self._elide_right = elide_right + self._line_scale = line_scale self._text = text self.set_text(text) @@ -217,4 +387,339 @@ class Label(Widget): 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 * FONT_SCALE + text_pos.y += (text_size.y or self._font_size * FONT_SCALE) * self._line_scale + + +class UnifiedLabel(Widget): + """ + Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel. + + Supports: + - Emoji rendering + - Text wrapping + - Automatic eliding (single-line or multiline) + - Proper multiline vertical alignment + - Height calculation for layout purposes + """ + def __init__(self, + text: str | Callable[[], str], + font_size: int = DEFAULT_TEXT_SIZE, + font_weight: FontWeight = FontWeight.NORMAL, + text_color: rl.Color = DEFAULT_TEXT_COLOR, + alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + text_padding: int = 0, + max_width: int | None = None, + elide: bool = True, + wrap_text: bool = True, + line_height: float = 1.0, + letter_spacing: float = 0.0): + super().__init__() + self._text = text + self._font_size = font_size + self._font_weight = font_weight + self._font = gui_app.font(self._font_weight) + self._text_color = text_color + self._alignment = alignment + self._alignment_vertical = alignment_vertical + self._text_padding = text_padding + self._max_width = max_width + self._elide = elide + self._wrap_text = wrap_text + self._line_height = line_height * 0.9 + self._letter_spacing = letter_spacing # 0.1 = 10% + self._spacing_pixels = font_size * letter_spacing + + # Cached data + self._cached_text: str | None = None + self._cached_wrapped_lines: list[str] = [] + self._cached_line_sizes: list[rl.Vector2] = [] + self._cached_line_emojis: list[list[tuple[int, int, str]]] = [] + self._cached_total_height: float | None = None + self._cached_width: int = -1 + + # If max_width is set, initialize rect size for Scroller support + if max_width is not None: + self._rect.width = max_width + self._rect.height = self.get_content_height(max_width) + + def set_text(self, text: str | Callable[[], str]): + """Update the text content.""" + self._text = text + self._cached_text = None # Invalidate cache + + @property + def text(self) -> str: + """Get the current text content.""" + return str(_resolve_value(self._text)) + + def set_text_color(self, color: rl.Color): + """Update the text color.""" + self._text_color = color + + def set_color(self, color: rl.Color): + """Update the text color (alias for set_text_color).""" + self.set_text_color(color) + + def set_font_size(self, size: int): + """Update the font size.""" + self._font_size = size + self._spacing_pixels = size * self._letter_spacing # Recalculate spacing + self._cached_text = None # Invalidate cache + + def set_letter_spacing(self, letter_spacing: float): + """Update letter spacing (as percentage, e.g., 0.1 = 10%).""" + self._letter_spacing = letter_spacing + self._spacing_pixels = self._font_size * letter_spacing + self._cached_text = None # Invalidate cache + + def set_font_weight(self, font_weight: FontWeight): + """Update the font weight.""" + if self._font_weight != font_weight: + self._font_weight = font_weight + self._font = gui_app.font(self._font_weight) + self._cached_text = None # Invalidate cache + + def set_alignment(self, alignment: int): + """Update the horizontal text alignment.""" + self._alignment = alignment + + def set_alignment_vertical(self, alignment_vertical: int): + """Update the vertical text alignment.""" + self._alignment_vertical = alignment_vertical + + def set_max_width(self, max_width: int | None): + """Set the maximum width constraint for wrapping/eliding.""" + if self._max_width != max_width: + self._max_width = max_width + self._cached_text = None # Invalidate cache + # Update rect size for Scroller support + if max_width is not None: + self._rect.width = max_width + self._rect.height = self.get_content_height(max_width) + + def _update_text_cache(self, available_width: int): + """Update cached text processing data.""" + text = self.text + + # Check if cache is still valid + if (self._cached_text == text and + self._cached_width == available_width and + self._cached_wrapped_lines): + return + + self._cached_text = text + self._cached_width = available_width + + # Determine wrapping width + content_width = available_width - (self._text_padding * 2) + if content_width <= 0: + content_width = 1 + + # Wrap text if enabled + if self._wrap_text: + self._cached_wrapped_lines = wrap_text(self._font, text, self._font_size, content_width, self._spacing_pixels) + else: + # Split by newlines but don't wrap + self._cached_wrapped_lines = text.split('\n') if text else [""] + + # Elide lines if needed (for width constraint) + self._cached_wrapped_lines = [self._elide_line(line, content_width) for line in self._cached_wrapped_lines] + + # Process each line: measure and find emojis + self._cached_line_sizes = [] + self._cached_line_emojis = [] + + for line in self._cached_wrapped_lines: + emojis = find_emoji(line) + self._cached_line_emojis.append(emojis) + # Empty lines should still have height (use font size as line height) + if not line: + size = rl.Vector2(0, self._font_size * FONT_SCALE) + else: + size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels) + self._cached_line_sizes.append(size) + + # Calculate total height + # Each line contributes its measured height * line_height (matching Label's behavior) + # This includes spacing to the next line + if self._cached_line_sizes: + # Match the rendering logic: first line doesn't get line_height scaling + total_height = 0.0 + for idx, size in enumerate(self._cached_line_sizes): + if idx == 0: + total_height += size.y + else: + total_height += size.y * self._line_height + self._cached_total_height = total_height + else: + self._cached_total_height = 0.0 + + def _elide_line(self, line: str, max_width: int, force: bool = False) -> str: + """Elide a single line if it exceeds max_width. If force is True, always elide even if it fits.""" + if not self._elide and not force: + return line + + text_size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels) + if text_size.x <= max_width and not force: + return line + + ellipsis = "..." + # If force=True and line fits, just append ellipsis without truncating + if force and text_size.x <= max_width: + ellipsis_size = measure_text_cached(self._font, ellipsis, self._font_size, self._spacing_pixels) + if text_size.x + ellipsis_size.x <= max_width: + return line + ellipsis + # If line + ellipsis doesn't fit, need to truncate + # Fall through to binary search below + + left, right = 0, len(line) + while left < right: + mid = (left + right) // 2 + candidate = line[:mid] + ellipsis + candidate_size = measure_text_cached(self._font, candidate, self._font_size, self._spacing_pixels) + if candidate_size.x <= max_width: + left = mid + 1 + else: + right = mid + return line[:left - 1] + ellipsis if left > 0 else ellipsis + + def get_content_height(self, max_width: int) -> float: + """ + Returns the height needed for text at given max_width. + Similar to HtmlRenderer.get_total_height(). + """ + # Use max_width if provided, otherwise use self._max_width or a default + width = max_width if max_width > 0 else (self._max_width if self._max_width else 1000) + self._update_text_cache(width) + + if self._cached_total_height is not None: + return self._cached_total_height + return 0.0 + + def _render(self, rect: rl.Rectangle): + """Render the label.""" + if rect.width <= 0 or rect.height <= 0: + return + + # Determine available width + available_width = rect.width + if self._max_width is not None: + available_width = min(available_width, self._max_width) + + # Update text cache + self._update_text_cache(int(available_width)) + + if not self._cached_wrapped_lines: + return + + # Calculate which lines fit in the available height + visible_lines: list[str] = [] + visible_sizes: list[rl.Vector2] = [] + visible_emojis: list[list[tuple[int, int, str]]] = [] + + current_height = 0.0 + broke_early = False + for line, size, emojis in zip( + self._cached_wrapped_lines, + self._cached_line_sizes, + self._cached_line_emojis, + strict=True): + + # Calculate height needed for this line + # Each line contributes its height * line_height (matching Label's behavior) + line_height_needed = size.y * self._line_height + + # Check if this line fits + if current_height + line_height_needed > rect.height: + # This line doesn't fit + if len(visible_lines) == 0: + # First line doesn't fit by height - still show it (will be clipped by scissor if needed) + # Continue to add this line below + pass + else: + # We have visible lines and this one doesn't fit - mark that we broke early + broke_early = True + break + + visible_lines.append(line) + visible_sizes.append(size) + visible_emojis.append(emojis) + + current_height += line_height_needed + + # If we broke early (there are more lines that don't fit) and elide is enabled, elide the last visible line + if broke_early and len(visible_lines) > 0 and self._elide: + content_width = int(available_width - (self._text_padding * 2)) + if content_width <= 0: + content_width = 1 + + last_line_idx = len(visible_lines) - 1 + last_line = visible_lines[last_line_idx] + # Force elide the last line to show "..." even if it fits in width (to indicate more content) + elided = self._elide_line(last_line, content_width, force=True) + visible_lines[last_line_idx] = elided + visible_sizes[last_line_idx] = measure_text_cached(self._font, elided, self._font_size, self._spacing_pixels) + + if not visible_lines: + return + + # Calculate total visible text block height + # First line is not changed by line_height scaling + total_visible_height = 0.0 + for idx, size in enumerate(visible_sizes): + if idx == 0: + total_visible_height += size.y + else: + total_visible_height += size.y * self._line_height + + # Calculate vertical alignment offset + if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: + start_y = rect.y + elif self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: + start_y = rect.y + rect.height - total_visible_height + else: # TEXT_ALIGN_MIDDLE + start_y = rect.y + (rect.height - total_visible_height) / 2 + + # Render each line + current_y = start_y + for idx, (line, size, emojis) in enumerate(zip(visible_lines, visible_sizes, visible_emojis, strict=True)): + # Calculate horizontal position + if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: + line_x = rect.x + self._text_padding + elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: + line_x = rect.x + (rect.width - size.x) / 2 + elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: + line_x = rect.x + rect.width - size.x - self._text_padding + else: + line_x = rect.x + self._text_padding + + # Render line with emojis + line_pos = rl.Vector2(line_x, current_y) + prev_index = 0 + + for start, end, emoji in emojis: + # Draw text before emoji + text_before = line[prev_index:start] + if text_before: + rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, self._spacing_pixels, self._text_color) + width_before = measure_text_cached(self._font, text_before, self._font_size, self._spacing_pixels) + line_pos.x += width_before.x + + # Draw emoji + tex = emoji_tex(emoji) + emoji_scale = self._font_size / tex.height * FONT_SCALE + rl.draw_texture_ex(tex, line_pos, 0.0, emoji_scale, self._text_color) + # Emoji width is font_size * FONT_SCALE (as per measure_text_cached) + line_pos.x += self._font_size * FONT_SCALE + prev_index = end + + # Draw remaining text after last emoji + text_after = line[prev_index:] + if text_after: + rl.draw_text_ex(self._font, text_after, line_pos, self._font_size, self._spacing_pixels, self._text_color) + + # Move to next line (if not last line) + if idx < len(visible_lines) - 1: + # Use current line's height * line_height for spacing to next line + current_y += size.y * self._line_height diff --git a/system/ui/widgets/mici_keyboard.py b/system/ui/widgets/mici_keyboard.py new file mode 100644 index 0000000000..2eb24cdb97 --- /dev/null +++ b/system/ui/widgets/mici_keyboard.py @@ -0,0 +1,388 @@ +from enum import IntEnum +import pyray as rl +import numpy as np +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget +from openpilot.common.filter_simple import BounceFilter + +CHAR_FONT_SIZE = 42 +CHAR_NEAR_FONT_SIZE = CHAR_FONT_SIZE * 2 +SELECTED_CHAR_FONT_SIZE = 128 +CHAR_CAPS_FONT_SIZE = 38 # TODO: implement this +NUMBER_LAYER_SWITCH_FONT_SIZE = 24 +KEYBOARD_COLUMN_PADDING = 33 +KEYBOARD_ROW_PADDING = {0: 44, 1: 33, 2: 44} # TODO: 2 should be 116 with extra control keys added in + +KEY_TOUCH_AREA_OFFSET = 10 # px +KEY_DRAG_HYSTERESIS = 5 # px +KEY_MIN_ANIMATION_TIME = 0.075 # s + +DEBUG = False +ANIMATION_SCALE = 0.65 + + +def zip_repeat(a, b): + la, lb = len(a), len(b) + for i in range(max(la, lb)): + yield (a[i] if i < la else a[-1], + b[i] if i < lb else b[-1]) + + +def fast_euclidean_distance(dx, dy): + # https://en.wikibooks.org/wiki/Algorithms/Distance_approximations + max_d, min_d = abs(dx), abs(dy) + if max_d < min_d: + max_d, min_d = min_d, max_d + return 0.941246 * max_d + 0.41 * min_d + + +class Key(Widget): + def __init__(self, char: str): + super().__init__() + self.char = char + self._font = gui_app.font(FontWeight.SEMI_BOLD) + self._x_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps) + self._y_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps) + self._size_filter = BounceFilter(CHAR_FONT_SIZE, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps) + self._alpha_filter = BounceFilter(1.0, 0.075 * ANIMATION_SCALE, 1 / gui_app.target_fps) + + self._color = rl.Color(255, 255, 255, 255) + + self._position_initialized = False + self.original_position = rl.Vector2(0, 0) + + def set_position(self, x: float, y: float, smooth: bool = True): + # TODO: swipe up from NavWidget has the keys lag behind other elements a bit + if not self._position_initialized: + self._x_filter.x = x + self._y_filter.x = y + # keep track of original position so dragging around feels consistent. also move touch area down a bit + self.original_position = rl.Vector2(x, y + KEY_TOUCH_AREA_OFFSET) + self._position_initialized = True + + if not smooth: + self._x_filter.x = x + self._y_filter.x = y + + self._rect.x = self._x_filter.update(x) + self._rect.y = self._y_filter.update(y) + + def set_alpha(self, alpha: float): + self._alpha_filter.update(alpha) + + def get_position(self) -> tuple[float, float]: + return self._rect.x, self._rect.y + + def _update_state(self): + self._color.a = min(int(255 * self._alpha_filter.x), 255) + + def _render(self, _): + # center char at rect position + text_size = measure_text_cached(self._font, self.char, self._get_font_size()) + x = self._rect.x + self._rect.width / 2 - text_size.x / 2 + y = self._rect.y + self._rect.height / 2 - text_size.y / 2 + rl.draw_text_ex(self._font, self.char, (x, y), self._get_font_size(), 0, self._color) + + if DEBUG: + rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key + rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED) + + def set_font_size(self, size: float): + self._size_filter.update(size) + + def _get_font_size(self) -> int: + return int(round(self._size_filter.x)) + + +class SmallKey(Key): + def __init__(self, chars: str): + super().__init__(chars) + self._size_filter.x = NUMBER_LAYER_SWITCH_FONT_SIZE + + def set_font_size(self, size: float): + self._size_filter.update(size * (NUMBER_LAYER_SWITCH_FONT_SIZE / CHAR_FONT_SIZE)) + + +class IconKey(Key): + def __init__(self, icon: str, vertical_align: str = "center", char: str = ""): + super().__init__(char) + self._icon = gui_app.texture(icon, 38, 38) + self._vertical_align = vertical_align + + def set_icon(self, icon: str): + self._icon = gui_app.texture(icon, 38, 38) + + def _render(self, _): + scale = np.interp(self._size_filter.x, [CHAR_FONT_SIZE, CHAR_NEAR_FONT_SIZE], [1, 1.5]) + + if self._vertical_align == "center": + dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2, + self._rect.y + (self._rect.height - self._icon.height * scale) / 2, + self._icon.width * scale, self._icon.height * scale) + src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height) + rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color) + + rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color) + + elif self._vertical_align == "bottom": + dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2, self._rect.y, + self._icon.width * scale, self._icon.height * scale) + src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height) + rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color) + + if DEBUG: + rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key + rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED) + + +class CapsState(IntEnum): + LOWER = 0 + UPPER = 1 + LOCK = 2 + + +class MiciKeyboard(Widget): + def __init__(self): + super().__init__() + + lower_chars = [ + "qwertyuiop", + "asdfghjkl", + "zxcvbnm", + ] + upper_chars = ["".join([char.upper() for char in row]) for row in lower_chars] + special_chars = [ + "1234567890", + "-/:;()$&@\"", + "~.,?!'#%", + ] + super_special_chars = [ + "1234567890", + "`[]{}^*+=_", + "\\|<>¥€£•", + ] + + self._lower_keys = [[Key(char) for char in row] for row in lower_chars] + self._upper_keys = [[Key(char) for char in row] for row in upper_chars] + self._special_keys = [[Key(char) for char in row] for row in special_chars] + self._super_special_keys = [[Key(char) for char in row] for row in super_special_chars] + + # control keys + self._space_key = IconKey("icons_mici/settings/keyboard/space.png", char=" ", vertical_align="bottom") + self._caps_key = IconKey("icons_mici/settings/keyboard/caps_lower.png") + # these two are in different places on some layouts + self._123_key, self._123_key2 = SmallKey("123"), SmallKey("123") + self._abc_key = SmallKey("abc") + self._super_special_key = SmallKey("#+=") + + # insert control keys + for keys in (self._lower_keys, self._upper_keys): + keys[2].insert(0, self._caps_key) + keys[2].append(self._123_key) + + for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys): + keys[1].append(self._space_key) + + for keys in (self._special_keys, self._super_special_keys): + keys[2].append(self._abc_key) + + self._special_keys[2].insert(0, self._super_special_key) + self._super_special_keys[2].insert(0, self._123_key2) + + # set initial keys + self._current_keys: list[list[Key]] = [] + self._set_keys(self._lower_keys) + self._caps_state = CapsState.LOWER + self._initialized = False + + self._load_images() + + self._closest_key: tuple[Key | None, float] = None, float('inf') + self._selected_key_t: float | None = None # time key was initially selected + self._unselect_key_t: float | None = None # time to unselect key after release + self._dragging_on_keyboard = False + + self._text: str = "" + + self._bg_scale_filter = BounceFilter(1.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps) + + def get_candidate_character(self) -> str: + # return str of character about to be added to text + key = self._closest_key[0] + return key.char if key is not None and key.__class__ is Key and self._dragging_on_keyboard else "" + + def get_keyboard_height(self) -> int: + return int(self._txt_bg.height) + + def _load_images(self): + self._txt_bg = gui_app.texture("icons_mici/settings/keyboard/keyboard_background.png", 520, 170, keep_aspect_ratio=False) + + def _set_keys(self, keys: list[list[Key]]): + # inherit previous keys' positions to fix switching animation + for current_row, row in zip(self._current_keys, keys, strict=False): + # not all layouts have the same number of keys + for current_key, key in zip_repeat(current_row, row): + current_pos = current_key.get_position() + key.set_position(current_pos[0], current_pos[1], smooth=False) + + self._current_keys = keys + + def set_text(self, text: str): + self._text = text + + def text(self) -> str: + return self._text + + def _handle_mouse_event(self, mouse_event: MouseEvent) -> None: + keyboard_pos_y = self._rect.y + self._rect.height - self._txt_bg.height + if mouse_event.left_pressed: + if mouse_event.pos.y > keyboard_pos_y: + self._dragging_on_keyboard = True + elif mouse_event.left_released: + self._dragging_on_keyboard = False + + if mouse_event.left_down and self._dragging_on_keyboard: + self._closest_key = self._get_closest_key() + if self._selected_key_t is None: + self._selected_key_t = rl.get_time() + + # unselect key temporarily if mouse goes above keyboard + if mouse_event.pos.y <= keyboard_pos_y: + self._closest_key = (None, float('inf')) + + if DEBUG: + print('HANDLE MOUSE EVENT', mouse_event, self._closest_key[0].char if self._closest_key[0] else 'None') + + def _get_closest_key(self) -> tuple[Key | None, float]: + closest_key: tuple[Key | None, float] = (None, float('inf')) + for row in self._current_keys: + for key in row: + mouse_pos = gui_app.last_mouse_event.pos + # approximate distance for comparison is accurate enough + dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - mouse_pos.y) + if dist < closest_key[1]: + if self._closest_key[0] is None or key is self._closest_key[0] or dist < self._closest_key[1] - KEY_DRAG_HYSTERESIS: + closest_key = (key, dist) + return closest_key + + def _set_uppercase(self, cycle: bool): + self._set_keys(self._upper_keys if cycle else self._lower_keys) + if not cycle: + self._caps_state = CapsState.LOWER + self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lower.png") + else: + if self._caps_state == CapsState.LOWER: + self._caps_state = CapsState.UPPER + self._caps_key.set_icon("icons_mici/settings/keyboard/caps_upper.png") + elif self._caps_state == CapsState.UPPER: + self._caps_state = CapsState.LOCK + self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lock.png") + else: + self._set_uppercase(False) + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._closest_key[0] is not None: + if self._closest_key[0] == self._caps_key: + self._set_uppercase(True) + elif self._closest_key[0] in (self._123_key, self._123_key2): + self._set_keys(self._special_keys) + elif self._closest_key[0] == self._abc_key: + self._set_uppercase(False) + elif self._closest_key[0] == self._super_special_key: + self._set_keys(self._super_special_keys) + else: + self._text += self._closest_key[0].char + + # Reset caps state + if self._caps_state == CapsState.UPPER: + self._set_uppercase(False) + + # ensure minimum selected animation time + key_selected_dt = rl.get_time() - (self._selected_key_t or 0) + cur_t = rl.get_time() + self._unselect_key_t = cur_t + KEY_MIN_ANIMATION_TIME if (key_selected_dt < KEY_MIN_ANIMATION_TIME) else cur_t + + def backspace(self): + if self._text: + self._text = self._text[:-1] + + def space(self): + self._text += ' ' + + def _update_state(self): + # unselect key after animation plays + if self._unselect_key_t is not None and rl.get_time() > self._unselect_key_t: + self._closest_key = (None, float('inf')) + self._unselect_key_t = None + self._selected_key_t = None + + def _lay_out_keys(self, bg_x, bg_y, keys: list[list[Key]]): + key_rect = rl.Rectangle(bg_x, bg_y, self._txt_bg.width, self._txt_bg.height) + for row_idx, row in enumerate(keys): + padding = KEYBOARD_ROW_PADDING[row_idx] + step_y = (key_rect.height - 2 * KEYBOARD_COLUMN_PADDING) / (len(keys) - 1) + for key_idx, key in enumerate(row): + key_x = key_rect.x + padding + key_idx * ((key_rect.width - 2 * padding) / (len(row) - 1)) + key_y = key_rect.y + KEYBOARD_COLUMN_PADDING + row_idx * step_y + + if self._closest_key[0] is None: + key.set_alpha(1.0) + key.set_font_size(CHAR_FONT_SIZE) + elif key == self._closest_key[0]: + # push key up with a max and inward so user can see key easier + key_y = max(key_y - 120, 40) + key_x += np.interp(key_x, [self._rect.x, self._rect.x + self._rect.width], [100, -100]) + key.set_alpha(1.0) + key.set_font_size(SELECTED_CHAR_FONT_SIZE) + + # draw black circle behind selected key + rl.draw_circle_gradient(int(key_x + key.rect.width / 2), int(key_y + key.rect.height / 2), + SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, 225), rl.BLANK) + else: + # move other keys away from selected key a bit + dx = key.original_position.x - self._closest_key[0].original_position.x + dy = key.original_position.y - self._closest_key[0].original_position.y + distance_from_selected_key = fast_euclidean_distance(dx, dy) + + inv = 1 / (distance_from_selected_key or 1.0) + ux = dx * inv + uy = dy * inv + + # NOTE: hardcode to 20 to get entire keyboard to move + push_pixels = np.interp(distance_from_selected_key, [0, 250], [20, 0]) + key_x += ux * push_pixels + key_y += uy * push_pixels + + # TODO: slow enough to use an approximation or nah? also caching might work + font_size = np.interp(distance_from_selected_key, [0, 150], [CHAR_NEAR_FONT_SIZE, CHAR_FONT_SIZE]) + + key_alpha = np.interp(distance_from_selected_key, [0, 100], [1.0, 0.35]) + key.set_alpha(key_alpha) + key.set_font_size(font_size) + + # TODO: I like the push amount, so we should clip the pos inside the keyboard rect + key.set_position(key_x, key_y) + + def _render(self, _): + # draw bg + bg_x = self._rect.x + (self._rect.width - self._txt_bg.width) / 2 + bg_y = self._rect.y + self._rect.height - self._txt_bg.height + + scale = self._bg_scale_filter.update(1.0307692307692307 if self._closest_key[0] is not None else 1.0) + src_rec = rl.Rectangle(0, 0, self._txt_bg.width, self._txt_bg.height) + dest_rec = rl.Rectangle(self._rect.x + self._rect.width / 2 - self._txt_bg.width * scale / 2, bg_y, + self._txt_bg.width * scale, self._txt_bg.height) + + rl.draw_texture_pro(self._txt_bg, src_rec, dest_rec, rl.Vector2(0, 0), 0.0, rl.WHITE) + + # draw keys + if not self._initialized: + for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys): + self._lay_out_keys(bg_x, bg_y, keys) + self._initialized = True + + self._lay_out_keys(bg_x, bg_y, self._current_keys) + for row in self._current_keys: + for key in row: + key.render() diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index 592c9de971..f41a04c249 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -12,7 +12,7 @@ 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 gui_label -from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item # These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI diff --git a/system/ui/widgets/option_dialog.py b/system/ui/widgets/option_dialog.py index 3b2201164a..62578d1cfb 100644 --- a/system/ui/widgets/option_dialog.py +++ b/system/ui/widgets/option_dialog.py @@ -4,7 +4,7 @@ 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 +from openpilot.system.ui.widgets.scroller_tici import Scroller # Constants MARGIN = 50 diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index a843010d56..fb7f635be0 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -1,10 +1,21 @@ import pyray as rl -from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel +import numpy as np +from collections.abc import Callable + +from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState from openpilot.system.ui.widgets import Widget -ITEM_SPACING = 40 +ITEM_SPACING = 20 LINE_COLOR = rl.GRAY LINE_PADDING = 40 +ANIMATION_SCALE = 0.6 + +MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds +DO_ZOOM = False +DO_JELLO = False +SCROLL_BAR = False class LineSeparator(Widget): @@ -23,21 +34,131 @@ class LineSeparator(Widget): class Scroller(Widget): - def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True): + def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = True, spacing: int = ITEM_SPACING, + line_separator: bool = False, pad_start: int = ITEM_SPACING, pad_end: int = ITEM_SPACING): super().__init__() self._items: list[Widget] = [] + self._horizontal = horizontal + self._snap_items = snap_items self._spacing = spacing self._line_separator = LineSeparator() if line_separator else None + self._pad_start = pad_start self._pad_end = pad_end - self.scroll_panel = GuiScrollPanel() + self._reset_scroll_at_show = True + + self._scrolling_to: float | None = None + self._scroll_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + self._zoom_filter = FirstOrderFilter(1.0, 0.2, 1 / gui_app.target_fps) + self._zoom_out_t: float = 0.0 + + self._item_pos_filter = BounceFilter(0.0, 0.05, 1 / gui_app.target_fps) + + # when not pressed, snap to closest item to be center + self._scroll_snap_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps) + + self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items) + self._scroll_enabled: bool | Callable[[], bool] = True + + self._txt_scroll_indicator = gui_app.texture("icons_mici/settings/vertical_scroll_indicator.png", 40, 80) for item in items: self.add_widget(item) + def set_reset_scroll_at_show(self, scroll: bool): + self._reset_scroll_at_show = scroll + + def scroll_to(self, pos: float, smooth: bool = False): + # already there + if abs(pos) < 1: + return + + # FIXME: the padding correction doesn't seem correct + scroll_offset = self.scroll_panel.get_offset() - pos + self._pad_end + if smooth: + self._scrolling_to = scroll_offset + else: + self.scroll_panel.set_offset(scroll_offset) + + @property + def is_auto_scrolling(self) -> bool: + return self._scrolling_to is not None + def add_widget(self, item: Widget) -> None: self._items.append(item) - item.set_touch_valid_callback(self.scroll_panel.is_touch_valid) + item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled) + + def set_scrolling_enabled(self, enabled: bool | Callable[[], bool]) -> None: + """Set whether scrolling is enabled (does not affect widget enabled state).""" + self._scroll_enabled = enabled + + def _update_state(self): + if DO_ZOOM: + if self._scrolling_to is not None or self.scroll_panel.state != ScrollState.STEADY: + self._zoom_out_t = rl.get_time() + MIN_ZOOM_ANIMATION_TIME + self._zoom_filter.update(0.85) + else: + if self._zoom_out_t is not None: + if rl.get_time() > self._zoom_out_t: + self._zoom_filter.update(1.0) + else: + self._zoom_filter.update(0.85) + + # Cancel auto-scroll if user starts manually scrolling + if self._scrolling_to is not None and (self.scroll_panel.state == ScrollState.PRESSED or self.scroll_panel.state == ScrollState.MANUAL_SCROLL): + self._scrolling_to = None + + if self._scrolling_to is not None: + self._scroll_filter.update(self._scrolling_to) + self.scroll_panel.set_offset(self._scroll_filter.x) + + if abs(self._scroll_filter.x - self._scrolling_to) < 1: + self.scroll_panel.set_offset(self._scrolling_to) + self._scrolling_to = None + else: + # keep current scroll position up to date + self._scroll_filter.x = self.scroll_panel.get_offset() + + def _get_scroll(self, visible_items: list[Widget], content_size: float) -> float: + scroll_enabled = self._scroll_enabled() if callable(self._scroll_enabled) else self._scroll_enabled + self.scroll_panel.set_enabled(scroll_enabled and self.enabled) + self.scroll_panel.update(self._rect, content_size) + if not self._snap_items: + return self.scroll_panel.get_offset() + + # Snap closest item to center + center_pos = self._rect.x + self._rect.width / 2 if self._horizontal else self._rect.y + self._rect.height / 2 + closest_delta_pos = float('inf') + scroll_snap_idx: int | None = None + for idx, item in enumerate(visible_items): + if self._horizontal: + delta_pos = (item.rect.x + item.rect.width / 2) - center_pos + else: + delta_pos = (item.rect.y + item.rect.height / 2) - center_pos + if abs(delta_pos) < abs(closest_delta_pos): + closest_delta_pos = delta_pos + scroll_snap_idx = idx + + if scroll_snap_idx is not None: + snap_item = visible_items[scroll_snap_idx] + if self.is_pressed: + # no snapping until released + self._scroll_snap_filter.x = 0 + else: + # TODO: this doesn't handle two small buttons at the edges well + if self._horizontal: + snap_delta_pos = (center_pos - (snap_item.rect.x + snap_item.rect.width / 2)) / 10 + snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10) + snap_delta_pos = max(snap_delta_pos, (self._rect.width - self.scroll_panel.get_offset() - content_size) / 10) + else: + snap_delta_pos = (center_pos - (snap_item.rect.y + snap_item.rect.height / 2)) / 10 + snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10) + snap_delta_pos = max(snap_delta_pos, (self._rect.height - self.scroll_panel.get_offset() - content_size) / 10) + self._scroll_snap_filter.update(snap_delta_pos) + + self.scroll_panel.set_offset(self.scroll_panel.get_offset() + self._scroll_snap_filter.x) + + return self.scroll_panel.get_offset() def _render(self, _): # TODO: don't draw items that are not in the viewport @@ -49,38 +170,78 @@ class Scroller(Widget): for i in range(1, len(visible_items)): visible_items.insert(l - i, self._line_separator) - 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.update(self._rect, rl.Rectangle(0, 0, self._rect.width, content_height)) + content_size = sum(item.rect.width if self._horizontal else item.rect.height for item in visible_items) + content_size += self._spacing * (len(visible_items) - 1) + content_size += self._pad_start + self._pad_end + + scroll_offset = self._get_scroll(visible_items, content_size) rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y), int(self._rect.width), int(self._rect.height)) - cur_height = 0 - for idx, item in enumerate(visible_items): - if not item.is_visible: - continue + self._item_pos_filter.update(scroll_offset) - # Nicely lay out items vertically - x = self._rect.x - y = self._rect.y + cur_height + self._spacing * (idx != 0) - cur_height += item.rect.height + self._spacing * (idx != 0) + cur_pos = 0 + for idx, item in enumerate(visible_items): + spacing = self._spacing if (idx > 0) else self._pad_start + # Nicely lay out items horizontally/vertically + if self._horizontal: + x = self._rect.x + cur_pos + spacing + y = self._rect.y + (self._rect.height - item.rect.height) / 2 + cur_pos += item.rect.width + spacing + else: + x = self._rect.x + (self._rect.width - item.rect.width) / 2 + y = self._rect.y + cur_pos + spacing + cur_pos += item.rect.height + spacing # Consider scroll - y += scroll + if self._horizontal: + x += scroll_offset + else: + y += scroll_offset + + # Add some jello effect when scrolling + if DO_JELLO: + if self._horizontal: + cx = self._rect.x + self._rect.width / 2 + jello_offset = scroll_offset - np.interp(x + item.rect.width / 2, + [self._rect.x, cx, self._rect.x + self._rect.width], + [self._item_pos_filter.x, scroll_offset, self._item_pos_filter.x]) + x -= np.clip(jello_offset, -20, 20) + else: + cy = self._rect.y + self._rect.height / 2 + jello_offset = scroll_offset - np.interp(y + item.rect.height / 2, + [self._rect.y, cy, self._rect.y + self._rect.height], + [self._item_pos_filter.x, scroll_offset, self._item_pos_filter.x]) + y -= np.clip(jello_offset, -20, 20) # Update item state - item.set_position(x, y) + item.set_position(round(x), round(y)) # round to prevent jumping when settling item.set_parent_rect(self._rect) + + # Scale each element around its own origin when scrolling + scale = self._zoom_filter.x + rl.rl_push_matrix() + rl.rl_scalef(scale, scale, 1.0) + rl.rl_translatef((1 - scale) * (x + item.rect.width / 2) / scale, + (1 - scale) * (y + item.rect.height / 2) / scale, 0) item.render() + rl.rl_pop_matrix() + + # Draw scroll indicator + if SCROLL_BAR and not self._horizontal and len(visible_items) > 0: + _real_content_size = content_size - self._rect.height + self._txt_scroll_indicator.height + scroll_bar_y = -scroll_offset / _real_content_size * self._rect.height + scroll_bar_y = min(max(scroll_bar_y, self._rect.y), self._rect.y + self._rect.height - self._txt_scroll_indicator.height) + rl.draw_texture_ex(self._txt_scroll_indicator, rl.Vector2(self._rect.x, scroll_bar_y), 0, 1.0, rl.WHITE) rl.end_scissor_mode() def show_event(self): super().show_event() - # Reset to top - self.scroll_panel.set_offset(0) + if self._reset_scroll_at_show: + self.scroll_to(self.scroll_panel.get_offset()) + for item in self._items: item.show_event() diff --git a/system/ui/widgets/scroller_tici.py b/system/ui/widgets/scroller_tici.py new file mode 100644 index 0000000000..a843010d56 --- /dev/null +++ b/system/ui/widgets/scroller_tici.py @@ -0,0 +1,90 @@ +import pyray as rl +from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel +from openpilot.system.ui.widgets import Widget + +ITEM_SPACING = 40 +LINE_COLOR = rl.GRAY +LINE_PADDING = 40 + + +class LineSeparator(Widget): + def __init__(self, height: int = 1): + super().__init__() + self._rect = rl.Rectangle(0, 0, 0, height) + + def set_parent_rect(self, parent_rect: rl.Rectangle) -> None: + super().set_parent_rect(parent_rect) + self._rect.width = parent_rect.width + + def _render(self, _): + rl.draw_line(int(self._rect.x) + LINE_PADDING, int(self._rect.y), + int(self._rect.x + self._rect.width) - LINE_PADDING, int(self._rect.y), + LINE_COLOR) + + +class Scroller(Widget): + def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True): + super().__init__() + self._items: list[Widget] = [] + self._spacing = spacing + self._line_separator = LineSeparator() if line_separator else None + self._pad_end = pad_end + + self.scroll_panel = GuiScrollPanel() + + for item in items: + self.add_widget(item) + + def add_widget(self, item: Widget) -> None: + self._items.append(item) + item.set_touch_valid_callback(self.scroll_panel.is_touch_valid) + + def _render(self, _): + # TODO: don't draw items that are not in the viewport + visible_items = [item for item in self._items if item.is_visible] + + # Add line separator between items + if self._line_separator is not None: + l = len(visible_items) + for i in range(1, len(visible_items)): + visible_items.insert(l - i, self._line_separator) + + 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.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)) + + cur_height = 0 + for idx, item in enumerate(visible_items): + if not item.is_visible: + continue + + # Nicely lay out items vertically + x = self._rect.x + y = self._rect.y + cur_height + self._spacing * (idx != 0) + cur_height += item.rect.height + self._spacing * (idx != 0) + + # Consider scroll + y += scroll + + # Update item state + item.set_position(x, y) + item.set_parent_rect(self._rect) + 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/slider.py b/system/ui/widgets/slider.py new file mode 100644 index 0000000000..b17d8f3b7c --- /dev/null +++ b/system/ui/widgets/slider.py @@ -0,0 +1,183 @@ +from collections.abc import Callable + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.common.filter_simple import FirstOrderFilter + + +class SmallSlider(Widget): + HORIZONTAL_PADDING = 8 + CONFIRM_DELAY = 0.2 + + def __init__(self, title: str, confirm_callback: Callable | None = None): + # TODO: unify this with BigConfirmationDialogV2 + super().__init__() + self._confirm_callback = confirm_callback + + self._font = gui_app.font(FontWeight.DISPLAY) + + self._load_assets() + + self._drag_threshold = -self._rect.width // 2 + + # State + self._opacity = 1.0 + self._confirmed_time = 0.0 + self._confirm_callback_called = False # we keep dialog open by default, only call once + self._start_x_circle = 0.0 + self._scroll_x_circle = 0.0 + self._scroll_x_circle_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) + + self._is_dragging_circle = False + + self._label = UnifiedLabel(title, font_size=36, font_weight=FontWeight.MEDIUM, text_color=rl.Color(255, 255, 255, int(255 * 0.65)), + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9) + + def _load_assets(self): + self.set_rect(rl.Rectangle(0, 0, 316 + self.HORIZONTAL_PADDING * 2, 100)) + + self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg.png", 316, 100) + self._circle_bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_red_circle.png", 100, 100) + self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 37, 32) + + @property + def confirmed(self) -> bool: + return self._confirmed_time > 0.0 + + def reset(self): + # reset all slider state + self._is_dragging_circle = False + self._confirmed_time = 0.0 + self._confirm_callback_called = False + + def set_opacity(self, opacity: float): + self._opacity = opacity + + @property + def slider_percentage(self): + activated_pos = -self._bg_txt.width + self._circle_bg_txt.width + return min(max(-self._scroll_x_circle_filter.x / abs(activated_pos), 0.0), 1.0) + + def _on_confirm(self): + if self._confirm_callback: + self._confirm_callback() + + def _handle_mouse_event(self, mouse_event): + super()._handle_mouse_event(mouse_event) + + if mouse_event.left_pressed: + # touch rect goes to the padding + circle_button_rect = rl.Rectangle( + self._rect.x + (self._rect.width - self._circle_bg_txt.width) + self._scroll_x_circle_filter.x - self.HORIZONTAL_PADDING * 2, + self._rect.y, + self._circle_bg_txt.width + self.HORIZONTAL_PADDING * 2, + self._rect.height, + ) + if rl.check_collision_point_rec(mouse_event.pos, circle_button_rect): + self._start_x_circle = mouse_event.pos.x + self._is_dragging_circle = True + + elif mouse_event.left_released: + # swiped to left + if self._scroll_x_circle_filter.x < self._drag_threshold: + self._confirmed_time = rl.get_time() + + self._is_dragging_circle = False + + if self._is_dragging_circle: + self._scroll_x_circle = mouse_event.pos.x - self._start_x_circle + + def _update_state(self): + super()._update_state() + # TODO: this math can probably be cleaned up to remove duplicate stuff + activated_pos = int(-self._bg_txt.width + self._circle_bg_txt.width) + self._scroll_x_circle = max(min(self._scroll_x_circle, 0), activated_pos) + + if self._confirmed_time > 0: + # swiped left to confirm + self._scroll_x_circle_filter.update(activated_pos) + + # activate once animation completes, small threshold for small floats + if self._scroll_x_circle_filter.x < (activated_pos + 1): + if not self._confirm_callback_called and (rl.get_time() - self._confirmed_time) >= self.CONFIRM_DELAY: + self._on_confirm() + self._confirm_callback_called = True + + elif not self._is_dragging_circle: + # reset back to right + self._scroll_x_circle_filter.update(0) + else: + # not activated yet, keep movement 1:1 + self._scroll_x_circle_filter.x = self._scroll_x_circle + + def _render(self, _): + # TODO: iOS text shimmering animation + + white = rl.Color(255, 255, 255, int(255 * self._opacity)) + + bg_txt_x = self._rect.x + (self._rect.width - self._bg_txt.width) / 2 + bg_txt_y = self._rect.y + (self._rect.height - self._bg_txt.height) / 2 + rl.draw_texture_ex(self._bg_txt, rl.Vector2(bg_txt_x, bg_txt_y), 0.0, 1.0, white) + + btn_x = bg_txt_x + self._bg_txt.width - self._circle_bg_txt.width + self._scroll_x_circle_filter.x + btn_y = self._rect.y + (self._rect.height - self._circle_bg_txt.height) / 2 + + if self._confirmed_time == 0.0 or self._scroll_x_circle > 0: + self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.65 * (1.0 - self.slider_percentage) * self._opacity))) + label_rect = rl.Rectangle( + self._rect.x + 20, + self._rect.y, + self._rect.width - self._circle_bg_txt.width - 20 * 3, + self._rect.height, + ) + self._label.render(label_rect) + + # circle and arrow + rl.draw_texture_ex(self._circle_bg_txt, rl.Vector2(btn_x, btn_y), 0.0, 1.0, white) + + arrow_x = btn_x + (self._circle_bg_txt.width - self._circle_arrow_txt.width) / 2 + arrow_y = btn_y + (self._circle_bg_txt.height - self._circle_arrow_txt.height) / 2 + rl.draw_texture_ex(self._circle_arrow_txt, rl.Vector2(arrow_x, arrow_y), 0.0, 1.0, white) + + +class LargerSlider(SmallSlider): + def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True): + self._green = green + super().__init__(title, confirm_callback=confirm_callback) + + def _load_assets(self): + self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 115)) + + self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg_larger.png", 520, 115) + circle_fn = "slider_green_rounded_rectangle" if self._green else "slider_black_rounded_rectangle" + self._circle_bg_txt = gui_app.texture(f"icons_mici/setup/small_slider/{circle_fn}.png", 180, 115) + self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 55) + + +class BigSlider(SmallSlider): + def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable | None = None): + self._icon = icon + super().__init__(title, confirm_callback=confirm_callback) + self._label = UnifiedLabel(title, font_size=48, font_weight=FontWeight.DISPLAY, text_color=rl.Color(255, 255, 255, int(255 * 0.65)), + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + line_height=0.875) + + def _load_assets(self): + self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180)) + + self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180) + self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180) + self._circle_arrow_txt = self._icon + + +class RedBigSlider(BigSlider): + def _load_assets(self): + self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180)) + + self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180) + self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180) + self._circle_arrow_txt = self._icon From e899f46727ee1106a4061bc9f4a092c1ceae3ca0 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 18 Nov 2025 22:35:32 -0800 Subject: [PATCH 19/48] fix: block update if not connected (#36641) --- selfdrive/ui/mici/layouts/settings/device.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py index de2e11caf2..b5e0ea838d 100644 --- a/selfdrive/ui/mici/layouts/settings/device.py +++ b/selfdrive/ui/mici/layouts/settings/device.py @@ -169,6 +169,11 @@ class UpdateOpenpilotBigButton(BigButton): self.set_enabled(True) def _handle_mouse_release(self, mouse_pos: MousePos): + if not system_time_valid(): + dlg = BigDialog(tr("Please connect to Wi-Fi to update"), "") + gui_app.set_modal_overlay(dlg) + return + self.set_enabled(False) self._state = UpdaterState.WAITING_FOR_UPDATER self.set_icon(self._txt_update_icon) From 85b05998edb907810fff86d22c740435fbbc44e0 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Tue, 18 Nov 2025 22:47:59 -0800 Subject: [PATCH 20/48] small release --- RELEASES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/RELEASES.md b/RELEASES.md index 9ab60e6cb7..58044dc694 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,5 +1,6 @@ -Version 0.10.2 (2025-11-23) +Version 0.10.2 (2025-11-19) ======================== +* comma four support Version 0.10.1 (2025-09-08) ======================== From b0b6bf87024a3975f9e4f2f733307b620a2ce795 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 18 Nov 2025 23:37:54 -0800 Subject: [PATCH 21/48] driving model -> openpilot --- selfdrive/ui/mici/layouts/onboarding.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/mici/layouts/onboarding.py b/selfdrive/ui/mici/layouts/onboarding.py index 6036393aa5..a3f8592080 100644 --- a/selfdrive/ui/mici/layouts/onboarding.py +++ b/selfdrive/ui/mici/layouts/onboarding.py @@ -287,8 +287,8 @@ class TrainingGuideConfidenceBall(SetupTermsPage): self._start_time = 0.0 self._title_header = TermsHeader("confidence ball", gui_app.texture("icons_mici/setup/green_car.png", 60, 60)) - self._warning_label = UnifiedLabel("The ball on the right communicates how confident the driving " + - "model is about the road scene at any given time.", 36, + self._warning_label = UnifiedLabel("The ball on the right communicates how confident openpilot " + + "is about the road scene at any given time.", 36, FontWeight.ROMAN) def show_event(self): From 85301e3a67dcbb1abbf80df8dbf6c222e2981f0a Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 18 Nov 2025 23:52:00 -0800 Subject: [PATCH 22/48] Remove unused mici images (#36644) remove uused images --- selfdrive/assets/icons_mici/buttons/button_circle_pressed.png | 3 --- .../assets/icons_mici/buttons/button_circle_red_pressed.png | 3 --- selfdrive/assets/icons_mici/buttons/button_side_home.png | 3 --- selfdrive/assets/icons_mici/buttons/toggle_dot_orange.png | 3 --- selfdrive/assets/icons_mici/eye.png | 3 --- selfdrive/assets/icons_mici/eye_crossed.png | 3 --- selfdrive/assets/icons_mici/notifications/blue_large.png | 3 --- selfdrive/assets/icons_mici/notifications/blue_small.png | 3 --- selfdrive/assets/icons_mici/notifications/icons/cable.png | 3 --- selfdrive/assets/icons_mici/notifications/icons/camera.png | 3 --- selfdrive/assets/icons_mici/notifications/icons/close.png | 3 --- selfdrive/assets/icons_mici/notifications/icons/critical.png | 3 --- selfdrive/assets/icons_mici/notifications/icons/eye.png | 3 --- selfdrive/assets/icons_mici/notifications/icons/fan.png | 3 --- .../assets/icons_mici/notifications/icons/temperature.png | 3 --- selfdrive/assets/icons_mici/notifications/icons/wheel.png | 3 --- selfdrive/assets/icons_mici/notifications/normal_large.png | 3 --- selfdrive/assets/icons_mici/notifications/normal_small.png | 3 --- selfdrive/assets/icons_mici/notifications/orange_large.png | 3 --- selfdrive/assets/icons_mici/notifications/orange_small.png | 3 --- selfdrive/assets/icons_mici/notifications/red_large.png | 3 --- selfdrive/assets/icons_mici/notifications/red_small.png | 3 --- selfdrive/assets/icons_mici/onroad/sunglasses.png | 3 --- selfdrive/assets/icons_mici/settings/developer/adb.png | 3 --- selfdrive/assets/icons_mici/settings/developer/debug_mode.png | 3 --- selfdrive/assets/icons_mici/settings/device/cancel.png | 3 --- selfdrive/assets/icons_mici/settings/device/downloading.png | 3 --- selfdrive/assets/icons_mici/settings/keyboard/back.png | 3 --- selfdrive/assets/icons_mici/settings/network/connect.png | 3 --- .../assets/icons_mici/settings/network/connect_disabled.png | 3 --- .../assets/icons_mici/settings/network/connect_pressed.png | 3 --- selfdrive/assets/icons_mici/settings/network/forget_pill.png | 3 --- .../assets/icons_mici/settings/network/forget_pill_pressed.png | 3 --- selfdrive/assets/icons_mici/settings/network/trash.png | 3 --- selfdrive/assets/icons_mici/setup/arrow.png | 3 --- selfdrive/assets/icons_mici/setup/back.png | 3 --- selfdrive/assets/icons_mici/setup/reboot.png | 3 --- selfdrive/assets/icons_mici/setup/small_button_disabled.png | 3 --- .../icons_mici/setup/small_slider/slider_arrow_outline.png | 3 --- 39 files changed, 117 deletions(-) delete mode 100644 selfdrive/assets/icons_mici/buttons/button_circle_pressed.png delete mode 100644 selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png delete mode 100644 selfdrive/assets/icons_mici/buttons/button_side_home.png delete mode 100644 selfdrive/assets/icons_mici/buttons/toggle_dot_orange.png delete mode 100644 selfdrive/assets/icons_mici/eye.png delete mode 100644 selfdrive/assets/icons_mici/eye_crossed.png delete mode 100644 selfdrive/assets/icons_mici/notifications/blue_large.png delete mode 100644 selfdrive/assets/icons_mici/notifications/blue_small.png delete mode 100644 selfdrive/assets/icons_mici/notifications/icons/cable.png delete mode 100644 selfdrive/assets/icons_mici/notifications/icons/camera.png delete mode 100644 selfdrive/assets/icons_mici/notifications/icons/close.png delete mode 100644 selfdrive/assets/icons_mici/notifications/icons/critical.png delete mode 100644 selfdrive/assets/icons_mici/notifications/icons/eye.png delete mode 100644 selfdrive/assets/icons_mici/notifications/icons/fan.png delete mode 100644 selfdrive/assets/icons_mici/notifications/icons/temperature.png delete mode 100644 selfdrive/assets/icons_mici/notifications/icons/wheel.png delete mode 100644 selfdrive/assets/icons_mici/notifications/normal_large.png delete mode 100644 selfdrive/assets/icons_mici/notifications/normal_small.png delete mode 100644 selfdrive/assets/icons_mici/notifications/orange_large.png delete mode 100644 selfdrive/assets/icons_mici/notifications/orange_small.png delete mode 100644 selfdrive/assets/icons_mici/notifications/red_large.png delete mode 100644 selfdrive/assets/icons_mici/notifications/red_small.png delete mode 100644 selfdrive/assets/icons_mici/onroad/sunglasses.png delete mode 100644 selfdrive/assets/icons_mici/settings/developer/adb.png delete mode 100644 selfdrive/assets/icons_mici/settings/developer/debug_mode.png delete mode 100644 selfdrive/assets/icons_mici/settings/device/cancel.png delete mode 100644 selfdrive/assets/icons_mici/settings/device/downloading.png delete mode 100644 selfdrive/assets/icons_mici/settings/keyboard/back.png delete mode 100644 selfdrive/assets/icons_mici/settings/network/connect.png delete mode 100644 selfdrive/assets/icons_mici/settings/network/connect_disabled.png delete mode 100644 selfdrive/assets/icons_mici/settings/network/connect_pressed.png delete mode 100644 selfdrive/assets/icons_mici/settings/network/forget_pill.png delete mode 100644 selfdrive/assets/icons_mici/settings/network/forget_pill_pressed.png delete mode 100644 selfdrive/assets/icons_mici/settings/network/trash.png delete mode 100644 selfdrive/assets/icons_mici/setup/arrow.png delete mode 100644 selfdrive/assets/icons_mici/setup/back.png delete mode 100644 selfdrive/assets/icons_mici/setup/reboot.png delete mode 100644 selfdrive/assets/icons_mici/setup/small_button_disabled.png delete mode 100644 selfdrive/assets/icons_mici/setup/small_slider/slider_arrow_outline.png diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_pressed.png b/selfdrive/assets/icons_mici/buttons/button_circle_pressed.png deleted file mode 100644 index 45027a372e..0000000000 --- a/selfdrive/assets/icons_mici/buttons/button_circle_pressed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d378fdbb9d683d5c94536ebf9c466146721b1f65859eb38667d5c2e9589e54c3 -size 12590 diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png b/selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png deleted file mode 100644 index 08b2e318d4..0000000000 --- a/selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:19d53ff0cb49ffc43507e5bac11583bc9b3037c2e2ed5e12b96bfd97d71e397f -size 26113 diff --git a/selfdrive/assets/icons_mici/buttons/button_side_home.png b/selfdrive/assets/icons_mici/buttons/button_side_home.png deleted file mode 100644 index 99c5ea6509..0000000000 --- a/selfdrive/assets/icons_mici/buttons/button_side_home.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3ec30f6ba49e7a7bc89e8369800ad71c8b57950bbf6b3f169fa944626a3ded59 -size 5258 diff --git a/selfdrive/assets/icons_mici/buttons/toggle_dot_orange.png b/selfdrive/assets/icons_mici/buttons/toggle_dot_orange.png deleted file mode 100644 index bf8559ec87..0000000000 --- a/selfdrive/assets/icons_mici/buttons/toggle_dot_orange.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1ca418dab8eab77569e3cc446deccdc5b468d79159711e6629d704eb531009d9 -size 6191 diff --git a/selfdrive/assets/icons_mici/eye.png b/selfdrive/assets/icons_mici/eye.png deleted file mode 100644 index db2953b690..0000000000 --- a/selfdrive/assets/icons_mici/eye.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c9c7f03c2784eac6882eb59d5f1c547008c12f19a861d04e9ca3549edb41218c -size 1988 diff --git a/selfdrive/assets/icons_mici/eye_crossed.png b/selfdrive/assets/icons_mici/eye_crossed.png deleted file mode 100644 index 11197fcf57..0000000000 --- a/selfdrive/assets/icons_mici/eye_crossed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5f54bdb8dbb94682ff474174b8e3c605dba867f1b6613a3efcc5fbbedc462a95 -size 1811 diff --git a/selfdrive/assets/icons_mici/notifications/blue_large.png b/selfdrive/assets/icons_mici/notifications/blue_large.png deleted file mode 100644 index e4aa33a135..0000000000 --- a/selfdrive/assets/icons_mici/notifications/blue_large.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2566b38173e6048f54ef62dd68a158b07747638f74f2724406cfef02ae38022b -size 14052 diff --git a/selfdrive/assets/icons_mici/notifications/blue_small.png b/selfdrive/assets/icons_mici/notifications/blue_small.png deleted file mode 100644 index 500f48e36f..0000000000 --- a/selfdrive/assets/icons_mici/notifications/blue_small.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7020a7ec5a1a31320a0cc2a381a79ed0d650bc220a9c60ae334c646868e12295 -size 10662 diff --git a/selfdrive/assets/icons_mici/notifications/icons/cable.png b/selfdrive/assets/icons_mici/notifications/icons/cable.png deleted file mode 100644 index 72b64f0732..0000000000 --- a/selfdrive/assets/icons_mici/notifications/icons/cable.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:028bb5611f41b0da7944f6bed42962cd894a57706a9940f124de7488a61b9b60 -size 1171 diff --git a/selfdrive/assets/icons_mici/notifications/icons/camera.png b/selfdrive/assets/icons_mici/notifications/icons/camera.png deleted file mode 100644 index fbe70ee826..0000000000 --- a/selfdrive/assets/icons_mici/notifications/icons/camera.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8667594a61ed4680b55ef981085cb84db7d1c86dd28ed998a7e8441f03b09193 -size 2000 diff --git a/selfdrive/assets/icons_mici/notifications/icons/close.png b/selfdrive/assets/icons_mici/notifications/icons/close.png deleted file mode 100644 index e0f061a015..0000000000 --- a/selfdrive/assets/icons_mici/notifications/icons/close.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:064cf235bfc30a08863a29ec7f94aa1f8cf7b6b68ee5eaad0a0c48740e9a0ce1 -size 2371 diff --git a/selfdrive/assets/icons_mici/notifications/icons/critical.png b/selfdrive/assets/icons_mici/notifications/icons/critical.png deleted file mode 100644 index 8acab1854f..0000000000 --- a/selfdrive/assets/icons_mici/notifications/icons/critical.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:77d7945ba3af1d0ecb4c52600fcfee80f9f8069f34dea4bce95fd0495ac6f80c -size 2596 diff --git a/selfdrive/assets/icons_mici/notifications/icons/eye.png b/selfdrive/assets/icons_mici/notifications/icons/eye.png deleted file mode 100644 index 11197fcf57..0000000000 --- a/selfdrive/assets/icons_mici/notifications/icons/eye.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5f54bdb8dbb94682ff474174b8e3c605dba867f1b6613a3efcc5fbbedc462a95 -size 1811 diff --git a/selfdrive/assets/icons_mici/notifications/icons/fan.png b/selfdrive/assets/icons_mici/notifications/icons/fan.png deleted file mode 100644 index 017da6c8c9..0000000000 --- a/selfdrive/assets/icons_mici/notifications/icons/fan.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:40ab18b92dda78353031c5df7b57db64a4a7f99820f696c4a4efe92cfa690109 -size 2465 diff --git a/selfdrive/assets/icons_mici/notifications/icons/temperature.png b/selfdrive/assets/icons_mici/notifications/icons/temperature.png deleted file mode 100644 index 09d0d798d8..0000000000 --- a/selfdrive/assets/icons_mici/notifications/icons/temperature.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3b76a778921377508da133ad105247b238244fae86f9c0791f3ad05dd69802a6 -size 1975 diff --git a/selfdrive/assets/icons_mici/notifications/icons/wheel.png b/selfdrive/assets/icons_mici/notifications/icons/wheel.png deleted file mode 100644 index ec4741f7ff..0000000000 --- a/selfdrive/assets/icons_mici/notifications/icons/wheel.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:23424ed80a70ebfedb99f14e286a08dcf59167988683b81fa8a56b06e9b2051e -size 2638 diff --git a/selfdrive/assets/icons_mici/notifications/normal_large.png b/selfdrive/assets/icons_mici/notifications/normal_large.png deleted file mode 100644 index df25984030..0000000000 --- a/selfdrive/assets/icons_mici/notifications/normal_large.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b6aa200bbb3381eb0d6a72c412227cd99c8bde5e843f705b218c09ee98576804 -size 9796 diff --git a/selfdrive/assets/icons_mici/notifications/normal_small.png b/selfdrive/assets/icons_mici/notifications/normal_small.png deleted file mode 100644 index f96de2e304..0000000000 --- a/selfdrive/assets/icons_mici/notifications/normal_small.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0486257b2e7735ad68c2bde66eb7bc710862c989feef856f06b0bf5d5231e7db -size 7369 diff --git a/selfdrive/assets/icons_mici/notifications/orange_large.png b/selfdrive/assets/icons_mici/notifications/orange_large.png deleted file mode 100644 index 62f6dac634..0000000000 --- a/selfdrive/assets/icons_mici/notifications/orange_large.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:32e3d36a95215cff4fdbe4af01c86408e647ff57dcc8724b517fda453b4b01e0 -size 15069 diff --git a/selfdrive/assets/icons_mici/notifications/orange_small.png b/selfdrive/assets/icons_mici/notifications/orange_small.png deleted file mode 100644 index 31fcf11a6a..0000000000 --- a/selfdrive/assets/icons_mici/notifications/orange_small.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0f754f98cd3f7104f3567bf02c4972277a8f5740757e245a2b3a60f9d1f61506 -size 11308 diff --git a/selfdrive/assets/icons_mici/notifications/red_large.png b/selfdrive/assets/icons_mici/notifications/red_large.png deleted file mode 100644 index 81cd5566fe..0000000000 --- a/selfdrive/assets/icons_mici/notifications/red_large.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7f820799a20667a265d5ed9e0ebc6a46a6e898935f2fa920b66d001347b88704 -size 13410 diff --git a/selfdrive/assets/icons_mici/notifications/red_small.png b/selfdrive/assets/icons_mici/notifications/red_small.png deleted file mode 100644 index 00b358c81c..0000000000 --- a/selfdrive/assets/icons_mici/notifications/red_small.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0fb44ce698c02929ca0c4360cc493ad18a99bd8f5d75586a422961e6f17d1371 -size 10169 diff --git a/selfdrive/assets/icons_mici/onroad/sunglasses.png b/selfdrive/assets/icons_mici/onroad/sunglasses.png deleted file mode 100644 index 15e502d617..0000000000 --- a/selfdrive/assets/icons_mici/onroad/sunglasses.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4b520b8a00ca245f1dcccca4ddbf1b1b6f8da9fb8b6ac9ea351e735db61641e6 -size 1006 diff --git a/selfdrive/assets/icons_mici/settings/developer/adb.png b/selfdrive/assets/icons_mici/settings/developer/adb.png deleted file mode 100644 index b3a7801467..0000000000 --- a/selfdrive/assets/icons_mici/settings/developer/adb.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:19b304727376ea30126e7aeb10d1193885ffa661ca5bdf4c09098e4412d2ab6a -size 2163 diff --git a/selfdrive/assets/icons_mici/settings/developer/debug_mode.png b/selfdrive/assets/icons_mici/settings/developer/debug_mode.png deleted file mode 100644 index 2849d2733c..0000000000 --- a/selfdrive/assets/icons_mici/settings/developer/debug_mode.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8bbdcedeb5f6f5844a8f67751f5d47793fe5af5e25ac6e7fd1ffc5857a85d56e -size 2997 diff --git a/selfdrive/assets/icons_mici/settings/device/cancel.png b/selfdrive/assets/icons_mici/settings/device/cancel.png deleted file mode 100644 index 6da29ad66f..0000000000 --- a/selfdrive/assets/icons_mici/settings/device/cancel.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:be9669cff5fc8a0b587dee27d1afb1caa77ceff2f92ca0ce3f01d25659e96596 -size 1332 diff --git a/selfdrive/assets/icons_mici/settings/device/downloading.png b/selfdrive/assets/icons_mici/settings/device/downloading.png deleted file mode 100644 index 2db5856989..0000000000 --- a/selfdrive/assets/icons_mici/settings/device/downloading.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a355a3960aea41b176be66d85a3e7bc83ec843bfb1e5bd4c7978f4ea41c5d1a2 -size 2756 diff --git a/selfdrive/assets/icons_mici/settings/keyboard/back.png b/selfdrive/assets/icons_mici/settings/keyboard/back.png deleted file mode 100644 index fccc2484df..0000000000 --- a/selfdrive/assets/icons_mici/settings/keyboard/back.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:de48d508af786b39fb725022b179e31456f32551a49b96ae07b5f55bfb968699 -size 1814 diff --git a/selfdrive/assets/icons_mici/settings/network/connect.png b/selfdrive/assets/icons_mici/settings/network/connect.png deleted file mode 100644 index f7beaf3923..0000000000 --- a/selfdrive/assets/icons_mici/settings/network/connect.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e47a5cb8b9ec784b6b893463622b7967a3692979b8a5e46e3334a09f745f1f71 -size 5413 diff --git a/selfdrive/assets/icons_mici/settings/network/connect_disabled.png b/selfdrive/assets/icons_mici/settings/network/connect_disabled.png deleted file mode 100644 index 0563668bed..0000000000 --- a/selfdrive/assets/icons_mici/settings/network/connect_disabled.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:05ddc9456627c0773dddcb10469e78124f9788381e63cab163ce4c6407a000f4 -size 3201 diff --git a/selfdrive/assets/icons_mici/settings/network/connect_pressed.png b/selfdrive/assets/icons_mici/settings/network/connect_pressed.png deleted file mode 100644 index aef242b6d8..0000000000 --- a/selfdrive/assets/icons_mici/settings/network/connect_pressed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cab1783614421ab86467c3686f4d11f439d6de2cce3a84801dec7e044c08c880 -size 9075 diff --git a/selfdrive/assets/icons_mici/settings/network/forget_pill.png b/selfdrive/assets/icons_mici/settings/network/forget_pill.png deleted file mode 100644 index a80de07634..0000000000 --- a/selfdrive/assets/icons_mici/settings/network/forget_pill.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2645cc56688a6225ba7ba0b4021af6543657942f31892a4986061a2959511054 -size 12106 diff --git a/selfdrive/assets/icons_mici/settings/network/forget_pill_pressed.png b/selfdrive/assets/icons_mici/settings/network/forget_pill_pressed.png deleted file mode 100644 index a1240481e5..0000000000 --- a/selfdrive/assets/icons_mici/settings/network/forget_pill_pressed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:42cea7a190be02027a1cc8d6daca435f4edfb5b9484c26a06e667a2346c91f0f -size 38471 diff --git a/selfdrive/assets/icons_mici/settings/network/trash.png b/selfdrive/assets/icons_mici/settings/network/trash.png deleted file mode 100644 index 99e1a2e246..0000000000 --- a/selfdrive/assets/icons_mici/settings/network/trash.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:efabf98ed66fe4447c0f13c74aec681b084de780c551ce18258c79636d4123c5 -size 1524 diff --git a/selfdrive/assets/icons_mici/setup/arrow.png b/selfdrive/assets/icons_mici/setup/arrow.png deleted file mode 100644 index 403aaedfb2..0000000000 --- a/selfdrive/assets/icons_mici/setup/arrow.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7aee85c239edcb7be41f03a4982b136d9a46908b027f37c5d36c80bd20372b22 -size 847 diff --git a/selfdrive/assets/icons_mici/setup/back.png b/selfdrive/assets/icons_mici/setup/back.png deleted file mode 100644 index 554c63e099..0000000000 --- a/selfdrive/assets/icons_mici/setup/back.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f88a958dd51eaf2c3f39df72165f538110c1d1eb4dbcac1f7016563db126c762 -size 1693 diff --git a/selfdrive/assets/icons_mici/setup/reboot.png b/selfdrive/assets/icons_mici/setup/reboot.png deleted file mode 100644 index 5633f2b499..0000000000 --- a/selfdrive/assets/icons_mici/setup/reboot.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:345bea231013aab33b98a134a05af22c2df7c166b60a1a81b6b4526da13209a5 -size 2293 diff --git a/selfdrive/assets/icons_mici/setup/small_button_disabled.png b/selfdrive/assets/icons_mici/setup/small_button_disabled.png deleted file mode 100644 index 5028a8cd21..0000000000 --- a/selfdrive/assets/icons_mici/setup/small_button_disabled.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9728423bd5e3197ef02d62e4bae415e6694aab875ca8630ffc9f188c38e18e5f -size 4141 diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow_outline.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow_outline.png deleted file mode 100644 index 1515867a40..0000000000 --- a/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow_outline.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f6394fa9dc03f83ac8599cf8179cdcc221c1b80b2c00d396b04bf2e3a7bfdca4 -size 1673 From b89c717643d27060280733a47468908ccd801068 Mon Sep 17 00:00:00 2001 From: Maxime Desroches Date: Wed, 19 Nov 2025 00:23:42 -0800 Subject: [PATCH 23/48] AGNOS 15.1 (#36638) * stage * staging * prod --- launch_env.sh | 2 +- system/hardware/tici/agnos.json | 12 ++++---- system/hardware/tici/all-partitions.json | 36 ++++++++++++------------ 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/launch_env.sh b/launch_env.sh index 0fba08bb24..fcbee2ff8d 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1 export QCOM_PRIORITY=12 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="15" + export AGNOS_VERSION="15.1" fi export STAGING_ROOT="/data/safe_staging" diff --git a/system/hardware/tici/agnos.json b/system/hardware/tici/agnos.json index 58c3d2a4e6..5a2a092aa8 100644 --- a/system/hardware/tici/agnos.json +++ b/system/hardware/tici/agnos.json @@ -67,17 +67,17 @@ }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b.img.xz", - "hash": "e9e99988d78c7287f29ad840130f65d5a11fa2301463d5298f1072399406f889", - "hash_raw": "8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b", + "url": "https://commadist.azureedge.net/agnosupdate/system-d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818.img.xz", + "hash": "a068d4d692ec770884f0a15e1a6d7aba52385ecae138f6d43fb0a9b1643ed5cd", + "hash_raw": "d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "21d3726fcdd39d126c9ecf05ccc43a104c8486b929045a63bf7e3ac8a8bb7a50", + "ondevice_hash": "6ffa02f7113badc122742f33efebc5d17f1cd61dd6358f3e130c162707dbfaf4", "alt": { - "hash": "8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b", - "url": "https://commadist.azureedge.net/agnosupdate/system-8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b.img", + "hash": "d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818", + "url": "https://commadist.azureedge.net/agnosupdate/system-d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818.img", "size": 4718592000 } } diff --git a/system/hardware/tici/all-partitions.json b/system/hardware/tici/all-partitions.json index bac2dfc594..3abf66cdd4 100644 --- a/system/hardware/tici/all-partitions.json +++ b/system/hardware/tici/all-partitions.json @@ -350,51 +350,51 @@ }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b.img.xz", - "hash": "e9e99988d78c7287f29ad840130f65d5a11fa2301463d5298f1072399406f889", - "hash_raw": "8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b", + "url": "https://commadist.azureedge.net/agnosupdate/system-d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818.img.xz", + "hash": "a068d4d692ec770884f0a15e1a6d7aba52385ecae138f6d43fb0a9b1643ed5cd", + "hash_raw": "d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "21d3726fcdd39d126c9ecf05ccc43a104c8486b929045a63bf7e3ac8a8bb7a50", + "ondevice_hash": "6ffa02f7113badc122742f33efebc5d17f1cd61dd6358f3e130c162707dbfaf4", "alt": { - "hash": "8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b", - "url": "https://commadist.azureedge.net/agnosupdate/system-8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b.img", + "hash": "d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818", + "url": "https://commadist.azureedge.net/agnosupdate/system-d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818.img", "size": 4718592000 } }, { "name": "userdata_90", - "url": "https://commadist.azureedge.net/agnosupdate/userdata_90-1d461d8be17827735a28c2588bb9fcad27d4b80fba15cd2740f3a04c8f29cc90.img.xz", - "hash": "763c7366049b3c0ad71bd19abbbf5c68d2c43597d4da5dafad890507ff489899", - "hash_raw": "1d461d8be17827735a28c2588bb9fcad27d4b80fba15cd2740f3a04c8f29cc90", + "url": "https://commadist.azureedge.net/agnosupdate/userdata_90-f9ea618ac97a86da49733ce66cd5e3aa19aa917666ee90de301cd746664e4d22.img.xz", + "hash": "dfc6812e76bd1583ed77a86eedf48cafdc306037d2a85c5d0aa7cdb23033b736", + "hash_raw": "f9ea618ac97a86da49733ce66cd5e3aa19aa917666ee90de301cd746664e4d22", "size": 96636764160, "sparse": true, "full_check": true, "has_ab": false, - "ondevice_hash": "90a265b8756b18caf1be4b8dc9b8b3104898170104ed87ec3274f77acc6c28e3" + "ondevice_hash": "ff95f994e9ed6504632f4b7c6daecef582f0a4e5261b8240d4474f16059faef4" }, { "name": "userdata_89", - "url": "https://commadist.azureedge.net/agnosupdate/userdata_89-ec37fcfb7d707d26d5fbc64994e20cfdbb73a27eeedfe37778559824a2032a27.img.xz", - "hash": "de475b604b63fbeb1841c6564fb8eb496da46c9a9564ec73e5d7c8045fc88ebc", - "hash_raw": "ec37fcfb7d707d26d5fbc64994e20cfdbb73a27eeedfe37778559824a2032a27", + "url": "https://commadist.azureedge.net/agnosupdate/userdata_89-393956e255c277b895bdb98bf65cfa3907e4b57822740ff82f857ac4e1a2f11e.img.xz", + "hash": "b5e2f05d31fc18fff18e82dcebfc2bf04de624baeca0511b93e50b3198b8a9ab", + "hash_raw": "393956e255c277b895bdb98bf65cfa3907e4b57822740ff82f857ac4e1a2f11e", "size": 95563022336, "sparse": true, "full_check": true, "has_ab": false, - "ondevice_hash": "03f6cbddc3bfbd2d0cd316d87d488434a03095c12870c8c6fe3bc4a2946ff0ef" + "ondevice_hash": "db64c6abc72bfcddc1682c73cc73c7230ed2f6e835d292fd38d054a9d242b8fc" }, { "name": "userdata_30", - "url": "https://commadist.azureedge.net/agnosupdate/userdata_30-3501f34c28f0e5ffe224f192b4a3a35a00a039980ca29a5c35d31449f3e918d6.img.xz", - "hash": "5bda2cb099b14f4944b476995d84dcb943af1858a57fdd62d5920b6e7b74fb80", - "hash_raw": "3501f34c28f0e5ffe224f192b4a3a35a00a039980ca29a5c35d31449f3e918d6", + "url": "https://commadist.azureedge.net/agnosupdate/userdata_30-a4b3e2a2fc3612a37322b7b1a4c5737765841dc3b8d6d3bb58b1e5a271023068.img.xz", + "hash": "ecec713cf7d8f1f616f122a16b138931f818290447e36a5925da6a4fc0fc7bf3", + "hash_raw": "a4b3e2a2fc3612a37322b7b1a4c5737765841dc3b8d6d3bb58b1e5a271023068", "size": 32212254720, "sparse": true, "full_check": true, "has_ab": false, - "ondevice_hash": "d1da6f8d928093dec15590b5c1740c0062031d0068a11962bdb28dca2104d8c6" + "ondevice_hash": "48fefa5a1880a4fd3dd50e1f9ddee297122053556816baca310d495129bc8893" } ] \ No newline at end of file From 1398bdb10ee11116ae41550add445cf72c52ed2a Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Wed, 19 Nov 2025 09:23:13 -0800 Subject: [PATCH 24/48] dmonitoringmodeld: follow same pattern as modeld (#36636) * dmonitoringmodeld: follow same pattern as modeld * lint * oops * rename --- selfdrive/modeld/dmonitoringmodeld.py | 61 ++++++++++++++++----------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/selfdrive/modeld/dmonitoringmodeld.py b/selfdrive/modeld/dmonitoringmodeld.py index dc2de6f998..fca762c69b 100755 --- a/selfdrive/modeld/dmonitoringmodeld.py +++ b/selfdrive/modeld/dmonitoringmodeld.py @@ -4,7 +4,6 @@ from openpilot.system.hardware import TICI os.environ['DEV'] = 'QCOM' if TICI else 'CPU' from tinygrad.tensor import Tensor from tinygrad.dtype import dtypes -import math import time import pickle import numpy as np @@ -18,7 +17,7 @@ from openpilot.common.realtime import config_realtime_process from openpilot.common.transformations.model import dmonitoringmodel_intrinsics from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.selfdrive.modeld.models.commonmodel_pyx import CLContext, MonitoringModelFrame -from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid +from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid, safe_exp from openpilot.selfdrive.modeld.runners.tinygrad_helpers import qcom_tensor_from_opencl_address PROCESS_NAME = "selfdrive.modeld.dmonitoringmodeld" @@ -34,7 +33,7 @@ class ModelState: def __init__(self, cl_ctx): with open(METADATA_PATH, 'rb') as f: model_metadata = pickle.load(f) - self.input_shapes = model_metadata['input_shapes'] + self.input_shapes = model_metadata['input_shapes'] self.output_slices = model_metadata['output_slices'] self.frame = MonitoringModelFrame(cl_ctx) @@ -65,32 +64,43 @@ class ModelState: t2 = time.perf_counter() return output, t2 - t1 +def slice_outputs(model_outputs, output_slices): + return {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} -def fill_driver_state(msg, model_output, output_slices, ds_suffix): - face_descs = model_output[output_slices[f'face_descs_{ds_suffix}']] - face_descs_std = face_descs[-6:] - msg.faceOrientation = [float(x) for x in face_descs[:3]] - msg.faceOrientationStd = [math.exp(x) for x in face_descs_std[:3]] - msg.facePosition = [float(x) for x in face_descs[3:5]] - msg.facePositionStd = [math.exp(x) for x in face_descs_std[3:5]] - msg.faceProb = float(sigmoid(model_output[output_slices[f'face_prob_{ds_suffix}']][0])) - msg.leftEyeProb = float(sigmoid(model_output[output_slices[f'left_eye_prob_{ds_suffix}']][0])) - msg.rightEyeProb = float(sigmoid(model_output[output_slices[f'right_eye_prob_{ds_suffix}']][0])) - msg.leftBlinkProb = float(sigmoid(model_output[output_slices[f'left_blink_prob_{ds_suffix}']][0])) - msg.rightBlinkProb = float(sigmoid(model_output[output_slices[f'right_blink_prob_{ds_suffix}']][0])) - msg.sunglassesProb = float(sigmoid(model_output[output_slices[f'sunglasses_prob_{ds_suffix}']][0])) - msg.phoneProb = float(sigmoid(model_output[output_slices[f'using_phone_prob_{ds_suffix}']][0])) +def parse_model_output(model_output): + parsed = {} + parsed['wheel_on_right'] = sigmoid(model_output['wheel_on_right']) + for ds_suffix in ['lhd', 'rhd']: + face_descs = model_output[f'face_descs_{ds_suffix}'] + parsed[f'face_descs_{ds_suffix}'] = face_descs[:, :-6] + parsed[f'face_descs_{ds_suffix}_std'] = safe_exp(face_descs[:, -6:]) + for key in ['face_prob', 'left_eye_prob', 'right_eye_prob','left_blink_prob', 'right_blink_prob', 'sunglasses_prob', 'using_phone_prob']: + parsed[f'{key}_{ds_suffix}'] = sigmoid(model_output[f'{key}_{ds_suffix}']) + return parsed -def get_driverstate_packet(model_output: np.ndarray, output_slices: dict[str, slice], frame_id: int, location_ts: int, exec_time: float, gpu_exec_time: float): +def fill_driver_data(msg, model_output, ds_suffix): + msg.faceOrientation = model_output[f'face_descs_{ds_suffix}'][0, :3].tolist() + msg.faceOrientationStd = model_output[f'face_descs_{ds_suffix}_std'][0, :3].tolist() + msg.facePosition = model_output[f'face_descs_{ds_suffix}'][0, 3:5].tolist() + msg.facePositionStd = model_output[f'face_descs_{ds_suffix}_std'][0, 3:5].tolist() + msg.faceProb = model_output[f'face_prob_{ds_suffix}'][0, 0].item() + msg.leftEyeProb = model_output[f'left_eye_prob_{ds_suffix}'][0, 0].item() + msg.rightEyeProb = model_output[f'right_eye_prob_{ds_suffix}'][0, 0].item() + msg.leftBlinkProb = model_output[f'left_blink_prob_{ds_suffix}'][0, 0].item() + msg.rightBlinkProb = model_output[f'right_blink_prob_{ds_suffix}'][0, 0].item() + msg.sunglassesProb = model_output[f'sunglasses_prob_{ds_suffix}'][0, 0].item() + msg.phoneProb = model_output[f'using_phone_prob_{ds_suffix}'][0, 0].item() + +def get_driverstate_packet(model_output, frame_id: int, location_ts: int, exec_time: float, gpu_exec_time: float): msg = messaging.new_message('driverStateV2', valid=True) ds = msg.driverStateV2 ds.frameId = frame_id ds.modelExecutionTime = exec_time ds.gpuExecutionTime = gpu_exec_time - ds.wheelOnRightProb = float(sigmoid(model_output[output_slices['wheel_on_right']][0])) - ds.rawPredictions = model_output.tobytes() if SEND_RAW_PRED else b'' - fill_driver_state(ds.leftDriverData, model_output, output_slices, 'lhd') - fill_driver_state(ds.rightDriverData, model_output, output_slices, 'rhd') + ds.rawPredictions = model_output['raw_pred'] + ds.wheelOnRightProb = model_output['wheel_on_right'][0, 0].item() + fill_driver_data(ds.leftDriverData, model_output, 'lhd') + fill_driver_data(ds.rightDriverData, model_output, 'rhd') return msg @@ -130,8 +140,11 @@ def main(): t1 = time.perf_counter() model_output, gpu_execution_time = model.run(buf, calib, model_transform) t2 = time.perf_counter() - - msg = get_driverstate_packet(model_output, model.output_slices, vipc_client.frame_id, vipc_client.timestamp_sof, t2 - t1, gpu_execution_time) + raw_pred = model_output.tobytes() if SEND_RAW_PRED else b'' + model_output = slice_outputs(model_output, model.output_slices) + model_output = parse_model_output(model_output) + model_output['raw_pred'] = raw_pred + msg = get_driverstate_packet(model_output, vipc_client.frame_id, vipc_client.timestamp_sof, t2 - t1, gpu_execution_time) pm.send("driverStateV2", msg) From 0d9b4cdaedf9b7ac09b15d38fe12a63e120f4bcf Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 19 Nov 2025 10:38:58 -0800 Subject: [PATCH 25/48] tmp bump this up --- selfdrive/test/test_onroad.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/test/test_onroad.py b/selfdrive/test/test_onroad.py index 69d920c1a0..27cc17624e 100644 --- a/selfdrive/test/test_onroad.py +++ b/selfdrive/test/test_onroad.py @@ -179,7 +179,7 @@ class TestOnroad: def test_manager_starting_time(self): st = self.ts['managerState']['t'][0] - assert (st - self.manager_st) < 12.5, f"manager.py took {st - self.manager_st}s to publish the first 'managerState' msg" + assert (st - self.manager_st) < 15.0, f"manager.py took {st - self.manager_st}s to publish the first 'managerState' msg" def test_cloudlog_size(self): msgs = self.msgs['logMessage'] From eeddfc058a6bd38ad76f1186999a5ad5936c4fb0 Mon Sep 17 00:00:00 2001 From: David <49467229+TheSecurityDev@users.noreply.github.com> Date: Wed, 19 Nov 2025 13:08:39 -0600 Subject: [PATCH 26/48] ui(mici): remove duplicate draw_texture_pro call in mici_keyboard (#36647) Remove duplicate draw_texture_pro call in IconKey class --- system/ui/widgets/mici_keyboard.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/system/ui/widgets/mici_keyboard.py b/system/ui/widgets/mici_keyboard.py index 2eb24cdb97..a4f4c7d09b 100644 --- a/system/ui/widgets/mici_keyboard.py +++ b/system/ui/widgets/mici_keyboard.py @@ -123,8 +123,6 @@ class IconKey(Key): src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height) rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color) - rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color) - elif self._vertical_align == "bottom": dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2, self._rect.y, self._icon.width * scale, self._icon.height * scale) From 9d0ab68f3bcc33b356514e8262d45346960f316c Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Wed, 19 Nov 2025 11:14:04 -0800 Subject: [PATCH 27/48] dm: settings w device type (#36650) * dm: settings w device type * lint * fix --- selfdrive/monitoring/helpers.py | 11 +++-------- selfdrive/monitoring/test_monitoring.py | 3 ++- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index 463bf2b7fa..208fc1676a 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -21,7 +21,7 @@ EventName = log.OnroadEvent.EventName # ****************************************************************************************** class DRIVER_MONITOR_SETTINGS: - def __init__(self): + def __init__(self, device_type): self._DT_DMON = DT_DMON # ref (page15-16): https://eur-lex.europa.eu/legal-content/EN/TXT/PDF/?uri=CELEX:42018X1947&rid=2 self._AWARENESS_TIME = 30. # passive wheeltouch total timeout @@ -36,10 +36,7 @@ class DRIVER_MONITOR_SETTINGS: self._SG_THRESHOLD = 0.9 self._BLINK_THRESHOLD = 0.865 - if HARDWARE.get_device_type() == 'mici': - self._PHONE_THRESH = 0.75 - else: - self._PHONE_THRESH = 0.4 + self._PHONE_THRESH = 0.75 if device_type == 'mici' else 0.4 self._PHONE_THRESH2 = 15.0 self._PHONE_MAX_OFFSET = 0.06 self._PHONE_MIN_OFFSET = 0.025 @@ -133,10 +130,8 @@ def face_orientation_from_net(angles_desc, pos_desc, rpy_calib): class DriverMonitoring: def __init__(self, rhd_saved=False, settings=None, always_on=False): - if settings is None: - settings = DRIVER_MONITOR_SETTINGS() # init policy settings - self.settings = settings + self.settings = settings if settings is not None else DRIVER_MONITOR_SETTINGS(device_type=HARDWARE.get_device_type()) # init driver status self.wheelpos_learner = RunningStatFilter() diff --git a/selfdrive/monitoring/test_monitoring.py b/selfdrive/monitoring/test_monitoring.py index 67234550f7..6ea9b80283 100644 --- a/selfdrive/monitoring/test_monitoring.py +++ b/selfdrive/monitoring/test_monitoring.py @@ -3,9 +3,10 @@ import numpy as np from cereal import log from openpilot.common.realtime import DT_DMON from openpilot.selfdrive.monitoring.helpers import DriverMonitoring, DRIVER_MONITOR_SETTINGS +from openpilot.system.hardware import HARDWARE EventName = log.OnroadEvent.EventName -dm_settings = DRIVER_MONITOR_SETTINGS() +dm_settings = DRIVER_MONITOR_SETTINGS(device_type=HARDWARE.get_device_type()) TEST_TIMESPAN = 120 # seconds DISTRACTED_SECONDS_TO_ORANGE = dm_settings._DISTRACTED_TIME - dm_settings._DISTRACTED_PROMPT_TIME_TILL_TERMINAL + 1 From c8effae4ebd71ec7b4ad3412904a160d720a3e30 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 19 Nov 2025 14:18:48 -0800 Subject: [PATCH 28/48] ui/wifi: fix no attribute error (#36653) --- system/ui/lib/wifi_manager.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/system/ui/lib/wifi_manager.py b/system/ui/lib/wifi_manager.py index 217ac5e89a..28bd58f226 100644 --- a/system/ui/lib/wifi_manager.py +++ b/system/ui/lib/wifi_manager.py @@ -138,6 +138,8 @@ class WifiManager: self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE) except FileNotFoundError: cloudlog.exception("Failed to connect to system D-Bus") + self._router_main = None + self._conn_monitor = None self._exit = True # Store wifi device path @@ -752,6 +754,8 @@ class WifiManager: if self._state_thread.is_alive(): self._state_thread.join() - self._router_main.close() - self._router_main.conn.close() - self._conn_monitor.close() + if self._router_main is not None: + self._router_main.close() + self._router_main.conn.close() + if self._conn_monitor is not None: + self._conn_monitor.close() From 3a001dd71cf7d80e9c505368b9089bc8e53672e0 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 19 Nov 2025 14:42:03 -0800 Subject: [PATCH 29/48] bump msgq --- msgq_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msgq_repo b/msgq_repo index 89096d90d2..a16cf1f608 160000 --- a/msgq_repo +++ b/msgq_repo @@ -1 +1 @@ -Subproject commit 89096d90d2f0f71be63a4af0152fe3b2aa55cf9d +Subproject commit a16cf1f608538d14f66bd6142230d8728f2d0abc From f0d8ebd85140e906b85a4e5c84833d84303d9a3f Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 19 Nov 2025 15:03:28 -0800 Subject: [PATCH 30/48] mici training guide tuneups (#36652) * bump up size * lil more * rm param * 5m timeout and 100% brightness * set parasm: --- common/params_keys.h | 2 - selfdrive/ui/layouts/onboarding.py | 11 +++-- selfdrive/ui/mici/layouts/onboarding.py | 44 ++++++++++++------- .../ui/tests/test_ui/raylib_screenshots.py | 5 +++ selfdrive/ui/ui_state.py | 5 ++- system/manager/manager.py | 4 +- system/ui/mici_setup.py | 5 +++ system/version.py | 6 --- 8 files changed, 48 insertions(+), 34 deletions(-) diff --git a/common/params_keys.h b/common/params_keys.h index 8d4c8d9e4b..bf410825f1 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -113,8 +113,6 @@ inline static std::unordered_map keys = { {"RouteCount", {PERSISTENT, INT, "0"}}, {"SnoozeUpdate", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"SshEnabled", {PERSISTENT, BOOL}}, - {"TermsVersion", {PERSISTENT, STRING}}, - {"TrainingVersion", {PERSISTENT, STRING}}, {"UbloxAvailable", {PERSISTENT, BOOL}}, {"UpdateAvailable", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"UpdateFailedCount", {CLEAR_ON_MANAGER_START, INT}}, diff --git a/selfdrive/ui/layouts/onboarding.py b/selfdrive/ui/layouts/onboarding.py index df259a8fb5..5d61c1c95a 100644 --- a/selfdrive/ui/layouts/onboarding.py +++ b/selfdrive/ui/layouts/onboarding.py @@ -11,6 +11,7 @@ from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.label import Label from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.version import terms_version, training_version DEBUG = False @@ -169,10 +170,8 @@ class DeclinePage(Widget): 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._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version + self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == training_version self._state = OnboardingState.TERMS if not self._accepted_terms else OnboardingState.ONBOARDING @@ -192,13 +191,13 @@ class OnboardingWindow(Widget): self._state = OnboardingState.TERMS def _on_terms_accepted(self): - ui_state.params.put("HasAcceptedTerms", self._current_terms_version) + ui_state.params.put("HasAcceptedTerms", 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) + ui_state.params.put("CompletedTrainingVersion", training_version) gui_app.set_modal_overlay(None) def _render(self, _): diff --git a/selfdrive/ui/mici/layouts/onboarding.py b/selfdrive/ui/mici/layouts/onboarding.py index a3f8592080..d140a17441 100644 --- a/selfdrive/ui/mici/layouts/onboarding.py +++ b/selfdrive/ui/mici/layouts/onboarding.py @@ -15,6 +15,7 @@ from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.lib.multilang import tr +from openpilot.system.version import terms_version, training_version class OnboardingState(IntEnum): @@ -60,7 +61,7 @@ class TrainingGuideIntro(SetupTermsPage): self._title_header = TermsHeader("welcome to openpilot", gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 60, 60)) self._dm_label = UnifiedLabel("Before we get on the road, let's review the " + - "functionality and limitations of openpilot.", 36, + "functionality and limitations of openpilot.", 42, FontWeight.ROMAN) @property @@ -90,7 +91,7 @@ class TrainingGuidePreDMTutorial(SetupTermsPage): self._dm_label = UnifiedLabel("Next, we'll ensure comma four is mounted properly.\n\nIf it does not have a clear view of the driver, " + "simply unplug and remount before continuing.\n\n" + - "NOTE: the driver camera will have a purple tint due to the IR illumination used for seeing at night.", 36, + "NOTE: the driver camera will have a purple tint due to the IR illumination used for seeing at night.", 42, FontWeight.ROMAN) def show_event(self): @@ -123,7 +124,14 @@ class TrainingGuideDMTutorial(Widget): super().__init__() self._title_header = TermsHeader("fill the circle to continue", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60)) - self._dialog = DriverCameraSetupDialog(continue_callback) + self._original_continue_callback = continue_callback + + # Wrap the continue callback to restore settings + def wrapped_continue_callback(): + self._restore_settings() + continue_callback() + + self._dialog = DriverCameraSetupDialog(wrapped_continue_callback) # Disable driver monitoring model when device times out for inactivity def inactivity_callback(): @@ -135,6 +143,13 @@ class TrainingGuideDMTutorial(Widget): super().show_event() self._dialog.show_event() + device.set_offroad_brightness(100) + device.reset_interactive_timeout(300) # 5 minutes + + def _restore_settings(self): + device.set_offroad_brightness(None) + device.reset_interactive_timeout() + def _update_state(self): super()._update_state() if device.awake: @@ -168,7 +183,7 @@ class TrainingGuideRecordFront(SetupTermsPage): self._title_header = TermsHeader("improve driver monitoring", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60)) self._dm_label = UnifiedLabel("Help improve driver monitoring by including your driving data in the training data set. " + - "Your preference can be changed at any time in Settings. Would you like to share your data?", 36, + "Your preference can be changed at any time in Settings. Would you like to share your data?", 42, FontWeight.ROMAN) def show_event(self): @@ -200,7 +215,7 @@ class TrainingGuideAttentionNotice1(SetupTermsPage): def __init__(self, continue_callback): super().__init__(continue_callback, continue_text="continue") self._title_header = TermsHeader("not a self driving car", gui_app.texture("icons_mici/setup/warning.png", 60, 60)) - self._warning_label = UnifiedLabel("THIS IS A DRIVER ASSISTANCE SYSTEM. A DRIVER ASSISTANCE SYSTEM IS NOT A SELF DRIVING CAR.", 36, + self._warning_label = UnifiedLabel("THIS IS A DRIVER ASSISTANCE SYSTEM. A DRIVER ASSISTANCE SYSTEM IS NOT A SELF-DRIVING CAR.", 42, FontWeight.ROMAN) @property @@ -227,7 +242,8 @@ class TrainingGuideAttentionNotice2(SetupTermsPage): def __init__(self, continue_callback): super().__init__(continue_callback, continue_text="continue") self._title_header = TermsHeader("attention is required", gui_app.texture("icons_mici/setup/warning.png", 60, 60)) - self._warning_label = UnifiedLabel("YOU MUST PAY ATTENTION AT ALL TIMES. YOU ARE FULLY RESPONSIBLE FOR DRIVING THE CAR.", 36, + self._warning_label = UnifiedLabel("1. You must pay attention at all times.\n\n2. You must be ready to take over at any time."+ + "\n\n3. You are fully responsible for driving the car.", 42, FontWeight.ROMAN) @property @@ -255,7 +271,7 @@ class TrainingGuideDisengaging(SetupTermsPage): super().__init__(continue_callback, continue_text="continue") self._title_header = TermsHeader("disengaging openpilot", gui_app.texture("icons_mici/setup/green_pedal.png", 60, 60)) self._warning_label = UnifiedLabel("You can disengage openpilot by either pressing the brake pedal or " + - "the cancel button on your steering wheel.", 36, + "the cancel button on your steering wheel.", 42, FontWeight.ROMAN) @property @@ -288,7 +304,7 @@ class TrainingGuideConfidenceBall(SetupTermsPage): self._title_header = TermsHeader("confidence ball", gui_app.texture("icons_mici/setup/green_car.png", 60, 60)) self._warning_label = UnifiedLabel("The ball on the right communicates how confident openpilot " + - "is about the road scene at any given time.", 36, + "is about the road scene at any given time.", 42, FontWeight.ROMAN) def show_event(self): @@ -343,7 +359,7 @@ class TrainingGuideSteeringArc(SetupTermsPage): self._title_header = TermsHeader("steering arc", gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 60, 60)) self._warning_label = UnifiedLabel("All cars limit the amount of steering that openpilot is able to apply. While driving, the " + "steering arc shows the current amount of force being applied in relation to the maximum available to openpilot. " + - "You may need to assist if you see the arc nearing its orange state.", 36, + "You may need to assist if you see the arc nearing its orange state.", 42, FontWeight.ROMAN) def show_event(self): @@ -506,10 +522,8 @@ class TermsPage(SetupTermsPage): 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._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version + self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == training_version self._state = OnboardingState.TERMS if not self._accepted_terms else OnboardingState.ONBOARDING @@ -535,11 +549,11 @@ class OnboardingWindow(Widget): gui_app.set_modal_overlay(None) def _on_terms_accepted(self): - ui_state.params.put("HasAcceptedTerms", self._current_terms_version) + ui_state.params.put("HasAcceptedTerms", terms_version) self._state = OnboardingState.ONBOARDING def _on_completed_training(self): - ui_state.params.put("CompletedTrainingVersion", self._current_training_version) + ui_state.params.put("CompletedTrainingVersion", training_version) self.close() def _render(self, _): diff --git a/selfdrive/ui/tests/test_ui/raylib_screenshots.py b/selfdrive/ui/tests/test_ui/raylib_screenshots.py index 3d476c4319..481ac111be 100755 --- a/selfdrive/ui/tests/test_ui/raylib_screenshots.py +++ b/selfdrive/ui/tests/test_ui/raylib_screenshots.py @@ -18,6 +18,7 @@ from openpilot.common.prefix import OpenpilotPrefix from openpilot.selfdrive.test.helpers import with_processes from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert from openpilot.system.updated.updated import parse_release_notes +from openpilot.system.version import terms_version, training_version AlertSize = log.SelfdriveState.AlertSize AlertStatus = log.SelfdriveState.AlertStatus @@ -298,6 +299,10 @@ def create_screenshots(): params.put("UpdaterCurrentDescription", VERSION) params.put("UpdaterNewDescription", VERSION) + # Set terms and training version (to skip onboarding) + params.put("HasAcceptedTerms", terms_version) + params.put("CompletedTrainingVersion", training_version) + if name == "homescreen_paired": params.put("PrimeType", 0) # NONE elif name == "homescreen_prime": diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index 4a6ff9ebd9..ef0696a22c 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -217,8 +217,9 @@ class Device: self._update_brightness() self._update_wakefulness() - def set_offroad_brightness(self, brightness: int): - # TODO: not yet used, should be used in prime widget for QR code, etc. + def set_offroad_brightness(self, brightness: int | None): + if brightness is None: + brightness = BACKLIGHT_OFFROAD self._offroad_brightness = min(max(brightness, 0), 100) def _update_brightness(self): diff --git a/system/manager/manager.py b/system/manager/manager.py index 36055d8635..8db13346e3 100755 --- a/system/manager/manager.py +++ b/system/manager/manager.py @@ -17,7 +17,7 @@ from openpilot.system.manager.process import ensure_running from openpilot.system.manager.process_config import managed_processes from openpilot.system.athena.registration import register, UNREGISTERED_DONGLE_ID from openpilot.common.swaglog import cloudlog, add_file_handler -from openpilot.system.version import get_build_metadata, terms_version, training_version +from openpilot.system.version import get_build_metadata from openpilot.system.hardware.hw import Paths @@ -54,8 +54,6 @@ def manager_init() -> None: # set params serial = HARDWARE.get_serial() params.put("Version", build_metadata.openpilot.version) - params.put("TermsVersion", terms_version) - params.put("TrainingVersion", training_version) params.put("GitCommit", build_metadata.openpilot.git_commit) params.put("GitCommitDate", build_metadata.openpilot.git_commit_date) params.put("GitBranch", build_metadata.channel) diff --git a/system/ui/mici_setup.py b/system/ui/mici_setup.py index 4afd69bfa7..d7395f9b7a 100755 --- a/system/ui/mici_setup.py +++ b/system/ui/mici_setup.py @@ -18,6 +18,7 @@ from openpilot.common.utils import run_cmd from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.wifi_manager import WifiManager +from openpilot.selfdrive.ui.ui_state import device from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 from openpilot.system.ui.widgets import Widget, DialogResult from openpilot.system.ui.widgets.button import (IconButton, SmallButton, WideRoundedButton, SmallerRoundedButton, @@ -225,6 +226,10 @@ class TermsPage(Widget): self._back_button.set_opacity(0.0) self._scroll_down_indicator.set_opacity(1.0) + def show_event(self): + super().show_event() + device.reset_interactive_timeout(300) + @property @abstractmethod def _content_height(self): diff --git a/system/version.py b/system/version.py index f59509715f..1f01b181cd 100755 --- a/system/version.py +++ b/system/version.py @@ -157,10 +157,4 @@ def get_build_metadata(path: str = BASEDIR) -> BuildMetadata: if __name__ == "__main__": - from openpilot.common.params import Params - - params = Params() - params.put("TermsVersion", terms_version) - params.put("TrainingVersion", training_version) - print(get_build_metadata()) From a29fdbd02407d41ecbcc69d151bb4837bfba3cbc Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 19 Nov 2025 15:43:49 -0800 Subject: [PATCH 31/48] enhance dcam a bit for onboarding (#36655) --- selfdrive/ui/mici/onroad/cameraview.py | 28 +++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/selfdrive/ui/mici/onroad/cameraview.py b/selfdrive/ui/mici/onroad/cameraview.py index f962210afb..0f425b10da 100644 --- a/selfdrive/ui/mici/onroad/cameraview.py +++ b/selfdrive/ui/mici/onroad/cameraview.py @@ -47,6 +47,7 @@ if TICI: uniform samplerExternalOES texture0; out vec4 fragColor; uniform int engaged; + uniform int enhance_driver; void main() { vec4 color = texture(texture0, fragTexCoord); @@ -57,8 +58,16 @@ if TICI: color.rgb = pow(color.rgb, vec3(1.0/1.28)); fragColor = vec4(color.rgb, color.a); } else { - fragColor = vec4(color.rgb * 0.85, color.a); // 85% opacity + color.rgb *= 0.85; // 85% opacity } + if (enhance_driver == 1) { + float brightness = 1.1; + color.rgb = color.rgb + 0.15; + color.rgb = clamp((color.rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0); + color.rgb = color.rgb * color.rgb * (3.0 - 2.0 * color.rgb); + color.rgb = pow(color.rgb, vec3(0.8)); + } + fragColor = vec4(color.rgb, color.a); } """ else: @@ -68,6 +77,7 @@ else: uniform sampler2D texture1; out vec4 fragColor; uniform int engaged; + uniform int enhance_driver; void main() { float y = texture(texture0, fragTexCoord).r; @@ -77,10 +87,19 @@ else: float gray = dot(rgb, vec3(0.299, 0.587, 0.114)); rgb = mix(vec3(gray), rgb, 0.2); // 20% saturation rgb = clamp((rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast - fragColor = vec4(rgb, 1.0); } else { - fragColor = vec4(rgb * 0.85, 1.0); // 85% opacity + rgb *= 0.85; // 85% opacity } + // TODO: the images out of camerad need some more correction and + // the ui should apply a gamma curve for the device display + if (enhance_driver == 1) { + float brightness = 1.1; + rgb = rgb + 0.15; + rgb = clamp((rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0); + rgb = rgb * rgb * (3.0 - 2.0 * rgb); + rgb = pow(rgb, vec3(0.8)); + } + fragColor = vec4(rgb, 1.0); } """ @@ -106,6 +125,8 @@ class CameraView(Widget): self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1 self._engaged_loc = rl.get_shader_location(self.shader, "engaged") self._engaged_val = rl.ffi.new("int[1]", [1]) + self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver") + self._enhance_driver_val = rl.ffi.new("int[1]", [1 if stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0]) self.frame: VisionBuf | None = None self.texture_y: rl.Texture | None = None @@ -300,6 +321,7 @@ class CameraView(Widget): def _update_texture_color_filtering(self): self._engaged_val[0] = 1 if ui_state.status != UIStatus.DISENGAGED else 0 rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT) + rl.set_shader_value(self.shader, self._enhance_driver_loc, self._enhance_driver_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT) def _ensure_connection(self) -> bool: if not self.client.is_connected(): From 5c10e7f6cf7fe037cd573064ddde4889c50e3d40 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 19 Nov 2025 22:20:47 -0800 Subject: [PATCH 32/48] fix: openpilot unavailable with replay (#36658) fix: openpilot unavailable --- selfdrive/ui/mici/onroad/driver_camera_dialog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/selfdrive/ui/mici/onroad/driver_camera_dialog.py b/selfdrive/ui/mici/onroad/driver_camera_dialog.py index f2fa5e8fe8..2f7f10b951 100644 --- a/selfdrive/ui/mici/onroad/driver_camera_dialog.py +++ b/selfdrive/ui/mici/onroad/driver_camera_dialog.py @@ -24,7 +24,6 @@ class DriverCameraDialog(NavWidget): self.driver_state_renderer = DriverStateRenderer(lines=True) self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200)) self.driver_state_renderer.load_icons() - self._pm = messaging.PubMaster(['selfdriveState']) if not no_escape: # TODO: this can grow unbounded, should be given some thought device.add_interactive_timeout_callback(self.stop_dmonitoringmodeld) @@ -50,10 +49,12 @@ class DriverCameraDialog(NavWidget): self._publish_alert_sound(None) device.reset_interactive_timeout(300) ui_state.params.remove("DriverTooDistracted") + self._pm = messaging.PubMaster(['selfdriveState']) def hide_event(self): super().hide_event() device.reset_interactive_timeout() + self._pm = None def _handle_mouse_release(self, _): ui_state.params.remove("DriverTooDistracted") From fa56d539a7cb29b695d101f2d90554989b0ff82c Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Thu, 20 Nov 2025 10:28:19 -0800 Subject: [PATCH 33/48] dm: phone offseter class + log stats (#36656) * dm: phone offseter class + log stats * lint:/ --- cereal/log.capnp | 2 ++ selfdrive/monitoring/helpers.py | 37 ++++++++++++++++++++------------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/cereal/log.capnp b/cereal/log.capnp index 86774b8d42..5fce839577 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -2226,6 +2226,8 @@ struct DriverMonitoringState @0xb83cda094a1da284 { isActiveMode @16 :Bool; isRHD @4 :Bool; uncertainCount @19 :UInt32; + phoneProbOffset @20 :Float32; + phoneProbalidCount @21 :UInt32; isPreviewDEPRECATED @15 :Bool; rhdCheckedDEPRECATED @5 :Bool; diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index 208fc1676a..4b216a496c 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -98,6 +98,12 @@ class DriverPose: self.cfactor_pitch = 1. self.cfactor_yaw = 1. +class DriverPhone: + def __init__(self, max_trackable): + self.prob = 0. + self.prob_offseter = RunningStatFilter(max_trackable=max_trackable) + self.prob_calibrated = False + class DriverBlink: def __init__(self): self.left = 0. @@ -136,10 +142,8 @@ class DriverMonitoring: # init driver status self.wheelpos_learner = RunningStatFilter() self.pose = DriverPose(self.settings._POSE_OFFSET_MAX_COUNT) + self.phone = DriverPhone(self.settings._POSE_OFFSET_MAX_COUNT) self.blink = DriverBlink() - self.phone_prob = 0. - self.phone_offseter = RunningStatFilter(max_trackable=self.settings._POSE_OFFSET_MAX_COUNT) - self.phone_calibrated = False self.always_on = always_on self.distracted_types = [] @@ -237,11 +241,11 @@ class DriverMonitoring: if (self.blink.left + self.blink.right)*0.5 > self.settings._BLINK_THRESHOLD: distracted_types.append(DistractedType.DISTRACTED_BLINK) - if self.phone_calibrated: - using_phone = self.phone_prob > max(min(self.phone_offseter.filtered_stat.M, self.settings._PHONE_MAX_OFFSET), self.settings._PHONE_MIN_OFFSET) \ - * self.settings._PHONE_THRESH2 + if self.phone.prob_calibrated: + using_phone = self.phone.prob > max(min(self.phone_offseter.filtered_stat.M, self.settings._PHONE_MAX_OFFSET), self.settings._PHONE_MIN_OFFSET) \ + * self.settings._PHONE_THRESH2 else: - using_phone = self.phone_prob > self.settings._PHONE_THRESH + using_phone = self.phone.prob > self.settings._PHONE_THRESH if using_phone: distracted_types.append(DistractedType.DISTRACTED_PHONE) @@ -275,14 +279,15 @@ class DriverMonitoring: model_std_max = max(self.pose.pitch_std, self.pose.yaw_std) self.pose.low_std = model_std_max < self.settings._POSESTD_THRESHOLD self.blink.left = driver_data.leftBlinkProb * (driver_data.leftEyeProb > self.settings._EYE_THRESHOLD) \ - * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD) + * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD) self.blink.right = driver_data.rightBlinkProb * (driver_data.rightEyeProb > self.settings._EYE_THRESHOLD) \ - * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD) - self.phone_prob = driver_data.phoneProb + * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD) + self.phone.prob = driver_data.phoneProb self.distracted_types = self._get_distracted_types() - self.driver_distracted = (DistractedType.DISTRACTED_PHONE in self.distracted_types or DistractedType.DISTRACTED_POSE in self.distracted_types - or DistractedType.DISTRACTED_BLINK in self.distracted_types) \ + self.driver_distracted = (DistractedType.DISTRACTED_PHONE in self.distracted_types + or DistractedType.DISTRACTED_POSE in self.distracted_types + or DistractedType.DISTRACTED_BLINK in self.distracted_types) \ and driver_data.faceProb > self.settings._FACE_THRESHOLD and self.pose.low_std self.driver_distraction_filter.update(self.driver_distracted) @@ -291,11 +296,11 @@ class DriverMonitoring: if self.face_detected and car_speed > self.settings._POSE_CALIB_MIN_SPEED and self.pose.low_std and (not op_engaged or not self.driver_distracted): self.pose.pitch_offseter.push_and_update(self.pose.pitch) self.pose.yaw_offseter.push_and_update(self.pose.yaw) - self.phone_offseter.push_and_update(self.phone_prob) + self.phone.prob_offseter.push_and_update(self.phone.prob) 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.phone_calibrated = self.phone_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT + self.pose.yaw_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT + self.phone.prob_calibrated = self.phone.prob_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT if self.face_detected and not self.driver_distracted: if model_std_max > self.settings._DCAM_UNCERTAIN_ALERT_THRESHOLD: @@ -401,6 +406,8 @@ class DriverMonitoring: "posePitchValidCount": self.pose.pitch_offseter.filtered_stat.n, "poseYawOffset": self.pose.yaw_offseter.filtered_stat.mean(), "poseYawValidCount": self.pose.yaw_offseter.filtered_stat.n, + "phoneProbOffset": self.phone.prob_offseter.filtered_stat.mean(), + "phoneProbalidCount": self.phone.prob_offseter.filtered_stat.n, "stepChange": self.step_change, "awarenessActive": self.awareness_active, "awarenessPassive": self.awareness_passive, From 38697ac6282f3b88a45295692c41229fc7ce1c71 Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Thu, 20 Nov 2025 12:30:28 -0800 Subject: [PATCH 34/48] Fix typo in phoneProbValidCount field name (#36662) * Fix typo in phoneProbValidCount field name * Fix typo in phoneProbValidCount key --- cereal/log.capnp | 2 +- selfdrive/monitoring/helpers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cereal/log.capnp b/cereal/log.capnp index 5fce839577..686771e284 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -2227,7 +2227,7 @@ struct DriverMonitoringState @0xb83cda094a1da284 { isRHD @4 :Bool; uncertainCount @19 :UInt32; phoneProbOffset @20 :Float32; - phoneProbalidCount @21 :UInt32; + phoneProbValidCount @21 :UInt32; isPreviewDEPRECATED @15 :Bool; rhdCheckedDEPRECATED @5 :Bool; diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index 4b216a496c..4d9e2e7b0b 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -407,7 +407,7 @@ class DriverMonitoring: "poseYawOffset": self.pose.yaw_offseter.filtered_stat.mean(), "poseYawValidCount": self.pose.yaw_offseter.filtered_stat.n, "phoneProbOffset": self.phone.prob_offseter.filtered_stat.mean(), - "phoneProbalidCount": self.phone.prob_offseter.filtered_stat.n, + "phoneProbValidCount": self.phone.prob_offseter.filtered_stat.n, "stepChange": self.step_change, "awarenessActive": self.awareness_active, "awarenessPassive": self.awareness_passive, From be700bc8250b894c7a63916d02fcbb63aa24e03e Mon Sep 17 00:00:00 2001 From: ZwX1616 Date: Thu, 20 Nov 2025 13:42:20 -0800 Subject: [PATCH 35/48] fix rhd behavior during preview/onboarding (#36657) * rhd learning not required for demo * fix switching and saving --------- Co-authored-by: Comma Device --- selfdrive/monitoring/dmonitoringd.py | 2 +- selfdrive/monitoring/helpers.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/selfdrive/monitoring/dmonitoringd.py b/selfdrive/monitoring/dmonitoringd.py index 293904a8ee..1dc256d467 100755 --- a/selfdrive/monitoring/dmonitoringd.py +++ b/selfdrive/monitoring/dmonitoringd.py @@ -38,7 +38,7 @@ def dmonitoringd_thread(): demo_mode = params.get_bool("IsDriverViewEnabled") # save rhd virtual toggle every 5 mins - if (sm['driverStateV2'].frameId % 6000 == 0 and + if (sm['driverStateV2'].frameId % 6000 == 0 and not demo_mode and DM.wheelpos_learner.filtered_stat.n > DM.settings._WHEELPOS_FILTER_MIN_COUNT and DM.wheel_on_right == (DM.wheelpos_learner.filtered_stat.M > DM.settings._WHEELPOS_THRESHOLD)): params.put_bool_nonblocking("IsRhdDetected", DM.wheel_on_right) diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index 4d9e2e7b0b..9b9282bf49 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -251,18 +251,18 @@ class DriverMonitoring: return distracted_types - def _update_states(self, driver_state, cal_rpy, car_speed, op_engaged, standstill): + def _update_states(self, driver_state, cal_rpy, car_speed, op_engaged, standstill, demo_mode=False): rhd_pred = driver_state.wheelOnRightProb # calibrates only when there's movement and either face detected if car_speed > self.settings._WHEELPOS_CALIB_MIN_SPEED and (driver_state.leftDriverData.faceProb > self.settings._FACE_THRESHOLD or driver_state.rightDriverData.faceProb > self.settings._FACE_THRESHOLD): self.wheelpos_learner.push_and_update(rhd_pred) - if self.wheelpos_learner.filtered_stat.n > self.settings._WHEELPOS_FILTER_MIN_COUNT: + if self.wheelpos_learner.filtered_stat.n > self.settings._WHEELPOS_FILTER_MIN_COUNT or demo_mode: self.wheel_on_right = self.wheelpos_learner.filtered_stat.M > self.settings._WHEELPOS_THRESHOLD else: self.wheel_on_right = self.wheel_on_right_default # use default/saved if calibration is unfinished # make sure no switching when engaged - if op_engaged and self.wheel_on_right_last is not None and self.wheel_on_right_last != self.wheel_on_right: + if op_engaged and self.wheel_on_right_last is not None and self.wheel_on_right_last != self.wheel_on_right and not demo_mode: self.wheel_on_right = self.wheel_on_right_last 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, @@ -448,6 +448,7 @@ class DriverMonitoring: car_speed=highway_speed, op_engaged=enabled, standstill=standstill, + demo_mode=demo, ) # Update distraction events From 6f89473f33d901d17fac4f5ce81f16cd0f07d75b Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 20 Nov 2025 13:50:35 -0800 Subject: [PATCH 36/48] Revert "fix: openpilot unavailable with replay (#36658)" This reverts commit 5c10e7f6cf7fe037cd573064ddde4889c50e3d40. --- selfdrive/ui/mici/onroad/driver_camera_dialog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/selfdrive/ui/mici/onroad/driver_camera_dialog.py b/selfdrive/ui/mici/onroad/driver_camera_dialog.py index 2f7f10b951..f2fa5e8fe8 100644 --- a/selfdrive/ui/mici/onroad/driver_camera_dialog.py +++ b/selfdrive/ui/mici/onroad/driver_camera_dialog.py @@ -24,6 +24,7 @@ class DriverCameraDialog(NavWidget): self.driver_state_renderer = DriverStateRenderer(lines=True) self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200)) self.driver_state_renderer.load_icons() + self._pm = messaging.PubMaster(['selfdriveState']) if not no_escape: # TODO: this can grow unbounded, should be given some thought device.add_interactive_timeout_callback(self.stop_dmonitoringmodeld) @@ -49,12 +50,10 @@ class DriverCameraDialog(NavWidget): self._publish_alert_sound(None) device.reset_interactive_timeout(300) ui_state.params.remove("DriverTooDistracted") - self._pm = messaging.PubMaster(['selfdriveState']) def hide_event(self): super().hide_event() device.reset_interactive_timeout() - self._pm = None def _handle_mouse_release(self, _): ui_state.params.remove("DriverTooDistracted") From b6bfa13b704cafd13bdc910806d5a1784cb6a166 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 20 Nov 2025 14:20:37 -0800 Subject: [PATCH 37/48] onboarding touchups, thanks nabeel! --- selfdrive/ui/mici/layouts/onboarding.py | 68 +++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/selfdrive/ui/mici/layouts/onboarding.py b/selfdrive/ui/mici/layouts/onboarding.py index d140a17441..66a0919b09 100644 --- a/selfdrive/ui/mici/layouts/onboarding.py +++ b/selfdrive/ui/mici/layouts/onboarding.py @@ -183,7 +183,7 @@ class TrainingGuideRecordFront(SetupTermsPage): self._title_header = TermsHeader("improve driver monitoring", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60)) self._dm_label = UnifiedLabel("Help improve driver monitoring by including your driving data in the training data set. " + - "Your preference can be changed at any time in Settings. Would you like to share your data?", 42, + "Your preference can be changed at any time in Settings.\n\nWould you like to share your data?", 42, FontWeight.ROMAN) def show_event(self): @@ -242,7 +242,7 @@ class TrainingGuideAttentionNotice2(SetupTermsPage): def __init__(self, continue_callback): super().__init__(continue_callback, continue_text="continue") self._title_header = TermsHeader("attention is required", gui_app.texture("icons_mici/setup/warning.png", 60, 60)) - self._warning_label = UnifiedLabel("1. You must pay attention at all times.\n\n2. You must be ready to take over at any time."+ + self._warning_label = UnifiedLabel("1. You must pay attention at all times.\n\n2. You must be ready to take over at any time." + "\n\n3. You are fully responsible for driving the car.", 42, FontWeight.ROMAN) @@ -265,13 +265,69 @@ class TrainingGuideAttentionNotice2(SetupTermsPage): self._warning_label.get_content_height(int(self._rect.width - 32)), )) +class TrainingGuideEngaging(SetupTermsPage): + def __init__(self, continue_callback): + super().__init__(continue_callback, continue_text="continue") + self._title_header = TermsHeader("engaging openpilot", gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 60, 60)) + self._warning_label = UnifiedLabel("You can engage openpilot using your car's cruise control inputs.\n\n" + + "These are usually located on either the steering wheel or on a lever near the wheel.", 42, + FontWeight.ROMAN) + + @property + def _content_height(self): + return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() + + def _render_content(self, scroll_offset): + self._title_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16 + scroll_offset, + self._title_header.rect.width, + self._title_header.rect.height, + )) + + self._warning_label.render(rl.Rectangle( + self._rect.x + 16, + self._title_header.rect.y + self._title_header.rect.height + 16, + self._rect.width - 32, + self._warning_label.get_content_height(int(self._rect.width - 32)), + )) + + +class TrainingGuideEnd(SetupTermsPage): + def __init__(self, continue_callback): + super().__init__(continue_callback, continue_text="finish") + self._title_header = TermsHeader("training complete!", gui_app.texture("icons_mici/setup/green_info.png", 60, 60)) + self._warning_label = UnifiedLabel("You have completed the openpilot training.\n\n" + + "This guide can be revisited at any time in Settings.", 42, + FontWeight.ROMAN) + + @property + def _content_height(self): + return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() + + def _render_content(self, scroll_offset): + self._title_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16 + scroll_offset, + self._title_header.rect.width, + self._title_header.rect.height, + )) + + self._warning_label.render(rl.Rectangle( + self._rect.x + 16, + self._title_header.rect.y + self._title_header.rect.height + 16, + self._rect.width - 32, + self._warning_label.get_content_height(int(self._rect.width - 32)), + )) + + class TrainingGuideDisengaging(SetupTermsPage): def __init__(self, continue_callback): super().__init__(continue_callback, continue_text="continue") self._title_header = TermsHeader("disengaging openpilot", gui_app.texture("icons_mici/setup/green_pedal.png", 60, 60)) self._warning_label = UnifiedLabel("You can disengage openpilot by either pressing the brake pedal or " + - "the cancel button on your steering wheel.", 42, + "the cruise control cancel button.", 42, FontWeight.ROMAN) @property @@ -352,7 +408,7 @@ class TrainingGuideSteeringArc(SetupTermsPage): TORQUE_BAR_HEIGHT = 100 def __init__(self, continue_callback): - super().__init__(continue_callback, continue_text="finish") + super().__init__(continue_callback, continue_text="continue") self._torque_bar = TorqueBar(demo=True) self._start_time = 0.0 @@ -433,9 +489,11 @@ class TrainingGuide(Widget): TrainingGuidePreDMTutorial(continue_callback=self._advance_step), TrainingGuideDMTutorial(continue_callback=self._advance_step), TrainingGuideRecordFront(continue_callback=self._advance_step), + TrainingGuideEngaging(continue_callback=self._advance_step), TrainingGuideDisengaging(continue_callback=self._advance_step), TrainingGuideConfidenceBall(continue_callback=self._advance_step), TrainingGuideSteeringArc(continue_callback=self._advance_step), + TrainingGuideEnd(continue_callback=self._advance_step), ] def _advance_step(self): @@ -497,7 +555,7 @@ class TermsPage(SetupTermsPage): super().__init__(on_accept, on_decline, "decline") info_txt = gui_app.texture("icons_mici/setup/green_info.png", 60, 60) - self._title_header = TermsHeader("scroll down to read &\n accept terms", info_txt) + self._title_header = TermsHeader("terms & conditions", info_txt) self._terms_label = UnifiedLabel("You must accept the Terms and Conditions to use openpilot. " + "Read the latest terms at https://comma.ai/terms before continuing.", 36, From a46af06baaf9a654211b9420963f02ad9f7a3d22 Mon Sep 17 00:00:00 2001 From: Maxime Desroches Date: Thu, 20 Nov 2025 14:28:48 -0800 Subject: [PATCH 38/48] document `MAGIC_DEBUG=1` --- system/ui/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/system/ui/README.md b/system/ui/README.md index 21c8ab974a..f81cb5573a 100644 --- a/system/ui/README.md +++ b/system/ui/README.md @@ -9,6 +9,7 @@ Quick start: * set `SCALE=1.5` to scale the entire UI by 1.5x * set `BURN_IN=1` to get a burn-in heatmap version of the UI * set `GRID=50` to show a 50-pixel alignment grid overlay +* set `MAGIC_DEBUG=1` to show every dropped frames (only on device) * https://www.raylib.com/cheatsheet/cheatsheet.html * https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart From 61fffb95787f7ce02bc5b6ed9b0eaa572aea83c8 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Fri, 21 Nov 2025 06:41:08 +0800 Subject: [PATCH 39/48] ui: avoid rendering off-viewport items in Scroller (#36659) avoid rendering off-viewport items in Scroller --- system/ui/widgets/scroller.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index fb7f635be0..9a04e84257 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -161,7 +161,6 @@ class Scroller(Widget): return self.scroll_panel.get_offset() def _render(self, _): - # TODO: don't draw items that are not in the viewport visible_items = [item for item in self._items if item.is_visible] # Add line separator between items @@ -219,6 +218,10 @@ class Scroller(Widget): item.set_position(round(x), round(y)) # round to prevent jumping when settling item.set_parent_rect(self._rect) + # Skip rendering if not in viewport + if not rl.check_collision_recs(item.rect, self._rect): + continue + # Scale each element around its own origin when scrolling scale = self._zoom_filter.x rl.rl_push_matrix() From 4378c4b8bbad1168b06af3de7438f64291bc0d8d Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 20 Nov 2025 14:55:16 -0800 Subject: [PATCH 40/48] ramp up IR faster for driver view (#36663) * ramp up IR faster for driver view * set it --- selfdrive/pandad/pandad.cc | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/selfdrive/pandad/pandad.cc b/selfdrive/pandad/pandad.cc index 2931eb4acd..a76cbc46e3 100644 --- a/selfdrive/pandad/pandad.cc +++ b/selfdrive/pandad/pandad.cc @@ -365,14 +365,19 @@ void process_panda_state(std::vector &pandas, PubMaster *pm, bool engag } void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control) { + static Params params; static SubMaster sm({"deviceState", "driverCameraState"}); static uint64_t last_driver_camera_t = 0; static uint16_t prev_fan_speed = 999; static int ir_pwr = 0; static int prev_ir_pwr = 999; + static uint32_t prev_frame_id = UINT32_MAX; + static bool driver_view = false; + // TODO: can we merge these? static FirstOrderFilter integ_lines_filter(0, 30.0, 0.05); + static FirstOrderFilter integ_lines_filter_driver_view(0, 5.0, 0.05); { sm.update(0); @@ -389,7 +394,15 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control) auto event = sm["driverCameraState"]; int cur_integ_lines = event.getDriverCameraState().getIntegLines(); - cur_integ_lines = integ_lines_filter.update(cur_integ_lines); + // reset the filter when camerad restarts + if (event.getDriverCameraState().getFrameId() < prev_frame_id) { + integ_lines_filter.reset(0); + integ_lines_filter_driver_view.reset(0); + driver_view = params.getBool("IsDriverViewEnabled"); + } + prev_frame_id = event.getDriverCameraState().getFrameId(); + + cur_integ_lines = (driver_view ? integ_lines_filter_driver_view : integ_lines_filter).update(cur_integ_lines); last_driver_camera_t = event.getLogMonoTime(); if (cur_integ_lines <= CUTOFF_IL) { From f19ff793f58b22c052059e92595bd607df44621e Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Thu, 20 Nov 2025 16:27:58 -0800 Subject: [PATCH 41/48] dm more typo --- selfdrive/monitoring/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index 9b9282bf49..7697e68b98 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -242,7 +242,7 @@ class DriverMonitoring: distracted_types.append(DistractedType.DISTRACTED_BLINK) if self.phone.prob_calibrated: - using_phone = self.phone.prob > max(min(self.phone_offseter.filtered_stat.M, self.settings._PHONE_MAX_OFFSET), self.settings._PHONE_MIN_OFFSET) \ + using_phone = self.phone.prob > max(min(self.phone.prob_offseter.filtered_stat.M, self.settings._PHONE_MAX_OFFSET), self.settings._PHONE_MIN_OFFSET) \ * self.settings._PHONE_THRESH2 else: using_phone = self.phone.prob > self.settings._PHONE_THRESH From 5151bb8bf2dee8ab1cc8452bfae07c912e45407d Mon Sep 17 00:00:00 2001 From: Maxime Desroches Date: Thu, 20 Nov 2025 17:06:24 -0800 Subject: [PATCH 42/48] installer: use release-mici for comma four --- selfdrive/ui/installer/installer.cc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/selfdrive/ui/installer/installer.cc b/selfdrive/ui/installer/installer.cc index 38dd1ce25c..072fa4e24b 100644 --- a/selfdrive/ui/installer/installer.cc +++ b/selfdrive/ui/installer/installer.cc @@ -66,6 +66,12 @@ void branchMigration() { } else if (BRANCH_STR == "release3-staging") { migrated_branch = "release-tizi-staging"; } + } else if (device_type == cereal::InitData::DeviceType::MICI) { + if (BRANCH_STR == "release3") { + migrated_branch = "release-mici"; + } else if (BRANCH_STR == "release3-staging") { + migrated_branch = "release-mici-staging"; + } } } From 8bc6becce1aa00eaa91b9916ad2db12725422cee Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 20 Nov 2025 18:54:54 -0800 Subject: [PATCH 43/48] simplify mici onboarding (#36666) * simplify mici onboarding * shorter * dead * cleanup --- selfdrive/ui/mici/layouts/onboarding.py | 293 +----------------------- 1 file changed, 9 insertions(+), 284 deletions(-) diff --git a/selfdrive/ui/mici/layouts/onboarding.py b/selfdrive/ui/mici/layouts/onboarding.py index 66a0919b09..afc7bfce17 100644 --- a/selfdrive/ui/mici/layouts/onboarding.py +++ b/selfdrive/ui/mici/layouts/onboarding.py @@ -9,8 +9,6 @@ from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.slider import SmallSlider from openpilot.system.ui.mici_setup import TermsHeader, TermsPage as SetupTermsPage from openpilot.selfdrive.ui.ui_state import ui_state, device -from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall -from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog from openpilot.system.ui.widgets.label import gui_label @@ -55,43 +53,13 @@ class DriverCameraSetupDialog(DriverCameraDialog): return -1 -class TrainingGuideIntro(SetupTermsPage): - def __init__(self, continue_callback): - super().__init__(continue_callback, continue_text="continue") - self._title_header = TermsHeader("welcome to openpilot", gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 60, 60)) - - self._dm_label = UnifiedLabel("Before we get on the road, let's review the " + - "functionality and limitations of openpilot.", 42, - FontWeight.ROMAN) - - @property - def _content_height(self): - return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._dm_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._dm_label.get_content_height(int(self._rect.width - 32)), - )) - - class TrainingGuidePreDMTutorial(SetupTermsPage): def __init__(self, continue_callback): super().__init__(continue_callback, continue_text="continue") self._title_header = TermsHeader("driver monitoring setup", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60)) self._dm_label = UnifiedLabel("Next, we'll ensure comma four is mounted properly.\n\nIf it does not have a clear view of the driver, " + - "simply unplug and remount before continuing.\n\n" + - "NOTE: the driver camera will have a purple tint due to the IR illumination used for seeing at night.", 42, + "unplug and remount before continuing.", 42, FontWeight.ROMAN) def show_event(self): @@ -182,8 +150,7 @@ class TrainingGuideRecordFront(SetupTermsPage): super().__init__(on_continue, back_callback=on_back, back_text="no", continue_text="yes") self._title_header = TermsHeader("improve driver monitoring", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60)) - self._dm_label = UnifiedLabel("Help improve driver monitoring by including your driving data in the training data set. " + - "Your preference can be changed at any time in Settings.\n\nWould you like to share your data?", 42, + self._dm_label = UnifiedLabel("Do you want to upload driver camera data to improve driver monitoring?", 42, FontWeight.ROMAN) def show_event(self): @@ -211,11 +178,14 @@ class TrainingGuideRecordFront(SetupTermsPage): )) -class TrainingGuideAttentionNotice1(SetupTermsPage): +class TrainingGuideAttentionNotice(SetupTermsPage): def __init__(self, continue_callback): super().__init__(continue_callback, continue_text="continue") - self._title_header = TermsHeader("not a self driving car", gui_app.texture("icons_mici/setup/warning.png", 60, 60)) - self._warning_label = UnifiedLabel("THIS IS A DRIVER ASSISTANCE SYSTEM. A DRIVER ASSISTANCE SYSTEM IS NOT A SELF-DRIVING CAR.", 42, + self._title_header = TermsHeader("driver assistance", gui_app.texture("icons_mici/setup/warning.png", 60, 60)) + self._warning_label = UnifiedLabel("1. openpilot is a driver assistance system.\n\n" + + "2. You must pay attention at all times.\n\n" + + "3. You must be ready to take over at any time.\n\n" + + "4. You are fully responsible for driving the car.", 42, FontWeight.ROMAN) @property @@ -238,244 +208,6 @@ class TrainingGuideAttentionNotice1(SetupTermsPage): )) -class TrainingGuideAttentionNotice2(SetupTermsPage): - def __init__(self, continue_callback): - super().__init__(continue_callback, continue_text="continue") - self._title_header = TermsHeader("attention is required", gui_app.texture("icons_mici/setup/warning.png", 60, 60)) - self._warning_label = UnifiedLabel("1. You must pay attention at all times.\n\n2. You must be ready to take over at any time." + - "\n\n3. You are fully responsible for driving the car.", 42, - FontWeight.ROMAN) - - @property - def _content_height(self): - return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._warning_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._warning_label.get_content_height(int(self._rect.width - 32)), - )) - -class TrainingGuideEngaging(SetupTermsPage): - def __init__(self, continue_callback): - super().__init__(continue_callback, continue_text="continue") - self._title_header = TermsHeader("engaging openpilot", gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 60, 60)) - self._warning_label = UnifiedLabel("You can engage openpilot using your car's cruise control inputs.\n\n" + - "These are usually located on either the steering wheel or on a lever near the wheel.", 42, - FontWeight.ROMAN) - - @property - def _content_height(self): - return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._warning_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._warning_label.get_content_height(int(self._rect.width - 32)), - )) - - -class TrainingGuideEnd(SetupTermsPage): - def __init__(self, continue_callback): - super().__init__(continue_callback, continue_text="finish") - self._title_header = TermsHeader("training complete!", gui_app.texture("icons_mici/setup/green_info.png", 60, 60)) - self._warning_label = UnifiedLabel("You have completed the openpilot training.\n\n" + - "This guide can be revisited at any time in Settings.", 42, - FontWeight.ROMAN) - - @property - def _content_height(self): - return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._warning_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._warning_label.get_content_height(int(self._rect.width - 32)), - )) - - - -class TrainingGuideDisengaging(SetupTermsPage): - def __init__(self, continue_callback): - super().__init__(continue_callback, continue_text="continue") - self._title_header = TermsHeader("disengaging openpilot", gui_app.texture("icons_mici/setup/green_pedal.png", 60, 60)) - self._warning_label = UnifiedLabel("You can disengage openpilot by either pressing the brake pedal or " + - "the cruise control cancel button.", 42, - FontWeight.ROMAN) - - @property - def _content_height(self): - return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._warning_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._warning_label.get_content_height(int(self._rect.width - 32)), - )) - - -class TrainingGuideConfidenceBall(SetupTermsPage): - ANIMATION_PAUSE = 3.5 - - def __init__(self, continue_callback): - super().__init__(continue_callback, continue_text="continue") - self._confidence_ball = ConfidenceBall(demo=True) - self._start_time = 0.0 - - self._title_header = TermsHeader("confidence ball", gui_app.texture("icons_mici/setup/green_car.png", 60, 60)) - self._warning_label = UnifiedLabel("The ball on the right communicates how confident openpilot " + - "is about the road scene at any given time.", 42, - FontWeight.ROMAN) - - def show_event(self): - super().show_event() - self._start_time = rl.get_time() - - @property - def _content_height(self): - return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - # room for confidence ball - label_width = self._rect.width - 32 - 60 - self._warning_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - label_width, - self._warning_label.get_content_height(int(label_width)), - )) - - duration = rl.get_time() - self._start_time - if duration > 5 + self.ANIMATION_PAUSE * 2: - # reset animation - self._start_time = rl.get_time() - if duration > 5 + self.ANIMATION_PAUSE: - self._confidence_ball.update_filter(0.1) - elif duration > 5: - self._confidence_ball.update_filter(0.4) - elif duration > 0.5: - self._confidence_ball.update_filter(0.9) - - self._confidence_ball.render(self._rect) - self._rect.width -= 60 - - -class TrainingGuideSteeringArc(SetupTermsPage): - ANIMATION_PAUSE = 2 - TORQUE_BAR_HEIGHT = 100 - - def __init__(self, continue_callback): - super().__init__(continue_callback, continue_text="continue") - self._torque_bar = TorqueBar(demo=True) - self._start_time = 0.0 - - self._title_header = TermsHeader("steering arc", gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 60, 60)) - self._warning_label = UnifiedLabel("All cars limit the amount of steering that openpilot is able to apply. While driving, the " + - "steering arc shows the current amount of force being applied in relation to the maximum available to openpilot. " + - "You may need to assist if you see the arc nearing its orange state.", 42, - FontWeight.ROMAN) - - def show_event(self): - super().show_event() - self._start_time = rl.get_time() - - @property - def _content_height(self): - return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() + self.TORQUE_BAR_HEIGHT - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._warning_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._warning_label.get_content_height(int(self._rect.width - 32)), - )) - - duration = rl.get_time() - self._start_time - if duration > self.ANIMATION_PAUSE * 5: - # reset animation - self._start_time = rl.get_time() - elif duration > self.ANIMATION_PAUSE * 4: - self._torque_bar.update_filter(-1.0) - elif duration > self.ANIMATION_PAUSE * 3: - self._torque_bar.update_filter(-0.2) - elif duration > self.ANIMATION_PAUSE * 2: - self._torque_bar.update_filter(1.0) - elif duration > self.ANIMATION_PAUSE: - self._torque_bar.update_filter(0.7) - else: - self._torque_bar.update_filter(0.0) - - # background gradient for torque bar legibility - rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height * 0.6), - int(self._rect.width), int(self._rect.height * 0.2), - rl.BLANK, rl.Color(0, 0, 0, int(255 * 0.9))) - rl.draw_rectangle(int(self._rect.x), int(self._rect.y + self._rect.height * 0.8), - int(self._rect.width), int(self._rect.height * 0.2), - rl.Color(0, 0, 0, int(255 * 0.9))) - - # scroll torque bar once we get to the bottom of content - torque_y_offset = min(0.0, self._warning_label.rect.y + self._warning_label.rect.height - - self._rect.height + self.TORQUE_BAR_HEIGHT) - torque_rect = rl.Rectangle( - self._rect.x, - self._rect.y + torque_y_offset, - self._rect.width, - self._rect.height, - ) - self._torque_bar.render(torque_rect) - - class TrainingGuide(Widget): def __init__(self, completed_callback=None): super().__init__() @@ -483,17 +215,10 @@ class TrainingGuide(Widget): self._step = 0 self._steps = [ - TrainingGuideIntro(continue_callback=self._advance_step), - TrainingGuideAttentionNotice1(continue_callback=self._advance_step), - TrainingGuideAttentionNotice2(continue_callback=self._advance_step), + TrainingGuideAttentionNotice(continue_callback=self._advance_step), TrainingGuidePreDMTutorial(continue_callback=self._advance_step), TrainingGuideDMTutorial(continue_callback=self._advance_step), TrainingGuideRecordFront(continue_callback=self._advance_step), - TrainingGuideEngaging(continue_callback=self._advance_step), - TrainingGuideDisengaging(continue_callback=self._advance_step), - TrainingGuideConfidenceBall(continue_callback=self._advance_step), - TrainingGuideSteeringArc(continue_callback=self._advance_step), - TrainingGuideEnd(continue_callback=self._advance_step), ] def _advance_step(self): From a49c68806ad0f6198298e47a177dadb4f8fa7837 Mon Sep 17 00:00:00 2001 From: Maxime Desroches Date: Thu, 20 Nov 2025 19:28:21 -0800 Subject: [PATCH 44/48] ui: fix confidence ball clipping (#36667) fix --- selfdrive/ui/mici/onroad/confidence_ball.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfdrive/ui/mici/onroad/confidence_ball.py b/selfdrive/ui/mici/onroad/confidence_ball.py index de792282c5..a5c95470f5 100644 --- a/selfdrive/ui/mici/onroad/confidence_ball.py +++ b/selfdrive/ui/mici/onroad/confidence_ball.py @@ -16,7 +16,7 @@ def draw_circle_gradient(center_x: float, center_y: float, radius: int, # Paint over square with a ring outer_radius = math.ceil(radius * math.sqrt(2)) + 1 - rl.draw_ring(rl.Vector2(center_x, center_y), radius, outer_radius, + rl.draw_ring(rl.Vector2(int(center_x), int(center_y)), radius, outer_radius, 0.0, 360.0, 20, rl.BLACK) From 04eac60983141a4a615bda33b37515f5d8fa1c51 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Fri, 21 Nov 2025 14:03:02 -0500 Subject: [PATCH 45/48] msgq: point back to comma's (#1498) --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index b9f0336a0e..cd6cf2168f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,7 +6,7 @@ url = https://github.com/sunnypilot/opendbc.git [submodule "msgq"] path = msgq_repo - url = https://github.com/sunnypilot/msgq.git + url = https://github.com/commaai/msgq.git [submodule "rednose_repo"] path = rednose_repo url = https://github.com/commaai/rednose.git From f43479aac5f64b24e1e3b28a7dd7a2567e826d31 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Fri, 21 Nov 2025 14:10:33 -0500 Subject: [PATCH 46/48] ci: update workflow file name for prebuilt jobs (#1499) --- .github/workflows/sunnypilot-build-prebuilt.yaml | 4 ++-- .github/workflows/sunnypilot-master-dev-prep.yaml | 6 +++--- .github/workflows/wait-for-action/action.yaml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 12fb01cbd1..d3ad2d2419 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -22,7 +22,7 @@ on: workflow_dispatch: inputs: wait_for_tests: - description: 'Wait for selfdrive_tests to finish' + description: 'Wait for tests to finish' required: false type: boolean default: false @@ -99,7 +99,7 @@ jobs: - name: Wait for Tests uses: ./.github/workflows/wait-for-action # Path to where you place the action with: - workflow: selfdrive_tests.yaml # The workflow file to monitor + workflow: tests.yaml # The workflow file to monitor github-token: ${{ secrets.GITHUB_TOKEN }} should-wait-for-start: ${{ github.event_name == 'push' && 'true' || 'false' }} diff --git a/.github/workflows/sunnypilot-master-dev-prep.yaml b/.github/workflows/sunnypilot-master-dev-prep.yaml index e93e778aa6..e7a9663743 100644 --- a/.github/workflows/sunnypilot-master-dev-prep.yaml +++ b/.github/workflows/sunnypilot-master-dev-prep.yaml @@ -57,7 +57,7 @@ jobs: || (contains(github.event_name, 'pull_request') && ((github.event.action == 'labeled' && (github.event.label.name == vars.PREBUILT_PR_LABEL || github.event.label.name == 'trust-fork-pr') && contains(github.event.pull_request.labels.*.name, vars.PREBUILT_PR_LABEL)))) ) with: - workflow: selfdrive_tests.yaml # The workflow file to monitor + workflow: tests.yaml # The workflow file to monitor github-token: ${{ secrets.GITHUB_TOKEN }} - name: Configure Git @@ -201,13 +201,13 @@ jobs: if: steps.push-changes.outputs.has_changes == 'true' run: | echo "Triggering selfdrive tests..." - gh workflow run selfdrive_tests.yaml --ref "${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" + gh workflow run tests.yaml --ref "${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" echo "Sleeping for 120s to give plenty of time for the action to start and then we wait" sleep 120 echo "Getting latest run ID..." - RUN_ID=$(gh run list --workflow=selfdrive_tests.yaml --branch="${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" --limit=1 --json databaseId --jq '.[0].databaseId') + RUN_ID=$(gh run list --workflow=tests.yaml --branch="${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" --limit=1 --json databaseId --jq '.[0].databaseId') echo "Watching run ID: $RUN_ID" gh run watch "$RUN_ID" diff --git a/.github/workflows/wait-for-action/action.yaml b/.github/workflows/wait-for-action/action.yaml index 9cde4cf076..01bc614618 100644 --- a/.github/workflows/wait-for-action/action.yaml +++ b/.github/workflows/wait-for-action/action.yaml @@ -4,7 +4,7 @@ inputs: workflow: description: 'The workflow file name to monitor' required: true - default: 'selfdrive_tests.yaml' + default: 'tests.yaml' branch: description: 'The branch to monitor (defaults to current branch)' required: false From 4d2ad45be6bcede183faa5f88fb4738f117a7b39 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 21 Nov 2025 14:22:23 -0500 Subject: [PATCH 47/48] [bot] Update Python packages (#1483) Update Python packages Co-authored-by: github-actions[bot] --- docs/CARS.md | 703 ++++++++++++++++++++++++++------------------------- opendbc_repo | 2 +- 2 files changed, 353 insertions(+), 352 deletions(-) diff --git a/docs/CARS.md b/docs/CARS.md index 695c2589e3..4c1e90e18d 100644 --- a/docs/CARS.md +++ b/docs/CARS.md @@ -4,349 +4,351 @@ A supported vehicle is one that just works when you install a comma device. All supported cars provide a better experience than any stock system. Supported vehicles reference the US market unless otherwise specified. -# 339 Supported Cars +# 341 Supported Cars |Make|Model|Supported Package|ACC|No ACC accel below|No ALC below|Steering Torque|Resume from stop|Hardware Needed
 |Video|Setup Video| |---|---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -|Acura|ILX 2016-18|Technology Plus Package or AcuraWatch Plus|openpilot|26 mph|25 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Acura|ILX 2019|All|openpilot|26 mph|25 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Acura|MDX 2025|All except Type S|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Acura|RDX 2016-18|AcuraWatch Plus or Advance Package|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Acura|RDX 2019-21|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Audi|A3 2014-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Audi|A3 Sportback e-tron 2017-18|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Audi|Q2 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Audi|Q3 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Audi|RS3 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Audi|S3 2015-17|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chevrolet|Bolt EUV 2022-23|Premier or Premier Redline Trim without Super Cruise Package|openpilot available[1](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chevrolet|Bolt EV 2022-23|2LT Trim with Adaptive Cruise Control Package|openpilot available[1](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chevrolet|Bolt EV Non-ACC 2017|Adaptive Cruise Control (ACC)|Stock|24 mph|7 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chevrolet|Bolt EV Non-ACC 2018-21|Adaptive Cruise Control (ACC)|Stock|24 mph|7 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chevrolet|Equinox 2019-22|Adaptive Cruise Control (ACC)|openpilot available[1](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chevrolet|Malibu Non-ACC 2016-23|Adaptive Cruise Control (ACC)|Stock|24 mph|7 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chevrolet|Silverado 1500 2020-21|Safety Package II|openpilot available[1](#footnotes)|0 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chevrolet|Trailblazer 2021-22|Adaptive Cruise Control (ACC)|openpilot available[1](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chrysler|Pacifica 2017-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chrysler|Pacifica 2019-20|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chrysler|Pacifica 2021-23|All|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chrysler|Pacifica Hybrid 2017-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Chrysler|Pacifica Hybrid 2019-25|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| +|Acura|ILX 2016-18|Technology Plus Package or AcuraWatch Plus|openpilot|26 mph|25 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Acura|ILX 2019|All|openpilot|26 mph|25 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Acura|MDX 2025|All except Type S|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Acura|RDX 2016-18|AcuraWatch Plus or Advance Package|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Acura|RDX 2019-21|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Acura|TLX 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Audi|A3 2014-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Audi|A3 Sportback e-tron 2017-18|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Audi|Q2 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Audi|Q3 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Audi|RS3 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Audi|S3 2015-17|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Chevrolet|Bolt EUV 2022-23|Premier or Premier Redline Trim without Super Cruise Package|openpilot available[1](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| +|Chevrolet|Bolt EV 2022-23|2LT Trim with Adaptive Cruise Control Package|openpilot available[1](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| +|Chevrolet|Bolt EV Non-ACC 2017|Adaptive Cruise Control (ACC)|Stock|24 mph|7 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| +|Chevrolet|Bolt EV Non-ACC 2018-21|Adaptive Cruise Control (ACC)|Stock|24 mph|7 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| +|Chevrolet|Equinox 2019-22|Adaptive Cruise Control (ACC)|openpilot available[1](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| +|Chevrolet|Malibu Non-ACC 2016-23|Adaptive Cruise Control (ACC)|Stock|24 mph|7 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| +|Chevrolet|Silverado 1500 2020-21|Safety Package II|openpilot available[1](#footnotes)|0 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| +|Chevrolet|Trailblazer 2021-22|Adaptive Cruise Control (ACC)|openpilot available[1](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| +|Chrysler|Pacifica 2017-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Chrysler|Pacifica 2019-20|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Chrysler|Pacifica 2021-23|All|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Chrysler|Pacifica Hybrid 2017-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Chrysler|Pacifica Hybrid 2019-25|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |comma|body|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|None||| -|CUPRA|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Bronco Sport 2021-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Escape 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Escape 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Escape Hybrid 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Escape Hybrid 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Escape Plug-in Hybrid 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Escape Plug-in Hybrid 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Expedition 2022-24|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Explorer 2020-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Explorer Hybrid 2020-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|F-150 2021-23|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|F-150 Hybrid 2021-23|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Focus 2018[3](#footnotes)|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Focus Hybrid 2018[3](#footnotes)|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Kuga 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Kuga Hybrid 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Kuga Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Kuga Plug-in Hybrid 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Kuga Plug-in Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Maverick 2022|LARIAT Luxury|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Maverick 2023-24|Co-Pilot360 Assist|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Maverick Hybrid 2022|LARIAT Luxury|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Maverick Hybrid 2023-24|Co-Pilot360 Assist|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Mustang Mach-E 2021-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ford|Ranger 2024|Adaptive Cruise Control with Lane Centering|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|G70 2018|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|G70 2019-21|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|G70 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|G80 2017|All|Stock|19 mph|37 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai J connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|G80 2018-19|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|G80 (2.5T Advanced Trim, with HDA II) 2024[6](#footnotes)|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai P connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|G90 2017-20|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|GV60 (Advanced Trim) 2023[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|GV60 (Performance Trim) 2022-23[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|GV70 (2.5T Trim, without HDA II) 2022-24[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|GV70 (3.5T Trim, without HDA II) 2022-23[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai M connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|GV70 Electrified (Australia Only) 2022[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|GV70 Electrified (with HDA II) 2023-24[6](#footnotes)|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Genesis|GV80 2023[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai M connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|GMC|Sierra 1500 2020-21|Driver Alert Package II|openpilot available[1](#footnotes)|0 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Accord 2018-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Accord 2023-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Accord Hybrid 2018-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Accord Hybrid 2023-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|City (Brazil only) 2023|All|openpilot available[1](#footnotes)|0 mph|14 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Civic 2016-18|Honda Sensing|openpilot|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Civic 2019-21|All|openpilot available[1](#footnotes)|0 mph|2 mph[5](#footnotes)|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Civic 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Civic Hatchback 2017-18|Honda Sensing|openpilot available[1](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Civic Hatchback 2019-21|All|openpilot available[1](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Civic Hatchback 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Civic Hatchback Hybrid 2025-26|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Civic Hatchback Hybrid (Europe only) 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Civic Hybrid 2025|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Clarity 2018-21|Honda Sensing|openpilot|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Nidec connector + Honda Clarity Proxy Board
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|CR-V 2015-16|Touring Trim|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|CR-V 2017-22|Honda Sensing|openpilot available[1](#footnotes)|0 mph|15 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|CR-V 2023-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|CR-V Hybrid 2017-22|Honda Sensing|openpilot available[1](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|CR-V Hybrid 2023-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|e 2020|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Fit 2018-20|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Freed 2020|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|HR-V 2019-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|HR-V 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Insight 2019-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Inspire 2018|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|N-Box 2018|All|openpilot available[1](#footnotes)|0 mph|11 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Odyssey 2018-20|Honda Sensing|openpilot|26 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Odyssey 2021-25|All|openpilot available[1](#footnotes)|0 mph|43 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Passport 2019-25|All|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Pilot 2016-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Pilot 2023-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Honda|Ridgeline 2017-25|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Azera 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Azera Hybrid 2019|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Azera Hybrid 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Custin 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Elantra 2017-18|Smart Cruise Control (SCC)|Stock|19 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Elantra 2019|Smart Cruise Control (SCC)|Stock|19 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai G connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Elantra 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Elantra GT 2017-20|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Elantra Hybrid 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Elantra Non-SCC 2022|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Genesis 2015-16|Smart Cruise Control (SCC)|Stock|19 mph|37 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai J connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|i30 2017-19|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Ioniq 5 (Southeast Asia and Europe only) 2022-24[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Ioniq 5 (with HDA II) 2022-24[6](#footnotes)|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Ioniq 5 (without HDA II) 2022-24[6](#footnotes)|Highway Driving Assist|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Ioniq 6 (with HDA II) 2023-24[6](#footnotes)|Highway Driving Assist II|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai P connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Ioniq Electric 2019|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Ioniq Electric 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Ioniq Hybrid 2017-19|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Ioniq Hybrid 2020-22|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Ioniq Plug-in Hybrid 2019|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Ioniq Plug-in Hybrid 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Kona 2020|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|6 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Kona 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai O connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Kona Electric 2018-21|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai G connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Kona Electric 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai O connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Kona Electric (with HDA II, Korea only) 2023[6](#footnotes)|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai R connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Kona Electric Non-SCC 2019|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai G connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Kona Hybrid 2020|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai I connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Kona Non-SCC 2019|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Nexo 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Palisade 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Santa Cruz 2022-24[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Santa Fe 2019-20|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Santa Fe 2021-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Santa Fe Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Santa Fe Plug-in Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Sonata 2018-19|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Sonata 2020-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Sonata Hybrid 2020-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Staria 2023[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Tucson 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Tucson 2022[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Tucson 2023-24[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Tucson Diesel 2019|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Tucson Hybrid 2022-24[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Tucson Plug-in Hybrid 2024[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Hyundai|Veloster 2019-20|Smart Cruise Control (SCC)|Stock|5 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Jeep|Grand Cherokee 2016-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Jeep|Grand Cherokee 2019-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Carnival 2022-24[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Carnival (China only) 2023[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Ceed 2019-21|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Ceed Plug-in Hybrid Non-SCC 2022|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai I connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|EV6 (Southeast Asia only) 2022-24[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai P connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|EV6 (with HDA II) 2022-24[6](#footnotes)|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai P connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|EV6 (without HDA II) 2022-24[6](#footnotes)|Highway Driving Assist|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Forte 2019-21|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|6 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai G connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Forte 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Forte Non-SCC 2019|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai G connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|K5 2021-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|K5 Hybrid 2020-22|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|K8 Hybrid (with HDA II) 2023[6](#footnotes)|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro EV 2019|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro EV 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro EV 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro EV 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro EV (with HDA II) 2025[6](#footnotes)|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai R connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro EV (without HDA II) 2023-25[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro Hybrid 2018|Smart Cruise Control (SCC)|Stock|10 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro Hybrid 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro Hybrid 2022|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro Hybrid 2023[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro Plug-in Hybrid 2018-19|All|Stock|10 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro Plug-in Hybrid 2020|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro Plug-in Hybrid 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Niro Plug-in Hybrid 2022|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Optima 2017|Advanced Smart Cruise Control|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Optima 2019-20|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai G connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Optima Hybrid 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Seltos 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Sorento 2018|Advanced Smart Cruise Control & LKAS|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Sorento 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Sorento 2021-23[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Sorento Hybrid 2021-23[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Sorento Plug-in Hybrid 2022-23[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Sportage 2023-24[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Sportage Hybrid 2023[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Stinger 2018-20|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Stinger 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Kia|Telluride 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|CT Hybrid 2017-18|Lexus Safety System+|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|ES 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|ES 2019-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|ES Hybrid 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|ES Hybrid 2019-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|GS F 2016|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|IS 2017-19|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|IS 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|LC 2024-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|NX 2018-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|NX 2020-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|NX Hybrid 2018-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|NX Hybrid 2020-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|RC 2018-20|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|RC 2023|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|RX 2016|Lexus Safety System+|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|RX 2017-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|RX 2020-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|RX Hybrid 2016|Lexus Safety System+|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|RX Hybrid 2017-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|RX Hybrid 2020-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lexus|UX Hybrid 2019-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lincoln|Aviator 2020-24|Co-Pilot360 Plus|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Lincoln|Aviator Plug-in Hybrid 2020-24|Co-Pilot360 Plus|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|MAN|eTGE 2020-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|MAN|TGE 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Mazda|CX-5 2022-25|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Mazda connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Mazda|CX-9 2021-23|All|Stock|0 mph|28 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Mazda connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Nissan[7](#footnotes)|Altima 2019-20|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan B connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Nissan[7](#footnotes)|Leaf 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Nissan[7](#footnotes)|Rogue 2018-20|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Nissan[7](#footnotes)|X-Trail 2017|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ram|1500 2019-24|Adaptive Cruise Control (ACC)|Stock|32 mph|1 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Ram connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ram|2500 2020-24|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ram connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Ram|3500 2019-22|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ram connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Rivian|R1S 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Rivian A connector
- 1 USB-C coupler
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Rivian|R1T 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Rivian A connector
- 1 USB-C coupler
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|SEAT|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|SEAT|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Subaru|Ascent 2019-21|All[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Forester 2017-18|EyeSight Driver Assistance[8](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Forester 2019-21|All[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Impreza 2017-19|EyeSight Driver Assistance[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Impreza 2020-22|EyeSight Driver Assistance[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Legacy 2015-18|EyeSight Driver Assistance[8](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Legacy 2020-22|All[8](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Subaru B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Outback 2015-17|EyeSight Driver Assistance[8](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Outback 2018-19|EyeSight Driver Assistance[8](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Outback 2020-22|All[8](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Subaru B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|XV 2018-19|EyeSight Driver Assistance[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|XV 2020-21|EyeSight Driver Assistance[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Škoda|Fabia 2022-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
[17](#footnotes)||| -|Škoda|Kamiq 2021-23[13,15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
[17](#footnotes)||| -|Škoda|Karoq 2019-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Škoda|Kodiaq 2017-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Škoda|Octavia 2015-19[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Škoda|Octavia RS 2016[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Škoda|Octavia Scout 2017-19[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Škoda|Scala 2020-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
[17](#footnotes)||| -|Škoda|Superb 2015-22[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Tesla[11](#footnotes)|Model 3 (with HW3) 2019-23[10](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Tesla A connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Tesla[11](#footnotes)|Model 3 (with HW4) 2024-25[10](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Tesla B connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Tesla[11](#footnotes)|Model Y (with HW3) 2020-23[10](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Tesla A connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Tesla[11](#footnotes)|Model Y (with HW4) 2024-25[10](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Tesla B connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Alphard 2019-20|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Alphard Hybrid 2021|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Avalon 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Avalon 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Avalon 2019-21|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Avalon 2022|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Avalon Hybrid 2019-21|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Avalon Hybrid 2022|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|C-HR 2017-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|C-HR 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|C-HR Hybrid 2017-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|C-HR Hybrid 2021-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Camry 2018-20|All|Stock|0 mph[12](#footnotes)|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Camry 2021-24|All|openpilot|0 mph[12](#footnotes)|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Camry Hybrid 2018-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Camry Hybrid 2021-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Corolla 2017-19|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Corolla 2020-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Corolla Cross (Non-US only) 2020-23|All|openpilot|17 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Corolla Cross Hybrid (Non-US only) 2020-22|All|openpilot|17 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Corolla Hatchback 2019-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Corolla Hybrid 2020-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Corolla Hybrid (South America only) 2020-23|All|openpilot|17 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Highlander 2017-19|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Highlander 2020-23|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Highlander Hybrid 2017-19|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Highlander Hybrid 2020-23|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Mirai 2021|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Prius 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Prius 2017-20|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Prius 2021-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Prius Prime 2017-20|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Prius Prime 2021-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Prius v 2017|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|RAV4 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|RAV4 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|RAV4 2019-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|RAV4 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|RAV4 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|RAV4 Hybrid 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|RAV4 Hybrid 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|RAV4 Hybrid 2019-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|RAV4 Hybrid 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|RAV4 Hybrid 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Toyota|Sienna 2018-20|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Arteon 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Arteon eHybrid 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Arteon R 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Arteon Shooting Brake 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Atlas 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Atlas Cross Sport 2020-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|California 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Caravelle 2020|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|CC 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Crafter 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|e-Crafter 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|e-Golf 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Golf 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Golf Alltrack 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Golf GTD 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Golf GTE 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Golf GTI 2015-21|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Jetta 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Passat 2015-22[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Passat Alltrack 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Passat GTE 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Polo 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
[17](#footnotes)||| -|Volkswagen|Polo GTI 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
[17](#footnotes)||| -|Volkswagen|T-Cross 2021|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
[17](#footnotes)||| -|Volkswagen|T-Roc 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Taos 2022-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Teramont 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Teramont Cross Sport 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Teramont X 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Tiguan 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Tiguan eHybrid 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| -|Volkswagen|Touran 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here
||| +|CUPRA|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Bronco Sport 2021-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Escape 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Escape 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Escape Hybrid 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Escape Hybrid 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Escape Plug-in Hybrid 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Escape Plug-in Hybrid 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Expedition 2022-24|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Explorer 2020-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Explorer Hybrid 2020-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|F-150 2021-23|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|F-150 Hybrid 2021-23|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Focus 2018[3](#footnotes)|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Focus Hybrid 2018[3](#footnotes)|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Kuga 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Kuga Hybrid 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Kuga Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Kuga Plug-in Hybrid 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Kuga Plug-in Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Maverick 2022|LARIAT Luxury|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Maverick 2023-24|Co-Pilot360 Assist|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Maverick Hybrid 2022|LARIAT Luxury|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Maverick Hybrid 2023-24|Co-Pilot360 Assist|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ford|Mustang Mach-E 2021-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Ranger 2024|Adaptive Cruise Control with Lane Centering|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Genesis|G70 2018|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|G70 2019-21|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|G70 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|G80 2017|All|Stock|19 mph|37 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai J connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|G80 2018-19|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|G80 (2.5T Advanced Trim, with HDA II) 2024|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai P connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|G90 2017-20|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|GV60 (Advanced Trim) 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|GV60 (Performance Trim) 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|GV70 (2.5T Trim, without HDA II) 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|GV70 (3.5T Trim, without HDA II) 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai M connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|GV70 Electrified (Australia Only) 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|GV70 Electrified (with HDA II) 2023-24|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Genesis|GV80 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai M connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|GMC|Sierra 1500 2020-21|Driver Alert Package II|openpilot available[1](#footnotes)|0 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Accord 2018-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Accord 2023-25|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Accord Hybrid 2018-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Accord Hybrid 2023-25|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|City (Brazil only) 2023|All|openpilot available[1](#footnotes)|0 mph|14 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic 2016-18|Honda Sensing|openpilot|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic 2019-21|All|openpilot available[1](#footnotes)|0 mph|2 mph[5](#footnotes)|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic Hatchback 2017-18|Honda Sensing|openpilot available[1](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic Hatchback 2019-21|All|openpilot available[1](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic Hatchback 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic Hatchback Hybrid 2025-26|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic Hatchback Hybrid (Europe only) 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic Hybrid 2025-26|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Clarity 2018-21|Honda Sensing|openpilot|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Nidec connector + Honda Clarity Proxy Board
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|CR-V 2015-16|Touring Trim|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|CR-V 2017-22|Honda Sensing|openpilot available[1](#footnotes)|0 mph|15 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|CR-V 2023-26|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|CR-V Hybrid 2017-22|Honda Sensing|openpilot available[1](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|CR-V Hybrid 2023-25|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|e 2020|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Fit 2018-20|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Freed 2020|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|HR-V 2019-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|HR-V 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Insight 2019-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Inspire 2018|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|N-Box 2018|All|openpilot available[1](#footnotes)|0 mph|11 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Odyssey 2018-20|Honda Sensing|openpilot|26 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Odyssey 2021-26|All|openpilot available[1](#footnotes)|0 mph|43 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Passport 2019-25|All|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Passport 2026|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Pilot 2016-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Pilot 2023-25|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Ridgeline 2017-25|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Azera 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Azera Hybrid 2019|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Azera Hybrid 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Custin 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Elantra 2017-18|Smart Cruise Control (SCC)|Stock|19 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Elantra 2019|Smart Cruise Control (SCC)|Stock|19 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Elantra 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Elantra GT 2017-20|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Elantra Hybrid 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Elantra Non-SCC 2022|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Genesis 2015-16|Smart Cruise Control (SCC)|Stock|19 mph|37 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai J connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|i30 2017-19|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Ioniq 5 (Southeast Asia and Europe only) 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Ioniq 5 (with HDA II) 2022-24|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Ioniq 5 (without HDA II) 2022-24|Highway Driving Assist|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Ioniq 6 (with HDA II) 2023-24|Highway Driving Assist II|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai P connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Ioniq Electric 2019|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Ioniq Electric 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Ioniq Hybrid 2017-19|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Ioniq Hybrid 2020-22|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Ioniq Plug-in Hybrid 2019|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Ioniq Plug-in Hybrid 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Kona 2020|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|6 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Kona 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai O connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Kona Electric 2018-21|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Kona Electric 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai O connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Kona Electric (with HDA II, Korea only) 2023|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai R connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Kona Electric Non-SCC 2019|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Kona Hybrid 2020|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai I connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Kona Non-SCC 2019|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Nexo 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Palisade 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Santa Cruz 2022-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Santa Fe 2019-20|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Santa Fe 2021-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Santa Fe Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Santa Fe Plug-in Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Sonata 2018-19|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Sonata 2020-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Sonata Hybrid 2020-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Staria 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Tucson 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Tucson 2022|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Tucson 2023-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Tucson Diesel 2019|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Tucson Hybrid 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Tucson Plug-in Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Veloster 2019-20|Smart Cruise Control (SCC)|Stock|5 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Jeep|Grand Cherokee 2016-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Jeep|Grand Cherokee 2019-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Carnival 2022-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Carnival (China only) 2023|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Ceed 2019-21|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Ceed Plug-in Hybrid Non-SCC 2022|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai I connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|EV6 (Southeast Asia only) 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai P connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|EV6 (with HDA II) 2022-24|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai P connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|EV6 (without HDA II) 2022-24|Highway Driving Assist|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Forte 2019-21|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|6 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Forte 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Forte Non-SCC 2019|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|K5 2021-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|K5 Hybrid 2020-22|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|K8 Hybrid (with HDA II) 2023|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro EV 2019|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro EV 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro EV 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro EV 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro EV (with HDA II) 2025|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai R connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro EV (without HDA II) 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro Hybrid 2018|Smart Cruise Control (SCC)|Stock|10 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro Hybrid 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro Hybrid 2022|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro Hybrid 2023|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro Plug-in Hybrid 2018-19|All|Stock|10 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro Plug-in Hybrid 2020|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro Plug-in Hybrid 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro Plug-in Hybrid 2022|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Optima 2017|Advanced Smart Cruise Control|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Optima 2019-20|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Optima Hybrid 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Seltos 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Sorento 2018|Advanced Smart Cruise Control & LKAS|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Sorento 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Sorento 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Sorento Hybrid 2021-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Sorento Plug-in Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Sportage 2023-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Sportage Hybrid 2023|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Stinger 2018-20|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Stinger 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Telluride 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|CT Hybrid 2017-18|Lexus Safety System+|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|ES 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|ES 2019-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|ES Hybrid 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|ES Hybrid 2019-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|GS F 2016|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|IS 2017-19|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|IS 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|LC 2024-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|NX 2018-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|NX 2020-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|NX Hybrid 2018-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|NX Hybrid 2020-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|RC 2018-20|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|RC 2023|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|RX 2016|Lexus Safety System+|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|RX 2017-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|RX 2020-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|RX Hybrid 2016|Lexus Safety System+|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|RX Hybrid 2017-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|RX Hybrid 2020-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|UX Hybrid 2019-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lincoln|Aviator 2020-24|Co-Pilot360 Plus|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lincoln|Aviator Plug-in Hybrid 2020-24|Co-Pilot360 Plus|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|MAN|eTGE 2020-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|MAN|TGE 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Mazda|CX-5 2022-25|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Mazda connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Mazda|CX-9 2021-23|All|Stock|0 mph|28 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Mazda connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Nissan[6](#footnotes)|Altima 2019-20, 2024|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan B connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Nissan[6](#footnotes)|Leaf 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Nissan[6](#footnotes)|Rogue 2018-20|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Nissan[6](#footnotes)|X-Trail 2017|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ram|1500 2019-24|Adaptive Cruise Control (ACC)|Stock|32 mph|1 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ram|2500 2020-24|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Ram|3500 2019-22|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Rivian|R1S 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Rivian|R1T 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|SEAT|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|SEAT|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Subaru|Ascent 2019-21|All[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Forester 2017-18|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Forester 2019-21|All[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Impreza 2017-19|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Impreza 2020-22|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Legacy 2015-18|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Legacy 2020-22|All[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Outback 2015-17|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Outback 2018-19|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Outback 2020-22|All[7](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|XV 2018-19|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|XV 2020-21|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Škoda|Fabia 2022-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| +|Škoda|Kamiq 2021-23[12,14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| +|Škoda|Karoq 2019-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Škoda|Kodiaq 2017-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Škoda|Octavia 2015-19[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Škoda|Octavia RS 2016[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Škoda|Octavia Scout 2017-19[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Škoda|Scala 2020-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| +|Škoda|Superb 2015-22[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Tesla[10](#footnotes)|Model 3 (with HW3) 2019-23[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 USB-C coupler
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Tesla[10](#footnotes)|Model 3 (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 USB-C coupler
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Tesla[10](#footnotes)|Model Y (with HW3) 2020-23[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 USB-C coupler
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Tesla[10](#footnotes)|Model Y (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 USB-C coupler
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Toyota|Alphard 2019-20|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Alphard Hybrid 2021|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Avalon 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Avalon 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Avalon 2019-21|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Avalon 2022|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Avalon Hybrid 2019-21|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Avalon Hybrid 2022|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|C-HR 2017-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|C-HR 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|C-HR Hybrid 2017-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|C-HR Hybrid 2021-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Camry 2018-20|All|Stock|0 mph[11](#footnotes)|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Camry 2021-24|All|openpilot|0 mph[11](#footnotes)|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Camry Hybrid 2018-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Camry Hybrid 2021-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Corolla 2017-19|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Corolla 2020-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Corolla Cross (Non-US only) 2020-23|All|openpilot|17 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Corolla Cross Hybrid (Non-US only) 2020-22|All|openpilot|17 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Corolla Hatchback 2019-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Corolla Hybrid 2020-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Corolla Hybrid (South America only) 2020-23|All|openpilot|17 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Highlander 2017-19|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Highlander 2020-23|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Highlander Hybrid 2017-19|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Highlander Hybrid 2020-23|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Mirai 2021|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Prius 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Prius 2017-20|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Prius 2021-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Prius Prime 2017-20|All|openpilot available[2](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Prius Prime 2021-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Prius v 2017|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 2019-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 Hybrid 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 Hybrid 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 Hybrid 2019-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 Hybrid 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 Hybrid 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Sienna 2018-20|All|openpilot available[2](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Volkswagen|Arteon 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Arteon eHybrid 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Arteon R 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Arteon Shooting Brake 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Atlas 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Atlas Cross Sport 2020-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|California 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Caravelle 2020|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|CC 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Crafter 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|e-Crafter 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|e-Golf 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Golf 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Golf Alltrack 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Golf GTD 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Golf GTE 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Golf GTI 2015-21|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Jetta 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Passat 2015-22[13](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Passat Alltrack 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Passat GTE 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Polo 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| +|Volkswagen|Polo GTI 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| +|Volkswagen|T-Cross 2021|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| +|Volkswagen|T-Roc 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Taos 2022-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Teramont 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Teramont Cross Sport 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Teramont X 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Tiguan 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Tiguan eHybrid 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Touran 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| ### Footnotes 1openpilot Longitudinal Control (Alpha) is available behind a toggle; the toggle is only available in non-release branches such as `devel` or `nightly-dev`.
@@ -354,18 +356,17 @@ A supported vehicle is one that just works when you install a comma device. All 3Refers only to the Focus Mk4 (C519) available in Europe/China/Taiwan/Australasia, not the Focus Mk3 (C346) in North and South America/Southeast Asia.
4See more setup details for GM.
52019 Honda Civic 1.6L Diesel Sedan does not have ALC below 12mph.
-6Requires a CAN FD panda kit if not using comma 3X for this CAN FD car.
-7See more setup details for Nissan.
-8In the non-US market, openpilot requires the car to come equipped with EyeSight with Lane Keep Assistance.
-9Enabling longitudinal control (alpha) will disable all EyeSight functionality, including AEB, LDW, and RAB.
-10Some 2023 model years have HW4. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
-11See more setup details for Tesla.
-12openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control.
-13Not including the China market Kamiq, which is based on the (currently) unsupported PQ34 platform.
-14Refers only to the MQB-based European B8 Passat, not the NMS Passat in the USA/China/Mideast markets.
-15Some Škoda vehicles are equipped with heated windshields, which are known to block GPS signal needed for some comma 3X functionality.
-16Only available for vehicles using a gateway (J533) harness. At this time, vehicles using a camera harness are limited to using stock ACC.
-17Model-years 2022 and beyond may have a combined CAN gateway and BCM, which is supported by openpilot in software, but doesn't yet have a harness available from the comma store.
+6See more setup details for Nissan.
+7In the non-US market, openpilot requires the car to come equipped with EyeSight with Lane Keep Assistance.
+8Enabling longitudinal control (alpha) will disable all EyeSight functionality, including AEB, LDW, and RAB.
+9Some 2023 model years have HW4. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
+10See more setup details for Tesla.
+11openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control.
+12Not including the China market Kamiq, which is based on the (currently) unsupported PQ34 platform.
+13Refers only to the MQB-based European B8 Passat, not the NMS Passat in the USA/China/Mideast markets.
+14Some Škoda vehicles are equipped with heated windshields, which are known to block GPS signal needed for some comma four functionality.
+15Only available for vehicles using a gateway (J533) harness. At this time, vehicles using a camera harness are limited to using stock ACC.
+16Model-years 2022 and beyond may have a combined CAN gateway and BCM, which is supported by openpilot in software, but doesn't yet have a harness available from the comma store.
## Community Maintained Cars Although they're not upstream, the community has openpilot running on other makes and models. See the 'Community Supported Models' section of each make [on our wiki](https://wiki.comma.ai/). diff --git a/opendbc_repo b/opendbc_repo index 62f6f9e4d4..61bf5a90c5 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 62f6f9e4d4ff4c424586c114c6ae3dd33629e4af +Subproject commit 61bf5a90c5c1917b657b8dd50c4d95e437413170 From 8184cd8a6ab72a4c6a57971f330ab739c21020a9 Mon Sep 17 00:00:00 2001 From: Nayan Date: Fri, 21 Nov 2025 14:59:54 -0500 Subject: [PATCH 48/48] ui: optimize `gui_app` & spinner (#1474) remove gui_app_sp & use upstream gui_app Co-authored-by: Jason Wen --- system/ui/spinner.py | 4 +--- system/ui/sunnypilot/lib/application.py | 24 ------------------------ 2 files changed, 1 insertion(+), 27 deletions(-) delete mode 100644 system/ui/sunnypilot/lib/application.py diff --git a/system/ui/spinner.py b/system/ui/spinner.py index dc49230818..33f4543c3e 100755 --- a/system/ui/spinner.py +++ b/system/ui/spinner.py @@ -8,8 +8,6 @@ from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.text import wrap_text from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.sunnypilot.lib.application import gui_app_sp - # Constants if gui_app.big_ui(): PROGRESS_BAR_WIDTH = 1000 @@ -37,7 +35,7 @@ def clamp(value, min_value, max_value): class Spinner(Widget): def __init__(self): super().__init__() - self._comma_texture = gui_app_sp.sp_texture("images/spinner_sunnypilot.png", TEXTURE_SIZE, TEXTURE_SIZE) + self._comma_texture = gui_app.texture("../../sunnypilot/selfdrive/assets/images/spinner_sunnypilot.png", TEXTURE_SIZE, TEXTURE_SIZE) self._spinner_texture = gui_app.texture("images/spinner_track.png", TEXTURE_SIZE, TEXTURE_SIZE, alpha_premultiply=True) self._rotation = 0.0 self._progress: int | None = None diff --git a/system/ui/sunnypilot/lib/application.py b/system/ui/sunnypilot/lib/application.py deleted file mode 100644 index 7440d224ca..0000000000 --- a/system/ui/sunnypilot/lib/application.py +++ /dev/null @@ -1,24 +0,0 @@ -from openpilot.system.ui.lib.application import GuiApplication -from importlib.resources import as_file, files - -ASSETS_DIR_SP = files("openpilot.sunnypilot.selfdrive").joinpath("assets") - - -class GuiApplicationSP(GuiApplication): - - def __init__(self, width: int, height: int): - super().__init__(width, height) - - def sp_texture(self, asset_path: str, width: int, height: int, 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_SP.joinpath(asset_path)) as fspath: - 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 - - -gui_app_sp = GuiApplicationSP(2160, 1080)