From f83b749ec2ecd93bb4f1ceb7daafcfeefe4d3091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Sun, 12 Apr 2026 15:30:29 -0400 Subject: [PATCH 01/14] github_utils: use exact-match ref lookup in get_bucket_sha (#37813) The plural `git/refs/heads/{bucket}` endpoint does prefix matching and returns a list when multiple refs share the prefix, which makes `r.json()['object']` raise TypeError. Switch to the singular `git/ref/heads/{bucket}` endpoint so we only match the exact bucket and get a clean 404 otherwise. --- tools/lib/github_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/lib/github_utils.py b/tools/lib/github_utils.py index 46a0dcf3c..6a443b415 100644 --- a/tools/lib/github_utils.py +++ b/tools/lib/github_utils.py @@ -62,7 +62,7 @@ class GithubUtils: self.api_call(github_path, data=data, method=HTTPMethod.POST, data_call=True) def get_bucket_sha(self, bucket): - github_path = f"git/refs/heads/{bucket}" + github_path = f"git/ref/heads/{bucket}" r = self.api_call(github_path, data_call=True, raise_on_failure=False) return r.json()['object']['sha'] if r.ok else None From 0584a5f5ebd2b800aa79980ab74d1a9bbd75a6b2 Mon Sep 17 00:00:00 2001 From: John Belmonte Date: Sun, 12 Apr 2026 14:29:22 -0700 Subject: [PATCH 02/14] add bridge target to cabana run script (#37814) The cabana run script builds for convenience, but omitted the cereal/messaging/bridge dependency needed for streaming. --- tools/cabana/cabana | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/cabana/cabana b/tools/cabana/cabana index 128e49400..cd9bf1dd7 100755 --- a/tools/cabana/cabana +++ b/tools/cabana/cabana @@ -33,6 +33,6 @@ fi # Build _cabana cd "$ROOT" -scons -j4 tools/cabana/_cabana +scons -j4 tools/cabana/_cabana cereal/messaging/bridge exec "$DIR/_cabana" "$@" From c91a0a83f61d6502153adf0628ab001cd77ffcab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Sun, 12 Apr 2026 23:47:43 -0400 Subject: [PATCH 03/14] Revert OP (#37812) * Revert "OP model 7 (#37760)" This reverts commit 052692b25d63c5ddda276b5c2271383b6aff129f. * Revert "OP model (#37740)" This reverts commit cb327933002bc1a00bf60a3c20af2eb7a5f653e5. * dead * parse_model_outputs: drop extra space --- scripts/reporter.py | 9 ++---- selfdrive/modeld/SConscript | 5 ++-- selfdrive/modeld/modeld.py | 30 ++++--------------- .../modeld/models/big_driving_policy.onnx | 1 + .../modeld/models/big_driving_vision.onnx | 1 + .../modeld/models/driving_off_policy.onnx | 3 -- .../modeld/models/driving_on_policy.onnx | 3 -- selfdrive/modeld/models/driving_policy.onnx | 3 ++ selfdrive/modeld/models/driving_vision.onnx | 4 +-- selfdrive/modeld/parse_model_outputs.py | 13 ++------ 10 files changed, 21 insertions(+), 51 deletions(-) create mode 120000 selfdrive/modeld/models/big_driving_policy.onnx create mode 120000 selfdrive/modeld/models/big_driving_vision.onnx delete mode 100644 selfdrive/modeld/models/driving_off_policy.onnx delete mode 100644 selfdrive/modeld/models/driving_on_policy.onnx create mode 100644 selfdrive/modeld/models/driving_policy.onnx diff --git a/scripts/reporter.py b/scripts/reporter.py index 64f6cb99b..d894b8af4 100755 --- a/scripts/reporter.py +++ b/scripts/reporter.py @@ -38,11 +38,6 @@ if __name__ == "__main__": continue fn = os.path.basename(f) - master_path = MASTER_PATH + MODEL_PATH + fn - if os.path.exists(master_path): - master = get_checkpoint(master_path) - master_col = f"[{master}](https://reporter.comma.life/experiment/{master})" - else: - master_col = "N/A (new model)" + master = get_checkpoint(MASTER_PATH + MODEL_PATH + fn) pr = get_checkpoint(BASEDIR + MODEL_PATH + fn) - print("|", fn, "|", master_col, "|", f"[{pr}](https://reporter.comma.life/experiment/{pr})", "|") + print("|", fn, "|", f"[{master}](https://reporter.comma.life/experiment/{master})", "|", f"[{pr}](https://reporter.comma.life/experiment/{pr})", "|") diff --git a/selfdrive/modeld/SConscript b/selfdrive/modeld/SConscript index 05045d098..86b62d2c1 100644 --- a/selfdrive/modeld/SConscript +++ b/selfdrive/modeld/SConscript @@ -44,7 +44,7 @@ compiled_flags_node = lenv.Command( mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else '' # Get model metadata -for model_name in ['driving_vision', 'driving_off_policy', 'driving_on_policy', 'dmonitoring_model']: +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'{tg_flags} {mac_brew_string} python3 {Dir("#selfdrive/modeld").abspath}/get_model_metadata.py {fn}.onnx' @@ -82,5 +82,6 @@ def tg_compile(flags, model_name): ) # Compile small models -for model_name in ['driving_vision', 'driving_off_policy', 'driving_on_policy', 'dmonitoring_model']: +for model_name in ['driving_vision', 'driving_policy', 'dmonitoring_model']: tg_compile(tg_flags, model_name) + diff --git a/selfdrive/modeld/modeld.py b/selfdrive/modeld/modeld.py index c61e417e1..0c50d8beb 100755 --- a/selfdrive/modeld/modeld.py +++ b/selfdrive/modeld/modeld.py @@ -36,10 +36,8 @@ SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') VISION_PKL_PATH = MODELS_DIR / 'driving_vision_tinygrad.pkl' VISION_METADATA_PATH = MODELS_DIR / 'driving_vision_metadata.pkl' -ON_POLICY_PKL_PATH = MODELS_DIR / 'driving_on_policy_tinygrad.pkl' -ON_POLICY_METADATA_PATH = MODELS_DIR / 'driving_on_policy_metadata.pkl' -OFF_POLICY_PKL_PATH = MODELS_DIR / 'driving_off_policy_tinygrad.pkl' -OFF_POLICY_METADATA_PATH = MODELS_DIR / 'driving_off_policy_metadata.pkl' +POLICY_PKL_PATH = MODELS_DIR / 'driving_policy_tinygrad.pkl' +POLICY_METADATA_PATH = MODELS_DIR / 'driving_policy_metadata.pkl' LAT_SMOOTH_SECONDS = 0.0 LONG_SMOOTH_SECONDS = 0.3 @@ -152,13 +150,7 @@ class ModelState: self.vision_output_slices = vision_metadata['output_slices'] vision_output_size = vision_metadata['output_shapes']['outputs'][1] - with open(OFF_POLICY_METADATA_PATH, 'rb') as f: - off_policy_metadata = pickle.load(f) - self.off_policy_input_shapes = off_policy_metadata['input_shapes'] - self.off_policy_output_slices = off_policy_metadata['output_slices'] - off_policy_output_size = off_policy_metadata['output_shapes']['outputs'][1] - - with open(ON_POLICY_METADATA_PATH, 'rb') as f: + with open(POLICY_METADATA_PATH, 'rb') as f: policy_metadata = pickle.load(f) self.policy_input_shapes = policy_metadata['input_shapes'] self.policy_output_slices = policy_metadata['output_slices'] @@ -182,13 +174,11 @@ class ModelState: self.vision_output = np.zeros(vision_output_size, dtype=np.float32) self.policy_inputs = {k: Tensor(v, device='NPY').realize() for k,v in self.numpy_inputs.items()} self.policy_output = np.zeros(policy_output_size, dtype=np.float32) - self.off_policy_output = np.zeros(off_policy_output_size, dtype=np.float32) self.parser = Parser() self.frame_buf_params : dict[str, tuple[int, int, int, int]] = {} self.update_imgs = None self.vision_run = pickle.loads(read_file_chunked(str(VISION_PKL_PATH))) - self.policy_run = pickle.loads(read_file_chunked(str(ON_POLICY_PKL_PATH))) - self.off_policy_run = pickle.loads(read_file_chunked(str(OFF_POLICY_PKL_PATH))) + self.policy_run = pickle.loads(read_file_chunked(str(POLICY_PKL_PATH))) def slice_outputs(self, model_outputs: np.ndarray, output_slices: dict[str, slice]) -> dict[str, np.ndarray]: parsed_model_outputs = {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} @@ -236,17 +226,9 @@ class ModelState: self.policy_output = self.policy_run(**self.policy_inputs).contiguous().realize().uop.base.buffer.numpy().flatten() policy_outputs_dict = self.parser.parse_policy_outputs(self.slice_outputs(self.policy_output, self.policy_output_slices)) - - self.off_policy_output = self.off_policy_run(**self.policy_inputs).contiguous().realize().uop.base.buffer.numpy() - off_policy_outputs_dict = self.parser.parse_off_policy_outputs(self.slice_outputs(self.off_policy_output, self.off_policy_output_slices)) - off_policy_outputs_dict.pop('plan') - - - combined_outputs_dict = {**vision_outputs_dict, **off_policy_outputs_dict, **policy_outputs_dict} - if 'planplus' in combined_outputs_dict and 'plan' in combined_outputs_dict: - combined_outputs_dict['plan'] = combined_outputs_dict['plan'] + combined_outputs_dict['planplus'] + combined_outputs_dict = {**vision_outputs_dict, **policy_outputs_dict} if SEND_RAW_PRED: - combined_outputs_dict['raw_pred'] = np.concatenate([self.vision_output.copy(), self.policy_output.copy(), self.off_policy_output.copy()]) + combined_outputs_dict['raw_pred'] = np.concatenate([self.vision_output.copy(), self.policy_output.copy()]) return combined_outputs_dict diff --git a/selfdrive/modeld/models/big_driving_policy.onnx b/selfdrive/modeld/models/big_driving_policy.onnx new file mode 120000 index 000000000..e1b653a14 --- /dev/null +++ b/selfdrive/modeld/models/big_driving_policy.onnx @@ -0,0 +1 @@ +driving_policy.onnx \ No newline at end of file diff --git a/selfdrive/modeld/models/big_driving_vision.onnx b/selfdrive/modeld/models/big_driving_vision.onnx new file mode 120000 index 000000000..28ee71dd7 --- /dev/null +++ b/selfdrive/modeld/models/big_driving_vision.onnx @@ -0,0 +1 @@ +driving_vision.onnx \ No newline at end of file diff --git a/selfdrive/modeld/models/driving_off_policy.onnx b/selfdrive/modeld/models/driving_off_policy.onnx deleted file mode 100644 index 5b0effc10..000000000 --- a/selfdrive/modeld/models/driving_off_policy.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e53f4e0527766082ba7bde38e275def0fe3c14f6c59ae2854439e239884d3ecc -size 13393365 diff --git a/selfdrive/modeld/models/driving_on_policy.onnx b/selfdrive/modeld/models/driving_on_policy.onnx deleted file mode 100644 index bfe10ca18..000000000 --- a/selfdrive/modeld/models/driving_on_policy.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ea89c50da3a16e710da292f97c81b083a982cfdee5c28eca0d37ed2fb99af6c5 -size 13022642 diff --git a/selfdrive/modeld/models/driving_policy.onnx b/selfdrive/modeld/models/driving_policy.onnx new file mode 100644 index 000000000..7c71bc947 --- /dev/null +++ b/selfdrive/modeld/models/driving_policy.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:853c6634746ff439a848349d00e4d5581cd941f13f7c1862c31b72a31cc24858 +size 14061595 diff --git a/selfdrive/modeld/models/driving_vision.onnx b/selfdrive/modeld/models/driving_vision.onnx index 335e9dbcf..afd617667 100644 --- a/selfdrive/modeld/models/driving_vision.onnx +++ b/selfdrive/modeld/models/driving_vision.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6263aa3fbb44cde6c68a34cdb7cd8c389789dbc02b15c1911afdac4e018281ae -size 23267151 +oid sha256:940e9006a25f27f0b6e85da798e6a8fd1f6dd492dd7d0b9ff1a9436460f46129 +size 46887794 diff --git a/selfdrive/modeld/parse_model_outputs.py b/selfdrive/modeld/parse_model_outputs.py index 802f0ad85..a0b45d2a9 100644 --- a/selfdrive/modeld/parse_model_outputs.py +++ b/selfdrive/modeld/parse_model_outputs.py @@ -96,17 +96,11 @@ class Parser: self.parse_mdn('pose', outs, in_N=0, out_N=0, out_shape=(ModelConstants.POSE_WIDTH,)) self.parse_mdn('wide_from_device_euler', outs, in_N=0, out_N=0, out_shape=(ModelConstants.WIDE_FROM_DEVICE_WIDTH,)) self.parse_mdn('road_transform', outs, in_N=0, out_N=0, out_shape=(ModelConstants.POSE_WIDTH,)) - self.parse_categorical_crossentropy('desire_pred', outs, out_shape=(ModelConstants.DESIRE_PRED_LEN,ModelConstants.DESIRE_PRED_WIDTH)) - self.parse_binary_crossentropy('meta', outs) - return outs - - def parse_off_policy_outputs(self, outs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: - plan_mhp = self.is_mhp(outs, 'plan', ModelConstants.IDX_N * ModelConstants.PLAN_WIDTH) - plan_in_N, plan_out_N = (ModelConstants.PLAN_MHP_N, ModelConstants.PLAN_MHP_SELECTION) if plan_mhp else (0, 0) - self.parse_mdn('plan', outs, in_N=plan_in_N, out_N=plan_out_N, out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH)) self.parse_mdn('lane_lines', outs, in_N=0, out_N=0, out_shape=(ModelConstants.NUM_LANE_LINES,ModelConstants.IDX_N,ModelConstants.LANE_LINES_WIDTH)) self.parse_mdn('road_edges', outs, in_N=0, out_N=0, out_shape=(ModelConstants.NUM_ROAD_EDGES,ModelConstants.IDX_N,ModelConstants.LANE_LINES_WIDTH)) self.parse_binary_crossentropy('lane_lines_prob', outs) + self.parse_categorical_crossentropy('desire_pred', outs, out_shape=(ModelConstants.DESIRE_PRED_LEN,ModelConstants.DESIRE_PRED_WIDTH)) + self.parse_binary_crossentropy('meta', outs) self.parse_binary_crossentropy('lead_prob', outs) lead_mhp = self.is_mhp(outs, 'lead', ModelConstants.LEAD_MHP_SELECTION * ModelConstants.LEAD_TRAJ_LEN * ModelConstants.LEAD_WIDTH) lead_in_N, lead_out_N = (ModelConstants.LEAD_MHP_N, ModelConstants.LEAD_MHP_SELECTION) if lead_mhp else (0, 0) @@ -116,7 +110,7 @@ class Parser: return outs def parse_policy_outputs(self, outs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: - plan_mhp = self.is_mhp(outs, 'plan', ModelConstants.IDX_N * ModelConstants.PLAN_WIDTH) + plan_mhp = self.is_mhp(outs, 'plan', ModelConstants.IDX_N * ModelConstants.PLAN_WIDTH) plan_in_N, plan_out_N = (ModelConstants.PLAN_MHP_N, ModelConstants.PLAN_MHP_SELECTION) if plan_mhp else (0, 0) self.parse_mdn('plan', outs, in_N=plan_in_N, out_N=plan_out_N, out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH)) if 'planplus' in outs: @@ -126,6 +120,5 @@ class Parser: def parse_outputs(self, outs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: outs = self.parse_vision_outputs(outs) - outs = self.parse_off_policy_outputs(outs) outs = self.parse_policy_outputs(outs) return outs From fcb0a496edf22851309dca7ae523272a710717f3 Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Mon, 13 Apr 2026 12:38:02 -0700 Subject: [PATCH 04/14] model reporter links (#37817) --- scripts/reporter.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/scripts/reporter.py b/scripts/reporter.py index d894b8af4..93b71761a 100755 --- a/scripts/reporter.py +++ b/scripts/reporter.py @@ -33,11 +33,7 @@ if __name__ == "__main__": print("|-| ----- | --------- |") for f in glob.glob(BASEDIR + MODEL_PATH + "/*.onnx"): - # TODO: add checkpoint to DM - if "dmonitoring" in f: - continue - fn = os.path.basename(f) master = get_checkpoint(MASTER_PATH + MODEL_PATH + fn) pr = get_checkpoint(BASEDIR + MODEL_PATH + fn) - print("|", fn, "|", f"[{master}](https://reporter.comma.life/experiment/{master})", "|", f"[{pr}](https://reporter.comma.life/experiment/{pr})", "|") + print("|", fn, "|", f"[{master}](https://reporterv2.comma.life/{master})", "|", f"[{pr}](https://reporterv2.comma.life/{pr})", "|") From bf2294dee24c8be4be5d058fe695635784a0489f Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Mon, 13 Apr 2026 13:51:50 -0700 Subject: [PATCH 05/14] Set fan to 100% when onroad is thermally blocked (#37804) set fan to 100% when onroad is thermally blocked --- system/hardware/hardwared.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/system/hardware/hardwared.py b/system/hardware/hardwared.py index aad30f77b..4324e90c7 100755 --- a/system/hardware/hardwared.py +++ b/system/hardware/hardwared.py @@ -323,6 +323,9 @@ def hardware_thread(end_event, hw_queue) -> None: show_alert = (not onroad_conditions["device_temp_good"] or not startup_conditions["device_temp_engageable"]) and onroad_conditions["ignition"] set_offroad_alert_if_changed("Offroad_TemperatureTooHigh", show_alert, extra_text=extra_text) + if show_alert: + msg.deviceState.fanSpeedPercentDesired = 100 + # *** registration check *** if not PC: # we enforce this for our software, but you are welcome @@ -421,9 +424,10 @@ def hardware_thread(end_event, hw_queue) -> None: statlog.gauge("fan_speed_percent_desired", msg.deviceState.fanSpeedPercentDesired) statlog.gauge("screen_brightness_percent", msg.deviceState.screenBrightnessPercent) - # report to server once every 10 minutes + # report to server once every 10 minutes, or every 1s when thermally blocked rising_edge_started = should_start and not should_start_prev - if rising_edge_started or (count % int(600. / DT_HW)) == 0: + status_packet_interval = 1. if show_alert else 600. + if rising_edge_started or (count % int(status_packet_interval / DT_HW)) == 0: dat = { 'count': count, 'pandaStates': [strip_deprecated_keys(p.to_dict()) for p in pandaStates], From 2406b32d55e3ddbfa0ba1a5196d1d2ab9e101fd9 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 14 Apr 2026 12:50:43 -0400 Subject: [PATCH 06/14] Default model: POP model --- common/model.h | 2 +- sunnypilot/modeld_v2/SConscript | 4 ++-- sunnypilot/models/default_model.py | 8 +++----- sunnypilot/models/tests/model_hash | 2 +- sunnypilot/models/tests/test_default_model.py | 7 +++---- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/common/model.h b/common/model.h index fc1110431..d134ebd15 100644 --- a/common/model.h +++ b/common/model.h @@ -1 +1 @@ -#define DEFAULT_MODEL "OP Model 7 (Default)" +#define DEFAULT_MODEL "POP model (Default)" diff --git a/sunnypilot/modeld_v2/SConscript b/sunnypilot/modeld_v2/SConscript index ddf889c0c..8526fd272 100644 --- a/sunnypilot/modeld_v2/SConscript +++ b/sunnypilot/modeld_v2/SConscript @@ -13,7 +13,7 @@ if PC: model_dir = Dir("models").abspath cmd = f'python3 {Dir("#sunnypilot/modeld_v2").abspath}/install_models_pc.py {model_dir}' - for model_name in ['supercombo', 'driving_vision', 'driving_off_policy', 'driving_policy']: + for model_name in ['supercombo', 'driving_vision', 'driving_off_policy', 'driving_on_policy', 'driving_policy']: if File(f"models/{model_name}.onnx").exists(): inputs.append(File(f"models/{model_name}.onnx")) inputs.append(File(f"models/{model_name}_tinygrad.pkl")) @@ -42,7 +42,7 @@ def tg_compile(flags, model_name): ) # Compile models -for model_name in ['supercombo', 'driving_vision', 'driving_off_policy', 'driving_policy']: +for model_name in ['supercombo', 'driving_vision', 'driving_off_policy', 'driving_on_policy', 'driving_policy']: if File(f"models/{model_name}.onnx").exists(): tg_compile(tg_flags, model_name) diff --git a/sunnypilot/models/default_model.py b/sunnypilot/models/default_model.py index d540efbff..0260a3c3b 100755 --- a/sunnypilot/models/default_model.py +++ b/sunnypilot/models/default_model.py @@ -8,16 +8,14 @@ from openpilot.sunnypilot import get_file_hash DEFAULT_MODEL_NAME_PATH = os.path.join(BASEDIR, "common", "model.h") MODEL_HASH_PATH = os.path.join(BASEDIR, "sunnypilot", "models", "tests", "model_hash") VISION_ONNX_PATH = os.path.join(BASEDIR, "selfdrive", "modeld", "models", "driving_vision.onnx") -OFF_POLICY_ONNX_PATH = os.path.join(BASEDIR, "selfdrive", "modeld", "models", "driving_off_policy.onnx") -ON_POLICY_ONNX_PATH = os.path.join(BASEDIR, "selfdrive", "modeld", "models", "driving_on_policy.onnx") +POLICY_ONNX_PATH = os.path.join(BASEDIR, "selfdrive", "modeld", "models", "driving_policy.onnx") def update_model_hash(): vision_hash = get_file_hash(VISION_ONNX_PATH) - off_policy_hash = get_file_hash(OFF_POLICY_ONNX_PATH) - on_policy_hash = get_file_hash(ON_POLICY_ONNX_PATH) + policy_hash = get_file_hash(POLICY_ONNX_PATH) - combined_hash = hashlib.sha256((vision_hash + off_policy_hash + on_policy_hash).encode()).hexdigest() + combined_hash = hashlib.sha256((vision_hash + policy_hash).encode()).hexdigest() with open(MODEL_HASH_PATH, "w") as f: f.write(combined_hash) diff --git a/sunnypilot/models/tests/model_hash b/sunnypilot/models/tests/model_hash index 3fdf97dd1..eaf923358 100644 --- a/sunnypilot/models/tests/model_hash +++ b/sunnypilot/models/tests/model_hash @@ -1 +1 @@ -793b5d480edb5a30eed3d0d3bdb43259522978670f6bc3dea7a4d661261d3c48 +5d4d21f1899de21137f69d74a4602c44cc5a6b04cf4e4aa9d0ec9206f8c30350 \ No newline at end of file diff --git a/sunnypilot/models/tests/test_default_model.py b/sunnypilot/models/tests/test_default_model.py index abe685c36..7c2fde70a 100644 --- a/sunnypilot/models/tests/test_default_model.py +++ b/sunnypilot/models/tests/test_default_model.py @@ -6,17 +6,16 @@ See the LICENSE.md file in the root directory for more details. """ from openpilot.sunnypilot import get_file_hash -from openpilot.sunnypilot.models.default_model import MODEL_HASH_PATH, VISION_ONNX_PATH, OFF_POLICY_ONNX_PATH, ON_POLICY_ONNX_PATH +from openpilot.sunnypilot.models.default_model import MODEL_HASH_PATH, VISION_ONNX_PATH, POLICY_ONNX_PATH import hashlib class TestDefaultModel: def test_compare_onnx_hashes(self): vision_hash = get_file_hash(VISION_ONNX_PATH) - off_policy_hash = get_file_hash(OFF_POLICY_ONNX_PATH) - on_policy_hash = get_file_hash(ON_POLICY_ONNX_PATH) + policy_hash = get_file_hash(POLICY_ONNX_PATH) - combined_hash = hashlib.sha256((vision_hash + off_policy_hash + on_policy_hash).encode()).hexdigest() + combined_hash = hashlib.sha256((vision_hash + policy_hash).encode()).hexdigest() with open(MODEL_HASH_PATH) as f: current_hash = f.read().strip() From 30a858c23d3376caba8e9b0b0e20aecb0f61778b Mon Sep 17 00:00:00 2001 From: mmmorks Date: Tue, 14 Apr 2026 10:33:51 -0700 Subject: [PATCH 07/14] NNLC: restore pre-v1 PID gains in torque extension (#1779) * NNLC: restore pre-v1 PID gains in torque extension When the torque lateral controller was refactored for v1 (VERSION=1), the NNLC extension's PID gains were inadvertently changed from the per-vehicle defaults (kp=1.0, ki=0.3, kf=1.0) to the new base controller values (kp=0.8, ki=0.15, no kf with speed interpolation). The NNLC extension operates in torque space with its own PID loop that is independent of the base controller's lateral acceleration PID. Coupling these gains to the base controller's values results in noticeably weaker steering response and ping-pong oscillation for NNLC users, with no workaround since Enforce Torque Lateral Control and NNLC are mutually exclusive. This restores the original PID gains that were used before the v1 refactor, matching the behavior from v2025.003.000 and earlier. Co-Authored-By: Claude Opus 4.6 (1M context) * Remove k_f from PIDController init k_f was removed from PIDController in the v1 refactor. The old k_f=1.0 was a no-op (feedforward scale of 1.0), and the current PIDController applies feedforward unscaled via update(), so behavior is unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Jason Wen --- .../controls/lib/latcontrol_torque_ext_base.py | 10 ++++------ sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py | 10 +++++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py index c6658bdc7..df773889a 100644 --- a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py +++ b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py @@ -14,11 +14,8 @@ from openpilot.selfdrive.modeld.constants import ModelConstants LAT_PLAN_MIN_IDX = 5 LATERAL_LAG_MOD = 0.0 # seconds, modifies how far in the future we look ahead for the lateral plan -# from selfdrive/controls/lib/latcontrol_torque.py -KP = 0.8 -KI = 0.15 -INTERP_SPEEDS = [1, 1.5, 2.0, 3.0, 5, 7.5, 10, 15, 30] -KP_INTERP = [250, 120, 65, 30, 11.5, 5.5, 3.5, 2.0, KP] +KP = 1.0 +KI = 0.3 def get_predicted_lateral_jerk(lat_accels, t_diffs): @@ -61,9 +58,10 @@ class LatControlTorqueExtBase: self.lookahead_lateral_jerk: float = 0.0 self.torque_from_lateral_accel_in_torque_space = CI.torque_from_lateral_accel_in_torque_space() + self.torque_params = lac_torque.torque_params self._ff = 0.0 - self._pid = PIDController([INTERP_SPEEDS, KP_INTERP], KI) + self._pid = PIDController(KP, KI) self._pid_log = None self._setpoint = 0.0 self._measurement = 0.0 diff --git a/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py b/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py index 2db88299c..1738a11e4 100644 --- a/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py +++ b/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py @@ -75,14 +75,14 @@ class NeuralNetworkLateralControl(LatControlTorqueExtBase): def update_feedforward_torque_space(self, CS): torque_from_setpoint = self.torque_from_lateral_accel_in_torque_space(LatControlInputs(self._setpoint, self._roll_compensation, CS.vEgo, CS.aEgo), - self.lac_torque.torque_params, gravity_adjusted=False) + self.torque_params, gravity_adjusted=False) torque_from_measurement = self.torque_from_lateral_accel_in_torque_space(LatControlInputs(self._measurement, self._roll_compensation, CS.vEgo, CS.aEgo), - self.lac_torque.torque_params, gravity_adjusted=False) + self.torque_params, gravity_adjusted=False) self._pid_log.error = float(torque_from_setpoint - torque_from_measurement) self._ff = self.torque_from_lateral_accel_in_torque_space(LatControlInputs(self._gravity_adjusted_lateral_accel, self._roll_compensation, - CS.vEgo, CS.aEgo), self.lac_torque.torque_params, gravity_adjusted=True) + CS.vEgo, CS.aEgo), self.torque_params, gravity_adjusted=True) self._ff += get_friction_in_torque_space(self._desired_lateral_accel - self._actual_lateral_accel, self._lateral_accel_deadzone, - FRICTION_THRESHOLD, self.lac_torque.torque_params) + FRICTION_THRESHOLD, self.torque_params) def update_output_torque(self, CS): freeze_integrator = self._steer_limited_by_safety or CS.steeringPressed or CS.vEgo < 5 @@ -159,6 +159,6 @@ class NeuralNetworkLateralControl(LatControlTorqueExtBase): # apply friction override for cars with low NN friction response if self.model.friction_override: - self._pid_log.error += get_friction(friction_input, self._lateral_accel_deadzone, FRICTION_THRESHOLD, self.lac_torque.torque_params) + self._pid_log.error += get_friction(friction_input, self._lateral_accel_deadzone, FRICTION_THRESHOLD, self.torque_params) self.update_output_torque(CS) From c23f2dce2cc7193bc9e2ee05af7ed758f6789874 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 14 Apr 2026 16:28:13 -0400 Subject: [PATCH 08/14] MADS safety: enable heartbeat and lateral controls mismatch checks (#1801) * init * nah * rename * bump * bump --- cereal/log.capnp | 4 ++-- opendbc_repo | 2 +- panda | 2 +- selfdrive/pandad/pandad.cc | 15 ++++----------- sunnypilot/mads/mads.py | 17 +++++++++++++++++ tools/cabana/panda.cc | 4 ++-- tools/cabana/panda.h | 2 +- tools/sim/lib/simulated_car.py | 2 ++ 8 files changed, 30 insertions(+), 18 deletions(-) diff --git a/cereal/log.capnp b/cereal/log.capnp index 7138a6178..cf91e017e 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -558,8 +558,8 @@ struct PandaState @0xa7649e2575e4591e { # these fields are not used by openpilot, but they're # reserved for forks building alternate experiences. - controlsAllowedRESERVED1 @38 :Bool; - controlsAllowedRESERVED2 @39 :Bool; + controlsAllowedLateral @38 :Bool; + controlsAllowedLongitudinal @39 :Bool; enum FaultStatus { none @0; diff --git a/opendbc_repo b/opendbc_repo index 427032a89..ded068839 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 427032a89a556b4d80b700749582f68807fe2443 +Subproject commit ded068839b7b84708f35b07ad207601f36652f4e diff --git a/panda b/panda index 6cd1972ec..c0cc96fba 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 6cd1972ecf31429abb55647cadeb8ab3e19a9141 +Subproject commit c0cc96fbad1d11445c34141d7626703fc13a9938 diff --git a/selfdrive/pandad/pandad.cc b/selfdrive/pandad/pandad.cc index 3d0a551d8..cafc3e122 100644 --- a/selfdrive/pandad/pandad.cc +++ b/selfdrive/pandad/pandad.cc @@ -21,8 +21,6 @@ #define CUTOFF_IL 400 #define SATURATE_IL 1000 -#define ALT_EXP_MADS_DISENGAGE_LATERAL_ON_BRAKE 2048 - ExitHandler do_exit; bool check_connected(Panda *panda) { @@ -34,15 +32,8 @@ bool check_connected(Panda *panda) { } bool process_mads_heartbeat(SubMaster *sm) { - const int &alt_exp = (*sm)["carParams"].getCarParams().getAlternativeExperience(); - const bool disengage_lateral_on_brake = (alt_exp & ALT_EXP_MADS_DISENGAGE_LATERAL_ON_BRAKE) != 0; - const auto &mads = (*sm)["selfdriveStateSP"].getSelfdriveStateSP().getMads(); - const bool heartbeat_type = disengage_lateral_on_brake ? mads.getActive() : mads.getEnabled(); - - const bool engaged = sm->allAliveAndValid({"selfdriveStateSP"}) && heartbeat_type; - - return engaged; + return sm->allAliveAndValid({"selfdriveStateSP"}) && mads.getEnabled(); } Panda *connect(std::string serial) { @@ -152,6 +143,8 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f); ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f); ps.setSoundOutputLevel(health.sound_output_level_pkt); + ps.setControlsAllowedLateral(health.controls_allowed_lateral_pkt); + ps.setControlsAllowedLongitudinal(health.controls_allowed_longitudinal_pkt); } void fill_panda_can_state(cereal::PandaState::PandaCanState::Builder &cs, const can_health_t &can_health) { @@ -380,7 +373,7 @@ void pandad_run(Panda *panda) { Params params; RateKeeper rk("pandad", 100); - SubMaster sm({"selfdriveState", "selfdriveStateSP", "carParams"}); + SubMaster sm({"selfdriveState", "selfdriveStateSP"}); PubMaster pm({"can", "pandaStates", "peripheralState"}); PandaSafety panda_safety(panda); bool engaged = false; diff --git a/sunnypilot/mads/mads.py b/sunnypilot/mads/mads.py index 7eab55e6e..0c87da4a2 100644 --- a/sunnypilot/mads/mads.py +++ b/sunnypilot/mads/mads.py @@ -33,6 +33,7 @@ class ModularAssistiveDrivingSystem: self.enabled = False self.active = False self.available = False + self.lateral_mismatch_counter = 0 self.allow_always = False self.no_main_cruise = False self.selfdrive = selfdrive @@ -104,6 +105,17 @@ class ModularAssistiveDrivingSystem: self.events.remove(old_event) self.events_sp.add(new_event) + def data_sample(self): + # When the safety and selfdrived do not agree on controls_allowed_lateral + # we want to disengage sunnypilot. However the status from the panda goes through + # another socket other than the CAN messages and one can arrive earlier than the other. + # Therefore we allow a mismatch for two samples, then we trigger the disengagement. + if not self.active or self.selfdrive.enabled: + self.lateral_mismatch_counter = 0 + elif any(not ps.controlsAllowedLateral for ps in self.selfdrive.sm['pandaStates'] + if ps.safetyModel not in IGNORED_SAFETY_MODES): + self.lateral_mismatch_counter += 1 + def update_events(self, CS: structs.CarState): if not self.selfdrive.enabled and self.enabled: if CS.standstill: @@ -186,6 +198,9 @@ class ModularAssistiveDrivingSystem: if self.state_machine.state == State.paused: self.events_sp.add(EventNameSP.silentLkasEnable) + if self.lateral_mismatch_counter >= 200: + self.events_sp.add(EventNameSP.controlsMismatchLateral) + self.events.remove(EventName.pcmDisable) self.events.remove(EventName.buttonCancel) self.events.remove(EventName.pedalPressed) @@ -195,6 +210,8 @@ class ModularAssistiveDrivingSystem: if not self.enabled_toggle: return + self.data_sample() + self.update_events(CS) if not self.CP.passive and self.selfdrive.initialized: diff --git a/tools/cabana/panda.cc b/tools/cabana/panda.cc index 0612d6774..cf5354a50 100644 --- a/tools/cabana/panda.cc +++ b/tools/cabana/panda.cc @@ -106,8 +106,8 @@ cereal::PandaState::PandaType Panda::get_hw_type() { -void Panda::send_heartbeat(bool engaged) { - control_write(0xf3, engaged, 0); +void Panda::send_heartbeat(bool engaged, bool engaged_mads) { + control_write(0xf3, engaged, engaged_mads); } void Panda::set_can_speed_kbps(uint16_t bus, uint16_t speed) { diff --git a/tools/cabana/panda.h b/tools/cabana/panda.h index d318c33f4..8b861a247 100644 --- a/tools/cabana/panda.h +++ b/tools/cabana/panda.h @@ -64,7 +64,7 @@ public: // Panda functionality cereal::PandaState::PandaType get_hw_type(); void set_safety_model(cereal::CarParams::SafetyModel safety_model, uint16_t safety_param=0U); - void send_heartbeat(bool engaged); + void send_heartbeat(bool engaged, bool engaged_mads = false); void set_can_speed_kbps(uint16_t bus, uint16_t speed); void set_data_speed_kbps(uint16_t bus, uint16_t speed); bool can_receive(std::vector& out_vec); diff --git a/tools/sim/lib/simulated_car.py b/tools/sim/lib/simulated_car.py index 68ff3050d..1567c20ef 100644 --- a/tools/sim/lib/simulated_car.py +++ b/tools/sim/lib/simulated_car.py @@ -92,6 +92,8 @@ class SimulatedCar: 'ignitionLine': simulator_state.ignition, 'pandaType': "blackPanda", 'controlsAllowed': True, + 'controlsAllowedLateral': True, + 'controlsAllowedLongitudinal': True, 'safetyModel': 'hondaBosch', 'alternativeExperience': self.sm["carParams"].alternativeExperience, 'safetyParam': HondaSafetyFlags.RADARLESS.value | HondaSafetyFlags.BOSCH_LONG.value, From c7efc009a4be54286c6f446ec70140009709f23a Mon Sep 17 00:00:00 2001 From: Nayan Date: Tue, 14 Apr 2026 16:38:00 -0400 Subject: [PATCH 09/14] [MICI] ui: models panel enhancements (#1705) * model panel - give it some love * fix sync issues * update for upstream sync * fix label * not red * fav models * uhh, yeah * handling for downloading state --------- Co-authored-by: Jason Wen --- .../ui/sunnypilot/layouts/settings/models.py | 8 +- .../ui/sunnypilot/mici/layouts/models.py | 145 ++++++++++++++---- 2 files changed, 119 insertions(+), 34 deletions(-) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/models.py b/selfdrive/ui/sunnypilot/layouts/settings/models.py index b34604af0..adbd99f1f 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -41,7 +41,7 @@ class ModelsLayout(Widget): self._initialize_items() - self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") + self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB") for ctrl, key in [(self.lane_turn_value_control, "LaneTurnValue"), (self.delay_control, "LagdToggleDelay")]: ctrl.action_item.set_value(int(float(ui_state.params.get(key, return_default=True)) * 100)) @@ -112,7 +112,7 @@ class ModelsLayout(Widget): self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading) @staticmethod - def _calculate_cache_size(): + def calculate_cache_size(): cache_size = 0.0 if os.path.exists(CUSTOM_MODEL_PATH): cache_size = sum(os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file)) for file in os.listdir(CUSTOM_MODEL_PATH)) / (1024**2) @@ -122,7 +122,7 @@ class ModelsLayout(Widget): def _callback(response): if response == DialogResult.CONFIRM: ui_state.params.put_bool("ModelManager_ClearCache", True) - self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") + self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB") dialog = ConfirmDialog(tr("This will delete ALL downloaded models from the cache except the currently active model. Are you sure?"), tr("Clear Cache"), callback=_callback) @@ -155,7 +155,7 @@ class ModelsLayout(Widget): if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5: self.last_cache_calc_time = current_time - self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") + self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB") if self.download_status == custom.ModelManagerSP.DownloadStatus.downloading: device._reset_interactive_timeout() diff --git a/selfdrive/ui/sunnypilot/mici/layouts/models.py b/selfdrive/ui/sunnypilot/mici/layouts/models.py index d8da750fe..331743416 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -5,13 +5,45 @@ This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ from collections.abc import Callable +import pyray as rl from cereal import custom from openpilot.selfdrive.ui.mici.widgets.button import BigButton -from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout +from openpilot.selfdrive.ui.ui_state import ui_state, device +from openpilot.system.ui.lib.application import FontWeight, gui_app from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.scroller import NavScroller +class CurrentModelInfo(Widget): + def __init__(self): + super().__init__() + + self.set_rect(rl.Rectangle(0, 0, 360, 180)) + + 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.current_model_header = UnifiedLabel(tr("active model"), 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY) + self.current_model_text = UnifiedLabel(tr("default model"), 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True) + + self.info_header = UnifiedLabel("cache size", 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY) + self.info_text = UnifiedLabel("0 mb", 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN) + + def _render(self, _): + self.current_model_header.set_position(self._rect.x + 20, self._rect.y - 10) + self.current_model_header.render() + + self.current_model_text.set_position(self._rect.x + 20, self._rect.y + 68 - 25) + self.current_model_text.render() + + self.info_header.set_position(self._rect.x + 20, self._rect.y + 114 - 30) + self.info_header.render() + + self.info_text.set_position(self._rect.x + 20, self._rect.y + 161 - 25) + self.info_text.render() class ModelsLayoutMici(NavScroller): def __init__(self, back_callback: Callable): @@ -20,25 +52,35 @@ class ModelsLayoutMici(NavScroller): self.original_back_callback = back_callback self.focused_widget = None - self.current_model_btn = BigButton(tr("current model")) - self.current_model_btn.set_click_callback(self._show_folders) + self.current_model_info = CurrentModelInfo() + self._download_progress = "." + self._download_frame = 0 + self._was_downloading = False + + self.select_model_btn = BigButton(tr("select model")) + self.select_model_btn.set_click_callback(self._show_folders) self.cancel_download_btn = BigButton(tr("cancel download")) self.cancel_download_btn.set_click_callback(lambda: ui_state.params.remove("ModelManager_DownloadIndex")) - self.main_items = [self.current_model_btn, self.cancel_download_btn] + self.main_items = [self.current_model_info, self.select_model_btn, self.cancel_download_btn] self._scroller.add_widgets(self.main_items) @property def model_manager(self): return ui_state.sm["modelManagerSP"] - def _get_grouped_bundles(self): + def _get_grouped_bundles(self, favorites = None): bundles = self.model_manager.availableBundles folders = {} for bundle in bundles: folder = next((override.value for override in bundle.overrides if override.key == "folder"), "") folders.setdefault(folder, []).append(bundle) + + if favorites: + for fav_bundle in [bundle for bundle in bundles if bundle.ref in favorites]: + folders.setdefault("favorites", []).append(fav_bundle) + return folders def _show_selection_view(self, items, back_callback: Callable): @@ -49,18 +91,25 @@ class ModelsLayoutMici(NavScroller): self.set_back_callback(back_callback) def _show_folders(self): - self.focused_widget = self.current_model_btn - folders = self._get_grouped_bundles() + self.focused_widget = self.select_model_btn + + favs = ui_state.params.get("ModelManager_Favs") + favorites = set(favs.split(';')) if favs else set() + + folders = self._get_grouped_bundles(favorites) folder_buttons = [] default_btn = BigButton(tr("default model")) default_btn.set_click_callback(self._select_default) folder_buttons.append(default_btn) for folder in sorted(folders.keys(), key=lambda f: max((bundle.index for bundle in folders[f]), default=-1), reverse=True): - if folder.lower() in ["release models", "master models"]: + if folder.lower() in ["release models", "master models", "favorites"]: btn = BigButton(folder.lower()) btn.set_click_callback(lambda f=folder: self._select_folder(f)) - folder_buttons.append(btn) + if folder.lower() == "favorites": + folder_buttons.insert(0, btn) + else: + folder_buttons.append(btn) self._show_selection_view(folder_buttons, self._reset_main_view) def _select_model(self, bundle): @@ -72,7 +121,10 @@ class ModelsLayoutMici(NavScroller): self._reset_main_view() def _select_folder(self, folder_name): - folders = self._get_grouped_bundles() + favs = ui_state.params.get("ModelManager_Favs") + favorites = set(favs.split(';')) if favs else set() + + folders = self._get_grouped_bundles(favorites) bundles = sorted(folders.get(folder_name, []), key=lambda b: b.index, reverse=True) btns = [] @@ -86,29 +138,62 @@ class ModelsLayoutMici(NavScroller): def _reset_main_view(self): self._scroller._items = self.main_items self.set_back_callback(self.original_back_callback) - if self.focused_widget and self.focused_widget in self.main_items: - x = self._scroller._pad - for item in self.main_items: - if not item.is_visible: - continue - if item == self.focused_widget: - break - x += item.rect.width + self._scroller._spacing - self._scroller.scroll_panel.set_offset(0) - self._scroller.scroll_to(x) - self.focused_widget = None - else: - self._scroller.scroll_panel.set_offset(0) + self._scroller.scroll_panel.set_offset(0) + self._scroller.scroll_to(0) + + def hide_event(self): + super().hide_event() + if self._was_downloading: + device.set_override_interactive_timeout(None) + self._was_downloading = False def _update_state(self): super()._update_state() + self.select_model_btn.set_enabled(ui_state.is_offroad()) + self.cancel_download_btn.set_visible(False) + self.current_model_info.current_model_header._shimmer = False + self.current_model_info.info_header._shimmer = False + manager = self.model_manager - if manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading: - self.current_model_btn.set_value("downloading...") + self._download_frame += 1 + should_update = self._download_frame % (gui_app.target_fps / 2) == 0 + if should_update: + self._download_progress = self._download_progress + "." if len(self._download_progress) < 3 else "" + + is_downloading = (manager.selectedBundle + and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading) + if self._was_downloading and not is_downloading: + device.set_override_interactive_timeout(None) + self._was_downloading = is_downloading + + self.current_model_info.current_model_header.set_text(tr("active model")) + self.current_model_info.current_model_text.set_text(manager.activeBundle.displayName.lower() if manager.activeBundle.index > 0 else tr("default model")) + self.current_model_info.info_header.set_text(tr("cache size")) + self.current_model_info.info_text.set_text(f"{ModelsLayout.calculate_cache_size():.2f} MB") + + if manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed: + self.current_model_info.info_header.set_text(tr("error") + self._download_progress) + self.current_model_info.info_text.set_text(tr("download failed")) + + elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading: self.cancel_download_btn.set_visible(True) - else: - self.current_model_btn.set_value(manager.activeBundle.internalName.lower() if manager.activeBundle else tr("default model")) - self.cancel_download_btn.set_visible(False) - self.current_model_btn.set_enabled(ui_state.is_offroad()) - self.current_model_btn.set_text(tr("current model")) + device.set_override_interactive_timeout(5) + progress = 0.0 + count = 0 + for model in manager.selectedBundle.models: + count += 1 + p = model.artifact.downloadProgress + if p.status == custom.ModelManagerSP.DownloadStatus.downloading: + progress += p.progress + elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, + custom.ModelManagerSP.DownloadStatus.cached): + progress += 100.0 + + self.current_model_info.current_model_header.set_text(tr("downloading")) + self.current_model_info.current_model_header._shimmer = True + self.current_model_info.current_model_text.set_text(f"{manager.selectedBundle.internalName.lower()}") + self.current_model_info.info_header.set_text(tr("progress") + self._download_progress) + self.current_model_info.info_header._shimmer = True + self.current_model_info.info_text.set_text(f"{progress/count:.2f}%") + From 6102aedf05abaa30d92dcdf84e85df4f368ae66e Mon Sep 17 00:00:00 2001 From: David <49467229+TheSecurityDev@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:56:39 -0400 Subject: [PATCH 10/14] [TIZI/TICI] ui: fix unintended selection while scrolling in TreeOptionDialog (#1763) * fix: enable touch validation for visible items in TreeOptionDialog during scrolling * rebuild scroller and call add_widget instead --------- Co-authored-by: Jason Wen --- system/ui/sunnypilot/widgets/tree_dialog.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py index c34db092e..69233b803 100644 --- a/system/ui/sunnypilot/widgets/tree_dialog.py +++ b/system/ui/sunnypilot/widgets/tree_dialog.py @@ -198,7 +198,10 @@ class TreeOptionDialog(MultiOptionDialog): self.option_buttons = self.visible_items self.options = [item.text for item in self.visible_items] - self.scroller._items = self.visible_items + # Rebuild scroller items to ensure proper setup of touch callbacks + self.scroller._items.clear() + for item in self.option_buttons: + self.scroller.add_widget(item) if reset_scroll: self.scroller.scroll_panel.set_offset(0) From fd590b206e411683c3eda4a3cec848b14f32ec07 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:23:15 -0700 Subject: [PATCH 11/14] tools: script for video concatenation (#1613) * tools: script for video concatenation * more * final --------- Co-authored-by: Jason Wen --- sunnypilot/tools/pull_footage.py | 136 +++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100755 sunnypilot/tools/pull_footage.py diff --git a/sunnypilot/tools/pull_footage.py b/sunnypilot/tools/pull_footage.py new file mode 100755 index 000000000..9964ef576 --- /dev/null +++ b/sunnypilot/tools/pull_footage.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import argparse +import os +import shutil +import subprocess +import sys +import requests +from openpilot.tools.lib.route import Route + + +def get_segments(source, route_id, camera, seg_range): + if "@" in source or "comma-" in source or "sunny-" in source: # SSH + if not route_id: + raise ValueError("route_id required for SSH") + cmd = ["ssh", source, f"ls -d /data/media/0/realdata/{route_id.split('--')[0]}--*"] + output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode("utf-8").strip() + return [{ + "type": "ssh", + "host": source, + "src": os.path.join(path, camera), + "num": int(path.split("--")[-1]) + } for path in sorted(output.split("\n"), key=lambda x: int(x.split("--")[-1])) if path] + else: # URL + route = Route(route_id) + cameras = [camera] + if camera == "fcamera.hevc": + cameras.extend([c for c in ["ecamera.hevc", "qcamera.ts"] if c != camera]) + + for cam in cameras: + attr_name = "camera_paths" if cam == "fcamera.hevc" else f"{cam.split('.')[0]}_paths" + paths = getattr(route, attr_name)() + if any(paths): + return [{"type": "url", "src": url, "num": idx, "cam": cam} for idx, url in enumerate(paths) if url] + + raise ValueError(f"No footage found for {route_id}") + + +def download(job, out_dir): + destination = os.path.join(out_dir, f"{job['num']}_{os.path.basename(job.get('cam', job.get('src')))}") + if os.path.exists(destination) and os.path.getsize(destination) > 0: + return destination + + print(f"Downloading segment {job['num']}") + if job["type"] == "ssh": + subprocess.check_call(["scp", f"{job['host']}:{job['src']}", destination]) + else: + with requests.get(job["src"], stream=True) as r: + r.raise_for_status() + with open(destination, 'wb') as f: + shutil.copyfileobj(r.raw, f) + return destination + + +def mux(files, output_file, codec): + list_filename = f"{output_file}.list.txt" + with open(list_filename, 'w') as f: + f.write('\n'.join([f"file '{os.path.abspath(name)}'" for name in files])) + + try: + cmd = [ + "ffmpeg", "-y", "-probesize", "100M", "-analyzeduration", "100M", "-f", "concat", + "-safe", "0", "-r", "20", "-i", list_filename, "-c", "copy", "-tag:v", codec, output_file + ] + subprocess.check_call(cmd) + print(f"Saved: {output_file} ({os.path.getsize(output_file) / 1048576:.2f} MB)") + if sys.platform == "darwin": + subprocess.run(["open", "-R", output_file]) + finally: + if os.path.exists(list_filename): + os.remove(list_filename) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("source") + parser.add_argument("route_id", nargs='?') + parser.add_argument("--output", "-o", default="output.mp4") + parser.add_argument("--camera", "-c", default="fcamera.hevc") + parser.add_argument("--keep-segments", action="store_true") + args = parser.parse_args() + + try: + route_id_str = args.route_id or args.source + segment_range = None + if "/" in route_id_str: + route_id_str, range_str = route_id_str.rsplit("/", 1) + if ":" in range_str or range_str.isdigit(): + segment_range = range_str + + is_ssh = "@" in args.source or "comma-" in args.source or "sunny-" in args.source + if not is_ssh and len(route_id_str.split("--")) > 2: + route_id_str = "--".join(route_id_str.split("--")[:2]) + + segments = get_segments(args.source, route_id_str, args.camera, segment_range) + if segment_range: + if ":" in segment_range: + parts = segment_range.split(":") + start_idx = int(parts[0]) if parts[0] else None + end_idx = int(parts[1]) if parts[1] else None + else: + start_idx = int(segment_range) + end_idx = start_idx + 1 + + segments = [ + segment for segment in segments + if (start_idx is None or segment['num'] >= start_idx) and (end_idx is None or segment['num'] < end_idx) + ] + + download_dir = f"{route_id_str}_segments" + os.makedirs(download_dir, exist_ok=True) + + downloaded_files = sorted( + [download(segment, download_dir) for segment in segments], + key=lambda x: int(os.path.basename(x).split("_")[0]) + ) + + camera_name = segments[0].get('cam', args.camera) + codec = "hvc1" if camera_name.endswith("hevc") else "avc1" + mux(downloaded_files, f"{route_id_str}--{args.output}", codec) + + if not args.keep_segments: + shutil.rmtree(download_dir) + + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() From 613d13bbfbc539b70ea442020ca2ce614a37e105 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:07:15 -0700 Subject: [PATCH 12/14] tools: profile memory usage (#1622) * tools: profile memory usage * final --------- Co-authored-by: Jason Wen --- sunnypilot/tools/__init__.py | 0 sunnypilot/tools/memory_profiler/__init__.py | 0 sunnypilot/tools/memory_profiler/mem_usage.py | 164 ++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 sunnypilot/tools/__init__.py create mode 100644 sunnypilot/tools/memory_profiler/__init__.py create mode 100644 sunnypilot/tools/memory_profiler/mem_usage.py diff --git a/sunnypilot/tools/__init__.py b/sunnypilot/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sunnypilot/tools/memory_profiler/__init__.py b/sunnypilot/tools/memory_profiler/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sunnypilot/tools/memory_profiler/mem_usage.py b/sunnypilot/tools/memory_profiler/mem_usage.py new file mode 100644 index 000000000..20b4bb2d0 --- /dev/null +++ b/sunnypilot/tools/memory_profiler/mem_usage.py @@ -0,0 +1,164 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import matplotlib.pyplot as plt +import os +import sys +import argparse +import numpy as np +import base64 +import io + +from openpilot.tools.lib.logreader import LogReader, ReadMode + + +def extract_mem_cpu_data(lr): + times, mems, cpus = [], [], [] + start_time = None + + for msg in lr: + if msg.which() == 'procLog': + if start_time is None: + start_time = msg.logMonoTime + mem = msg.procLog.mem + mem_usage = (mem.total - mem.available) / mem.total * 100 + cpu_usages = [(total - cpu.idle) / total * 100 for cpu in msg.procLog.cpuTimes + if (total := cpu.idle + cpu.user + cpu.system + cpu.nice + cpu.iowait + cpu.irq + cpu.softirq) > 0] + avg_cpu = sum(cpu_usages) / len(cpu_usages) if cpu_usages else 0 + times.append((msg.logMonoTime - start_time) / 1e9) + mems.append(mem_usage) + cpus.append(avg_cpu) + return times, mems, cpus + + +def process_segment(lr): + return [extract_mem_cpu_data(lr)] + + +def calculate_r_squared(y_true, y_pred): + ss_res = np.sum((y_true - y_pred) ** 2) + ss_tot = np.sum((y_true - np.mean(y_true)) ** 2) + return 1 - (ss_res / ss_tot) if ss_tot != 0 else 0 + + +def plot_results(segments, segment_data, route_name): + valid_data = [d for d in segment_data if d and d[0]] + if not valid_data: + print("No valid data to plot") + return + + avg_mems = [np.mean(d[1]) for d in valid_data] + avg_cpus = [np.mean(d[2]) for d in valid_data] + valid_segments = [segments[i] for i, d in enumerate(segment_data) if d and d[0]] + + height = max(10, 5 + len(valid_segments) * 0.4) + fig1, ax1 = plt.subplots(1, 1, figsize=(12, height), dpi=150) + + y_pos = range(len(valid_segments)) + ax1.barh([y - 0.2 for y in y_pos], avg_mems, height=0.4, color="dodgerblue", alpha=0.8, label="Avg Mem %") + ax1.barh([y + 0.2 for y in y_pos], avg_cpus, height=0.4, color="green", alpha=0.8, label="Avg CPU %") + + for i, (mem, cpu) in enumerate(zip(avg_mems, avg_cpus, strict=True)): + ax1.text(mem, i - 0.2, f"{mem:.1f}%", va="center", fontsize=8, color="#005a9e", fontweight="bold") + ax1.text(cpu, i + 0.2, f"{cpu:.1f}%", va="center", fontsize=8, color="#005a9e", fontweight="bold") + + ax1.set_yticks(y_pos) + ax1.set_yticklabels([f"Seg {s}" for s in valid_segments]) + ax1.set_xlabel("Usage (%)") + ax1.set_title("Average Memory and CPU Usage by Segment") + ax1.legend() + ax1.grid(axis="x", linestyle="--", alpha=0.5) + ax1.invert_yaxis() + + fig2, ax2 = plt.subplots(1, 1, figsize=(12, 8), dpi=150) + combined_times, combined_mems, combined_cpus = [], [], [] + time_offset = 0.0 + for times, mems, cpus in valid_data: + if times: + combined_times.extend([t + time_offset for t in times]) + combined_mems.extend(mems) + combined_cpus.extend(cpus) + time_offset += max(times) + + ax2.plot(combined_times, combined_mems, color="red", label="Memory Usage", alpha=0.6) + ax2.plot(combined_times, combined_cpus, color="blue", label="CPU Usage", alpha=0.6) + + warmup_sec = 60 + if len(combined_times) > 1 and combined_times[-1] > warmup_sec: + mask = np.array(combined_times) > warmup_sec + x_reg = np.array(combined_times)[mask] + + y_mem_reg = np.array(combined_mems)[mask] + slope_mem, intercept_mem = np.polyfit(x_reg, y_mem_reg, 1) + trend_mem = slope_mem * x_reg + intercept_mem + r2_mem = calculate_r_squared(y_mem_reg, trend_mem) + ax2.plot(x_reg, trend_mem, color="darkred", linestyle="--", linewidth=2.5, + label=f"Mem Trend (Slope: {slope_mem:.4f} %/s, R²: {r2_mem:.2f})") + + y_cpu_reg = np.array(combined_cpus)[mask] + slope_cpu, intercept_cpu = np.polyfit(x_reg, y_cpu_reg, 1) + trend_cpu = slope_cpu * x_reg + intercept_cpu + r2_cpu = calculate_r_squared(y_cpu_reg, trend_cpu) + ax2.plot(x_reg, trend_cpu, color="navy", linestyle="--", linewidth=2.5, + label=f"CPU Trend (Slope: {slope_cpu:.4f} %/s, R²: {r2_cpu:.2f})") + + ax2.set_xlabel("Time (s)") + ax2.set_ylabel("Usage (%)") + ax2.set_title("Memory and CPU Usage Over Time") + ax2.legend(loc='lower left', fontsize='small', framealpha=0.9) + ax2.grid(True, linestyle="--", alpha=0.5) + + buffer1 = io.BytesIO() + fig1.savefig(buffer1, format='webp', bbox_inches='tight', pad_inches=1.0) + buffer1.seek(0) + img1 = base64.b64encode(buffer1.getvalue()).decode() + + buffer2 = io.BytesIO() + fig2.savefig(buffer2, format='webp', bbox_inches='tight', pad_inches=1.0) + buffer2.seek(0) + img2 = base64.b64encode(buffer2.getvalue()).decode() + + filename = f"memory_usage_{route_name}.html" + save_path = os.path.join(os.path.dirname(__file__), "plots", filename) + os.makedirs(os.path.dirname(save_path), exist_ok=True) + + html_template = ( + "" + + f"

Memory Profile Report

Route: {route_name.replace('_', '/')}

" + + f"" + + f"" + ) + + plt.close(fig1) + plt.close(fig2) + + with open(save_path, "w") as f: + f.write(html_template) + + print(f"Report saved to {save_path}") + + +def main(): + parser = argparse.ArgumentParser(description='Extract memory usage from route logs.') + parser.add_argument('route_or_segment_name', help='Route or segment name from comma connect') + args = parser.parse_args() + + try: + print(f"Fetching logs for {args.route_or_segment_name}") + lr = LogReader(args.route_or_segment_name, default_mode=ReadMode.QLOG) + segment_data = lr.run_across_segments(24, process_segment) + segments = list(range(len(segment_data))) + route_name = args.route_or_segment_name.replace('/', '_') + plot_results(segments, segment_data, route_name) + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() From 3509fccec7c050c1f4758574ec5a5cdf93774e98 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 14 Apr 2026 21:44:39 -0400 Subject: [PATCH 13/14] [TIZI/TICI] ui: remove per-frame param sync (#1802) * [TIZI/TICI] ui: remove per-frame param sync * fix: prevent params.put skip in OptionControlSP by deferring mutation to set_value The idempotent guard added in the previous commit was being bypassed because _handle_mouse_release mutated self.current_value before calling set_value(), making the check always return early. Now we calculate the new value and pass it to set_value, allowing the guard to work correctly and params to persist. --- .../ui/sunnypilot/layouts/settings/display.py | 17 ++------- .../ui/sunnypilot/widgets/option_control.py | 36 ++++++++++--------- 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/display.py b/selfdrive/ui/sunnypilot/layouts/settings/display.py index acd7b52dc..8ba566366 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/display.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/display.py @@ -6,12 +6,10 @@ See the LICENSE.md file in the root directory for more details. """ from enum import IntEnum -from openpilot.common.params import Params -from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP from openpilot.system.ui.widgets import Widget from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets.scroller_tici import Scroller -from openpilot.system.ui.sunnypilot.widgets.list_view import option_item_sp, ToggleActionSP +from openpilot.system.ui.sunnypilot.widgets.list_view import option_item_sp from openpilot.sunnypilot.system.params_migration import ONROAD_BRIGHTNESS_TIMER_VALUES @@ -25,7 +23,6 @@ class DisplayLayout(Widget): def __init__(self): super().__init__() - self._params = Params() items = self._initialize_items() self._scroller = Scroller(items, line_separator=True, spacing=0) @@ -87,17 +84,7 @@ class DisplayLayout(Widget): def _update_state(self): super()._update_state() - for _item in self._scroller._items: - if isinstance(_item.action_item, ToggleActionSP) and _item.action_item.toggle.param_key is not None: - _item.action_item.set_state(self._params.get_bool(_item.action_item.toggle.param_key)) - elif isinstance(_item.action_item, OptionControlSP) and _item.action_item.param_key is not None: - raw_value = self._params.get(_item.action_item.param_key, return_default=True) - if _item.action_item.value_map: - reverse_map = {v: k for k, v in _item.action_item.value_map.items()} - raw_value = reverse_map.get(raw_value, _item.action_item.current_value) - _item.action_item.set_value(raw_value) - - brightness_val = self._params.get("OnroadScreenOffBrightness", return_default=True) + brightness_val = self._onroad_brightness.action_item.current_value self._onroad_brightness_timer.action_item.set_enabled(brightness_val not in (OnroadBrightness.AUTO, OnroadBrightness.AUTO_DARK)) def _render(self, rect): diff --git a/system/ui/sunnypilot/widgets/option_control.py b/system/ui/sunnypilot/widgets/option_control.py index 291d8f6ff..82126417d 100644 --- a/system/ui/sunnypilot/widgets/option_control.py +++ b/system/ui/sunnypilot/widgets/option_control.py @@ -60,17 +60,19 @@ class OptionControlSP(ItemAction): def set_value(self, value: int): """Set the control to a specific value""" - if self.min_value <= value <= self.max_value: - self.current_value = value - if self.value_map: - self.params.put(self.param_key, self.value_map[value]) - else: - if self.use_float_scaling: - self.params.put(self.param_key, value / 100.0) - else: - self.params.put(self.param_key, value) - if self.on_value_changed: - self.on_value_changed(value) + if not (self.min_value <= value <= self.max_value): + return + if value == self.current_value: + return + self.current_value = value + if self.value_map: + self.params.put(self.param_key, self.value_map[value]) + elif self.use_float_scaling: + self.params.put(self.param_key, value / 100.0) + else: + self.params.put(self.param_key, value) + if self.on_value_changed: + self.on_value_changed(value) def get_displayed_value(self) -> str: """Get the displayed value, handling value mapping if present""" @@ -157,10 +159,10 @@ class OptionControlSP(ItemAction): def _handle_mouse_release(self, mouse_pos: MousePos): if self._minus_enabled and rl.check_collision_point_rec(mouse_pos, self.minus_btn_rect): - self.current_value -= self.value_change_step - self.current_value = max(self.min_value, self.current_value) + new_value = self.current_value - self.value_change_step + new_value = max(self.min_value, new_value) + self.set_value(new_value) elif self._plus_enabled and rl.check_collision_point_rec(mouse_pos, self.plus_btn_rect): - self.current_value += self.value_change_step - self.current_value = min(self.max_value, self.current_value) - - self.set_value(self.current_value) + new_value = self.current_value + self.value_change_step + new_value = min(self.max_value, new_value) + self.set_value(new_value) From 61915eb9144d8f921177341dfcd353119000e435 Mon Sep 17 00:00:00 2001 From: Nayan Date: Tue, 14 Apr 2026 23:36:06 -0400 Subject: [PATCH 14/14] [MICI] ui: always offroad (#1695) * always offroad ui * remove * lint * better * fix sync issues * fix sync issues * update for upstream sync * move it all to top settings panel * not red * no home screen, just buttons --------- Co-authored-by: Jason Wen --- selfdrive/ui/mici/layouts/main.py | 1 - .../ui/sunnypilot/mici/layouts/settings.py | 59 ++++++++++++++++++- selfdrive/ui/sunnypilot/ui_state.py | 1 + .../assets/icons_mici/always_offroad.png | 3 + .../assets/icons_mici/disable_offroad.png | 3 + 5 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 sunnypilot/selfdrive/assets/icons_mici/always_offroad.png create mode 100644 sunnypilot/selfdrive/assets/icons_mici/disable_offroad.png diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index 2f41e1f17..e7dfd3410 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -13,7 +13,6 @@ from openpilot.system.ui.lib.application import gui_app if gui_app.sunnypilot_ui(): from openpilot.selfdrive.ui.sunnypilot.mici.layouts.settings import SettingsLayoutSP as SettingsLayout - ONROAD_DELAY = 2.5 # seconds diff --git a/selfdrive/ui/sunnypilot/mici/layouts/settings.py b/selfdrive/ui/sunnypilot/mici/layouts/settings.py index 9e160521c..85a782ecb 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/settings.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/settings.py @@ -5,18 +5,32 @@ This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ from openpilot.selfdrive.ui.mici.layouts.settings import settings as OP -from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton +from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici +from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr ICON_SIZE = 70 +BIG_ICON_SIZE = 110 class SettingsLayoutSP(OP.SettingsLayout): def __init__(self): OP.SettingsLayout.__init__(self) + device_panel = DeviceLayoutMici() + self._scroller._items[2].set_click_callback(lambda: gui_app.push_widget(device_panel)) + + self.icon_offroad_enable = gui_app.texture("../../sunnypilot/selfdrive/assets/icons_mici/always_offroad.png", BIG_ICON_SIZE, + BIG_ICON_SIZE) + self.icon_offroad_disable = gui_app.texture("../../sunnypilot/selfdrive/assets/icons_mici/disable_offroad.png", BIG_ICON_SIZE, + BIG_ICON_SIZE) + self.icon_offroad_slider = gui_app.texture("icons_mici/settings/device/lkas.png", BIG_ICON_SIZE, BIG_ICON_SIZE) + sunnylink_panel = SunnylinkLayoutMici(back_callback=gui_app.pop_widget) sunnylink_btn = BigButton("sunnylink", "", gui_app.texture("icons_mici/settings/developer/ssh.png", ICON_SIZE, ICON_SIZE)) sunnylink_btn.set_click_callback(lambda: gui_app.push_widget(sunnylink_panel)) @@ -25,10 +39,53 @@ class SettingsLayoutSP(OP.SettingsLayout): models_btn = BigButton("models", "", gui_app.texture("../../sunnypilot/selfdrive/assets/offroad/icon_models.png", ICON_SIZE, ICON_SIZE)) models_btn.set_click_callback(lambda: gui_app.push_widget(models_panel)) + # onroad: enable button sits at the front (left of toggles) + self._enable_offroad_btn_onroad = BigCircleButton(self.icon_offroad_enable, red=True) + self._enable_offroad_btn_onroad.set_click_callback(lambda: self._handle_always_offroad(True)) + self._enable_offroad_btn_onroad.set_visible(lambda: ui_state.started and not ui_state.always_offroad) + + # offroad: enable button sits at the end (right of developer) + self._enable_offroad_btn_offroad = BigCircleButton(self.icon_offroad_enable, red=True) + self._enable_offroad_btn_offroad.set_click_callback(lambda: self._handle_always_offroad(True)) + self._enable_offroad_btn_offroad.set_visible(lambda: not ui_state.started and not ui_state.always_offroad) + + self._disable_offroad_btn = BigCircleButton(self.icon_offroad_disable, red=False) + self._disable_offroad_btn.set_click_callback(lambda: self._handle_always_offroad(False)) + self._disable_offroad_btn.set_visible(lambda: ui_state.always_offroad) + items = self._scroller._items.copy() items.insert(1, sunnylink_btn) items.insert(2, models_btn) + + # front slots (only one ever visible at a time): exit-always-offroad, then enable-onroad + items.insert(0, self._enable_offroad_btn_onroad) + items.insert(0, self._disable_offroad_btn) + # end slot: enable-offroad (right of developer) + items.append(self._enable_offroad_btn_offroad) + self._scroller._items.clear() for item in items: self._scroller.add_widget(item) + + def _update_state(self): + super()._update_state() + + def _handle_always_offroad(self, enable: bool): + + def _set_offroad_status(status: bool): + if not ui_state.engaged: + ui_state.params.put_bool("OffroadMode", status) + ui_state.always_offroad = status + + if not enable: + dlg = BigConfirmationDialog(tr("slide to exit always offroad"), self.icon_offroad_slider, red=False, + confirm_callback=lambda: _set_offroad_status(False)) + else: + if ui_state.engaged: + gui_app.push_widget(BigDialog(tr("disengage to enable always offroad"), "", )) + return + + dlg = BigConfirmationDialog(tr("slide to force offroad"), self.icon_offroad_slider, red=True, + confirm_callback=lambda: _set_offroad_status(True)) + gui_app.push_widget(dlg) diff --git a/selfdrive/ui/sunnypilot/ui_state.py b/selfdrive/ui/sunnypilot/ui_state.py index 7766c353a..2a48e9eee 100644 --- a/selfdrive/ui/sunnypilot/ui_state.py +++ b/selfdrive/ui/sunnypilot/ui_state.py @@ -146,6 +146,7 @@ class UIStateSP: self.true_v_ego_ui = self.params.get_bool("TrueVEgoUI") self.turn_signals = self.params.get_bool("ShowTurnSignals") self.boot_offroad_mode = self.params.get("DeviceBootMode", return_default=True) + self.always_offroad = self.params.get_bool("OffroadMode") class DeviceSP: diff --git a/sunnypilot/selfdrive/assets/icons_mici/always_offroad.png b/sunnypilot/selfdrive/assets/icons_mici/always_offroad.png new file mode 100644 index 000000000..56f35669c --- /dev/null +++ b/sunnypilot/selfdrive/assets/icons_mici/always_offroad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e459241d896824f5e8207d568847acab3dedd41caae7af59d4c17e043663b0c9 +size 4035 diff --git a/sunnypilot/selfdrive/assets/icons_mici/disable_offroad.png b/sunnypilot/selfdrive/assets/icons_mici/disable_offroad.png new file mode 100644 index 000000000..146734aaf --- /dev/null +++ b/sunnypilot/selfdrive/assets/icons_mici/disable_offroad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27a0fca872d4586f578d246890b83674cdb7ecb03f58b2b0379b4b64a5816053 +size 3908