diff --git a/cereal/log.capnp b/cereal/log.capnp index 50777e4fb..9d4ca5e80 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -560,8 +560,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/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/opendbc_repo b/opendbc_repo index 8d529c156..2b538cde7 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 8d529c156600ff68308d2f5adf4fbec934ebd483 +Subproject commit 2b538cde7cc39c5c3be7d34dbf44ecc06bc64611 diff --git a/panda b/panda index 1681ede8d..821baec41 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 1681ede8dba76eec13cc303b957d134b6a269750 +Subproject commit 821baec41b98bf5b65bbd43276405f708ff8aba1 diff --git a/scripts/reporter.py b/scripts/reporter.py index 64f6cb99b..93b71761a 100755 --- a/scripts/reporter.py +++ b/scripts/reporter.py @@ -33,16 +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_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://reporterv2.comma.life/{master})", "|", f"[{pr}](https://reporterv2.comma.life/{pr})", "|") diff --git a/selfdrive/modeld/SConscript b/selfdrive/modeld/SConscript index 7a82ff88b..f02e66722 100644 --- a/selfdrive/modeld/SConscript +++ b/selfdrive/modeld/SConscript @@ -21,7 +21,7 @@ tg_flags = { }.get(arch, 'DEV=CPU CPU_LLVM=1 THREADS=0') # 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} python3 {Dir("#selfdrive/modeld").abspath}/get_model_metadata.py {fn}.onnx' @@ -59,5 +59,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 df77c9c0e..d774e475d 100755 --- a/selfdrive/modeld/modeld.py +++ b/selfdrive/modeld/modeld.py @@ -40,10 +40,8 @@ SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') MODELS_DIR = Path(__file__).parent / 'models' 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 @@ -158,13 +156,7 @@ class ModelState(ModelStateBase): 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'] @@ -188,13 +180,11 @@ class ModelState(ModelStateBase): 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()} @@ -243,17 +233,9 @@ class ModelState(ModelStateBase): 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 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/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/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/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}%") + 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/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/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() 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 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) 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() 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() diff --git a/system/hardware/hardwared.py b/system/hardware/hardwared.py index 60721b614..d4d28418a 100755 --- a/system/hardware/hardwared.py +++ b/system/hardware/hardwared.py @@ -338,6 +338,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 + # Handle offroad/onroad transition should_start = all(onroad_conditions.values()) if started_ts is None: @@ -435,9 +438,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], 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) 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) 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" "$@" 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/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 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,