diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 65d2f5588e..e8f1b7eb8b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,11 @@ * @sunnypilot/dev-internal /.github/ @devtekve @sunnyhaibin -/release/ci/ @devtekve @sunnyhaibin \ No newline at end of file +/release/ci/ @devtekve @sunnyhaibin +/tinygrad_repo @devtekve @Discountchubbs +/tinygrad/ @devtekve @Discountchubbs +/selfdrive/controls/lib/longitudinal_planner.py @devtekve @Discountchubbs +/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py @devtekve @Discountchubbs +/selfdrive/modeld/ @devtekve @Discountchubbs +/sunnypilot/model* @devtekve @Discountchubbs +/sunnypilot/sunnylink/ @devtekve +/system/athena/ @devtekve \ No newline at end of file diff --git a/RELEASES.md b/RELEASES.md index 5d74689a2d..91652d6dc7 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,3 +1,6 @@ +Version 0.9.10 (2025-06-30) +======================== + Version 0.9.9 (2025-05-23) ======================== * New driving model diff --git a/SConstruct b/SConstruct index 02126f6359..77c8525896 100644 --- a/SConstruct +++ b/SConstruct @@ -363,13 +363,13 @@ SConscript(['rednose/SConscript']) # Build system services SConscript([ - 'system/proclogd/SConscript', 'system/ubloxd/SConscript', 'system/loggerd/SConscript', ]) if arch != "Darwin": SConscript([ 'system/logcatd/SConscript', + 'system/proclogd/SConscript', ]) if arch == "larch64": diff --git a/cereal/custom.capnp b/cereal/custom.capnp index 8a42f8b6a5..c9e4f3e16d 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -226,7 +226,13 @@ struct BackupManagerSP @0xf98d843bfd7004a3 { struct CarStateSP @0xb86e6369214c01c8 { } -struct CustomReserved8 @0xf416ec09499d9d19 { +struct LiveMapDataSP @0xf416ec09499d9d19 { + speedLimitValid @0 :Bool; + speedLimit @1 :Float32; + speedLimitAheadValid @2 :Bool; + speedLimitAhead @3 :Float32; + speedLimitAheadDistance @4 :Float32; + roadName @5 :Text; } struct CustomReserved9 @0xa1680744031fdb2d { diff --git a/cereal/log.capnp b/cereal/log.capnp index 412660bc6a..46d1d25d64 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -2610,7 +2610,7 @@ struct Event { carControlSP @112 :Custom.CarControlSP; backupManagerSP @113 :Custom.BackupManagerSP; carStateSP @114 :Custom.CarStateSP; - customReserved8 @115 :Custom.CustomReserved8; + liveMapDataSP @115 :Custom.LiveMapDataSP; customReserved9 @116 :Custom.CustomReserved9; customReserved10 @136 :Custom.CustomReserved10; customReserved11 @137 :Custom.CustomReserved11; diff --git a/cereal/services.py b/cereal/services.py index 4097e484f6..3e72041b72 100755 --- a/cereal/services.py +++ b/cereal/services.py @@ -84,6 +84,7 @@ _services: dict[str, tuple] = { "carParamsSP": (True, 0.02, 1), "carControlSP": (True, 100., 10), "carStateSP": (True, 100., 10), + "liveMapDataSP": (True, 1., 1), # debug "uiDebug": (True, 0., 1), diff --git a/common/params_keys.h b/common/params_keys.h index 6474fb5840..9d826e44f0 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -71,7 +71,7 @@ inline static std::unordered_map keys = { {"LastPowerDropDetected", CLEAR_ON_MANAGER_START}, {"LastUpdateException", CLEAR_ON_MANAGER_START}, {"LastUpdateTime", PERSISTENT}, - {"LiveDelay", PERSISTENT}, + {"LiveDelay", PERSISTENT | BACKUP}, {"LiveParameters", PERSISTENT}, {"LiveParametersV2", PERSISTENT}, {"LiveTorqueParameters", PERSISTENT | DONT_LOG}, @@ -131,11 +131,12 @@ inline static std::unordered_map keys = { {"CarParamsSPCache", CLEAR_ON_MANAGER_START}, {"CarParamsSPPersistent", PERSISTENT}, {"CarPlatformBundle", PERSISTENT}, + {"DeviceBootMode", PERSISTENT | BACKUP}, {"EnableGithubRunner", PERSISTENT | BACKUP}, {"MaxTimeOffroad", PERSISTENT | BACKUP}, + {"Brightness", PERSISTENT | BACKUP}, {"ModelRunnerTypeCache", CLEAR_ON_ONROAD_TRANSITION}, {"OffroadMode", CLEAR_ON_MANAGER_START}, - {"OffroadMode_Status", CLEAR_ON_MANAGER_START}, {"QuietMode", PERSISTENT | BACKUP}, // MADS params @@ -170,4 +171,28 @@ inline static std::unordered_map keys = { {"HyundaiLongitudinalTuning", PERSISTENT}, {"DynamicExperimentalControl", PERSISTENT}, + {"BlindSpot", PERSISTENT | BACKUP}, + + // model panel params + {"LagdToggle", PERSISTENT | BACKUP}, + + // mapd + {"MapAdvisorySpeedLimit", CLEAR_ON_ONROAD_TRANSITION}, + {"MapdVersion", PERSISTENT}, + {"MapSpeedLimit", CLEAR_ON_ONROAD_TRANSITION}, + {"NextMapSpeedLimit", CLEAR_ON_ONROAD_TRANSITION}, + {"Offroad_OSMUpdateRequired", CLEAR_ON_MANAGER_START}, + {"OsmDbUpdatesCheck", CLEAR_ON_MANAGER_START}, // mapd database update happens with device ON, reset on boot + {"OSMDownloadBounds", PERSISTENT}, + {"OsmDownloadedDate", PERSISTENT}, + {"OSMDownloadLocations", PERSISTENT}, + {"OSMDownloadProgress", CLEAR_ON_MANAGER_START}, + {"OsmLocal", PERSISTENT}, + {"OsmLocationName", PERSISTENT}, + {"OsmLocationTitle", PERSISTENT}, + {"OsmLocationUrl", PERSISTENT}, + {"OsmStateName", PERSISTENT}, + {"OsmStateTitle", PERSISTENT}, + {"OsmWayTest", PERSISTENT}, + {"RoadName", CLEAR_ON_ONROAD_TRANSITION}, }; diff --git a/common/version.h b/common/version.h index 6351a5b3ff..67b69da7c7 100644 --- a/common/version.h +++ b/common/version.h @@ -1 +1 @@ -#define COMMA_VERSION "0.9.9" +#define COMMA_VERSION "0.9.10" diff --git a/common/watchdog.py b/common/watchdog.py new file mode 100644 index 0000000000..ddb6f744e9 --- /dev/null +++ b/common/watchdog.py @@ -0,0 +1,22 @@ +import os +import time +import struct +from openpilot.system.hardware.hw import Paths + +WATCHDOG_FN = f"{Paths.shm_path()}/wd_" +_LAST_KICK = 0.0 + +def kick_watchdog(): + global _LAST_KICK + current_time = time.monotonic() + + if current_time - _LAST_KICK < 1.0: + return + + try: + with open(f"{WATCHDOG_FN}{os.getpid()}", 'wb') as f: + f.write(struct.pack(' \"Device\" to exit Always Offroad mode.", - "severity": 1 + "Offroad_OSMUpdateRequired": { + "text": "OpenStreetMap database is out of date. New maps must be downloaded if you wish to continue using OpenStreetMap data for Enhanced Speed Control and road name display.\n\n%1", + "severity": 0 } } diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index 4c2e44ed9d..edd0ae7d7c 100755 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -129,8 +129,10 @@ class SelfdriveD(CruiseHelper): # some comma three with NVMe experience NVMe dropouts mid-drive that # cause loggerd to crash on write, so ignore it only on that platform self.ignored_processes = set() - if HARDWARE.get_device_type() == 'tici' and os.path.exists('/dev/nvme0'): + nvme_expected = os.path.exists('/dev/nvme0n1') or (not os.path.isfile("/persist/comma/living-in-the-moment")) + if HARDWARE.get_device_type() == 'tici' and nvme_expected: self.ignored_processes = {'loggerd', } + self.ignored_processes.update({'mapd'}) # Determine startup event self.startup_event = EventName.startup if build_metadata.openpilot.comma_remote and build_metadata.tested_channel else EventName.startupMaster diff --git a/selfdrive/test/process_replay/regen.py b/selfdrive/test/process_replay/regen.py index 4d00613f0a..273659c9ff 100755 --- a/selfdrive/test/process_replay/regen.py +++ b/selfdrive/test/process_replay/regen.py @@ -23,7 +23,7 @@ class DummyFrameReader(BaseFrameReader): self.frame_count = frame_count self.frame_type = FrameType.raw - def get(self, idx, count=1, pix_fmt="yuv420p"): + def get(self, idx, count=1, pix_fmt="rgb24"): if pix_fmt == "rgb24": shape = (self.h, self.w, 3) elif pix_fmt == "nv12" or pix_fmt == "yuv420p": diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py index 56df253b52..55de9e82f1 100644 --- a/selfdrive/ui/layouts/home.py +++ b/selfdrive/ui/layouts/home.py @@ -1,17 +1,212 @@ +import time import pyray as rl -from openpilot.system.ui.lib.label import gui_text_box +from collections.abc import Callable +from enum import IntEnum +from openpilot.common.params import Params +from openpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.lib.label import gui_label +from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_COLOR + + +HEADER_HEIGHT = 80 +HEAD_BUTTON_FONT_SIZE = 40 +CONTENT_MARGIN = 40 +SPACING = 25 +RIGHT_COLUMN_WIDTH = 750 +REFRESH_INTERVAL = 10.0 + +PRIME_BG_COLOR = rl.Color(51, 51, 51, 255) + + +class HomeLayoutState(IntEnum): + HOME = 0 + UPDATE = 1 + ALERTS = 2 class HomeLayout: def __init__(self): - pass + self.params = Params() + + self.update_alert = UpdateAlert() + self.offroad_alert = OffroadAlert() + + self.current_state = HomeLayoutState.HOME + self.last_refresh = 0 + self.settings_callback: callable | None = None + + self.update_available = False + self.alert_count = 0 + + self.header_rect = rl.Rectangle(0, 0, 0, 0) + self.content_rect = rl.Rectangle(0, 0, 0, 0) + self.left_column_rect = rl.Rectangle(0, 0, 0, 0) + self.right_column_rect = rl.Rectangle(0, 0, 0, 0) + + self.update_notif_rect = rl.Rectangle(0, 0, 200, HEADER_HEIGHT - 10) + self.alert_notif_rect = rl.Rectangle(0, 0, 220, HEADER_HEIGHT - 10) + + self._setup_callbacks() + + def _setup_callbacks(self): + self.update_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME)) + self.offroad_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME)) + + def set_settings_callback(self, callback: Callable): + self.settings_callback = callback + + def _set_state(self, state: HomeLayoutState): + self.current_state = state def render(self, rect: rl.Rectangle): - gui_text_box( - rect, - "Demo Home Layout", - font_size=170, - color=rl.WHITE, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + self._update_layout_rects(rect) + + current_time = time.time() + if current_time - self.last_refresh >= REFRESH_INTERVAL: + self._refresh() + self.last_refresh = current_time + + self._handle_input() + self._render_header() + + # Render content based on current state + if self.current_state == HomeLayoutState.HOME: + self._render_home_content() + elif self.current_state == HomeLayoutState.UPDATE: + self._render_update_view() + elif self.current_state == HomeLayoutState.ALERTS: + self._render_alerts_view() + + def _update_layout_rects(self, rect: rl.Rectangle): + self.header_rect = rl.Rectangle( + rect.x + CONTENT_MARGIN, rect.y + CONTENT_MARGIN, rect.width - 2 * CONTENT_MARGIN, HEADER_HEIGHT ) + + content_y = rect.y + CONTENT_MARGIN + HEADER_HEIGHT + SPACING + content_height = rect.height - CONTENT_MARGIN - HEADER_HEIGHT - SPACING - CONTENT_MARGIN + + self.content_rect = rl.Rectangle( + rect.x + CONTENT_MARGIN, content_y, rect.width - 2 * CONTENT_MARGIN, content_height + ) + + left_width = self.content_rect.width - RIGHT_COLUMN_WIDTH - SPACING + + self.left_column_rect = rl.Rectangle(self.content_rect.x, self.content_rect.y, left_width, self.content_rect.height) + + self.right_column_rect = rl.Rectangle( + self.content_rect.x + left_width + SPACING, self.content_rect.y, RIGHT_COLUMN_WIDTH, self.content_rect.height + ) + + self.update_notif_rect.x = self.header_rect.x + self.update_notif_rect.y = self.header_rect.y + (self.header_rect.height - 60) // 2 + + notif_x = self.header_rect.x + (220 if self.update_available else 0) + self.alert_notif_rect.x = notif_x + self.alert_notif_rect.y = self.header_rect.y + (self.header_rect.height - 60) // 2 + + def _handle_input(self): + if not rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT): + return + + mouse_pos = rl.get_mouse_position() + + if self.update_available and rl.check_collision_point_rec(mouse_pos, self.update_notif_rect): + self._set_state(HomeLayoutState.UPDATE) + return + + if self.alert_count > 0 and rl.check_collision_point_rec(mouse_pos, self.alert_notif_rect): + self._set_state(HomeLayoutState.ALERTS) + return + + # Content area input handling + if self.current_state == HomeLayoutState.UPDATE: + self.update_alert.handle_input(mouse_pos, True) + elif self.current_state == HomeLayoutState.ALERTS: + self.offroad_alert.handle_input(mouse_pos, True) + + def _render_header(self): + font = gui_app.font(FontWeight.MEDIUM) + + # Update notification button + if self.update_available: + # Highlight if currently viewing updates + highlight_color = rl.Color(255, 140, 40, 255) if self.current_state == HomeLayoutState.UPDATE else rl.Color(255, 102, 0, 255) + rl.draw_rectangle_rounded(self.update_notif_rect, 0.3, 10, highlight_color) + + text = "UPDATE" + text_width = measure_text_cached(font, text, HEAD_BUTTON_FONT_SIZE).x + text_x = self.update_notif_rect.x + (self.update_notif_rect.width - text_width) // 2 + text_y = self.update_notif_rect.y + (self.update_notif_rect.height - HEAD_BUTTON_FONT_SIZE) // 2 + rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE) + + # Alert notification button + if self.alert_count > 0: + # Highlight if currently viewing alerts + highlight_color = rl.Color(255, 70, 70, 255) if self.current_state == HomeLayoutState.ALERTS else rl.Color(226, 44, 44, 255) + rl.draw_rectangle_rounded(self.alert_notif_rect, 0.3, 10, highlight_color) + + alert_text = f"{self.alert_count} ALERT{'S' if self.alert_count > 1 else ''}" + text_width = measure_text_cached(font, alert_text, HEAD_BUTTON_FONT_SIZE).x + text_x = self.alert_notif_rect.x + (self.alert_notif_rect.width - text_width) // 2 + text_y = self.alert_notif_rect.y + (self.alert_notif_rect.height - HEAD_BUTTON_FONT_SIZE) // 2 + rl.draw_text_ex(font, alert_text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE) + + # Version text (right aligned) + version_text = self._get_version_text() + text_width = measure_text_cached(gui_app.font(FontWeight.NORMAL), version_text, 48).x + version_x = self.header_rect.x + self.header_rect.width - text_width + version_y = self.header_rect.y + (self.header_rect.height - 48) // 2 + rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), version_text, rl.Vector2(int(version_x), int(version_y)), 48, 0, DEFAULT_TEXT_COLOR) + + def _render_home_content(self): + self._render_left_column() + self._render_right_column() + + def _render_update_view(self): + self.update_alert.render(self.content_rect) + + def _render_alerts_view(self): + self.offroad_alert.render(self.content_rect) + + def _render_left_column(self): + rl.draw_rectangle_rounded(self.left_column_rect, 0.02, 10, PRIME_BG_COLOR) + gui_label(self.left_column_rect, "Prime Widget", 48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + + def _render_right_column(self): + widget_height = (self.right_column_rect.height - SPACING) // 2 + + exp_rect = rl.Rectangle( + self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, widget_height + ) + rl.draw_rectangle_rounded(exp_rect, 0.02, 10, PRIME_BG_COLOR) + gui_label(exp_rect, "Experimental Mode", 36, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + + setup_rect = rl.Rectangle( + self.right_column_rect.x, + self.right_column_rect.y + widget_height + SPACING, + self.right_column_rect.width, + widget_height, + ) + rl.draw_rectangle_rounded(setup_rect, 0.02, 10, PRIME_BG_COLOR) + gui_label(setup_rect, "Setup", 36, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + + def _refresh(self): + self.update_available = self.update_alert.refresh() + self.alert_count = self.offroad_alert.refresh() + self._update_state_priority(self.update_available, self.alert_count > 0) + + def _update_state_priority(self, update_available: bool, alerts_present: bool): + current_state = self.current_state + + if not update_available and not alerts_present: + self.current_state = HomeLayoutState.HOME + elif update_available and (current_state == HomeLayoutState.HOME or (not alerts_present and current_state == HomeLayoutState.ALERTS)): + self.current_state = HomeLayoutState.UPDATE + elif alerts_present and (current_state == HomeLayoutState.HOME or (not update_available and current_state == HomeLayoutState.UPDATE)): + self.current_state = HomeLayoutState.ALERTS + + def _get_version_text(self) -> str: + brand = "openpilot" + description = self.params.get("UpdaterCurrentDescription", encoding='utf-8') + return f"{brand} {description}" if description else brand diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index 2096480f22..08ce50407d 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -35,6 +35,7 @@ class MainLayout: self._current_callback = None self._update_layout_rects(rect) + self._handle_onroad_transition() self._render_main_content() self._handle_input() @@ -47,7 +48,7 @@ class MainLayout: on_flag=lambda: setattr(self, '_current_callback', self._on_flag_clicked), ) self._layouts[MainState.SETTINGS].set_callbacks( - on_close=lambda: setattr(self, '_current_callback', self._on_settings_closed) + on_close=lambda: setattr(self, '_current_callback', self._set_mode_for_state) ) def _update_layout_rects(self, rect): @@ -57,14 +58,24 @@ class MainLayout: x_offset = SIDEBAR_WIDTH if self._sidebar_visible else 0 self._content_rect = rl.Rectangle(rect.y + x_offset, rect.y, rect.width - x_offset, rect.height) + def _handle_onroad_transition(self): + if ui_state.started != self._prev_onroad: + self._prev_onroad = ui_state.started + + self._set_mode_for_state() + + def _set_mode_for_state(self): + if ui_state.started: + self._current_mode = MainState.ONROAD + self._sidebar_visible = False + else: + self._current_mode = MainState.HOME + self._sidebar_visible = True + def _on_settings_clicked(self): self._current_mode = MainState.SETTINGS self._sidebar_visible = False - def _on_settings_closed(self): - self._current_mode = MainState.HOME if not ui_state.started else MainState.ONROAD - self._sidebar_visible = True - def _on_flag_clicked(self): pass @@ -73,13 +84,6 @@ class MainLayout: if self._sidebar_visible: self._sidebar.render(self._sidebar_rect) - if ui_state.started != self._prev_onroad: - self._prev_onroad = ui_state.started - if ui_state.started: - self._current_mode = MainState.ONROAD - else: - self._current_mode = MainState.HOME - content_rect = self._content_rect if self._sidebar_visible else self._window_rect self._layouts[self._current_mode].render(content_rect) diff --git a/selfdrive/ui/layouts/network.py b/selfdrive/ui/layouts/network.py new file mode 100644 index 0000000000..bf52f18876 --- /dev/null +++ b/selfdrive/ui/layouts/network.py @@ -0,0 +1,19 @@ +import pyray as rl +from openpilot.system.ui.lib.wifi_manager import WifiManagerWrapper +from openpilot.system.ui.widgets.network import WifiManagerUI + + +class NetworkLayout: + def __init__(self): + self.wifi_manager = WifiManagerWrapper() + self.wifi_ui = WifiManagerUI(self.wifi_manager) + + def render(self, rect: rl.Rectangle): + self.wifi_ui.render(rect) + + @property + def require_full_screen(self): + return self.wifi_ui.require_full_screen + + def shutdown(self): + self.wifi_manager.shutdown() diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py new file mode 100644 index 0000000000..6ab5be2792 --- /dev/null +++ b/selfdrive/ui/layouts/settings/developer.py @@ -0,0 +1,52 @@ +from openpilot.system.ui.lib.list_view import ListView, toggle_item +from openpilot.common.params import Params + +# Description constants +DESCRIPTIONS = { + 'enable_adb': ( + "ADB (Android Debug Bridge) allows connecting to your device over USB or over the network. " + + "See https://docs.comma.ai/how-to/connect-to-comma for more info." + ), + 'joystick_debug_mode': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)", +} + + +class DeveloperLayout: + def __init__(self): + self._params = Params() + items = [ + toggle_item( + "Enable ADB", + description=DESCRIPTIONS["enable_adb"], + initial_state=self._params.get_bool("AdbEnabled"), + callback=self._on_enable_adb, + ), + toggle_item( + "Joystick Debug Mode", + description=DESCRIPTIONS["joystick_debug_mode"], + initial_state=self._params.get_bool("JoystickDebugMode"), + callback=self._on_joystick_debug_mode, + ), + toggle_item( + "Longitudinal Maneuver Mode", + description="", + initial_state=self._params.get_bool("LongitudinalManeuverMode"), + callback=self._on_long_maneuver_mode, + ), + toggle_item( + "openpilot Longitudinal Control (Alpha)", + description="", + initial_state=self._params.get_bool("AlphaLongitudinalEnabled"), + callback=self._on_alpha_long_enabled, + ), + ] + + self._list_widget = ListView(items) + + def render(self, rect): + self._list_widget.render(rect) + + def _on_enable_adb(self): pass + def _on_joystick_debug_mode(self): pass + def _on_long_maneuver_mode(self): pass + def _on_alpha_long_enabled(self): pass diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py new file mode 100644 index 0000000000..5051dd6ba6 --- /dev/null +++ b/selfdrive/ui/layouts/settings/device.py @@ -0,0 +1,47 @@ +from openpilot.system.ui.lib.list_view import ListView, text_item, button_item +from openpilot.common.params import Params +from openpilot.system.hardware import TICI + +# Description constants +DESCRIPTIONS = { + 'pair_device': "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer.", + 'driver_camera': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)", + 'reset_calibration': ( + "openpilot requires the device to be mounted within 4° left or right and within 5° " + + "up or 9° down. openpilot is continuously calibrating, resetting is rarely required." + ), + 'review_guide': "Review the rules, features, and limitations of openpilot", +} + + +class DeviceLayout: + def __init__(self): + params = Params() + dongle_id = params.get("DongleId", encoding="utf-8") or "N/A" + serial = params.get("HardwareSerial") or "N/A" + + items = [ + text_item("Dongle ID", dongle_id), + text_item("Serial", serial), + button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], self._on_pair_device), + button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], self._on_driver_camera), + button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], self._on_reset_calibration), + button_item("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide), + ] + + if TICI: + items.append(button_item("Regulatory", "VIEW", callback=self._on_regulatory)) + + items.append(button_item("Change Language", "CHANGE", callback=self._on_change_language)) + + self._list_widget = ListView(items) + + def render(self, rect): + self._list_widget.render(rect) + + def _on_pair_device(self): pass + def _on_driver_camera(self): pass + def _on_reset_calibration(self): pass + def _on_review_training_guide(self): pass + def _on_regulatory(self): pass + def _on_change_language(self): pass diff --git a/selfdrive/ui/layouts/settings/settings.py b/selfdrive/ui/layouts/settings/settings.py index 6a9a50b855..1afa4f8f4a 100644 --- a/selfdrive/ui/layouts/settings/settings.py +++ b/selfdrive/ui/layouts/settings/settings.py @@ -3,8 +3,14 @@ from dataclasses import dataclass from enum import IntEnum from collections.abc import Callable from openpilot.common.params import Params +from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout +from openpilot.selfdrive.ui.layouts.settings.device import DeviceLayout +from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout +from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.label import gui_text_box +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.selfdrive.ui.layouts.network import NetworkLayout # Import individual panels @@ -46,18 +52,16 @@ class SettingsLayout: def __init__(self): self._params = Params() self._current_panel = PanelType.DEVICE - self._close_btn_pressed = False - self._scroll_offset = 0.0 self._max_scroll = 0.0 # Panel configuration self._panels = { - PanelType.DEVICE: PanelInfo("Device", None, rl.Rectangle(0, 0, 0, 0)), - PanelType.TOGGLES: PanelInfo("Toggles", None, rl.Rectangle(0, 0, 0, 0)), - PanelType.SOFTWARE: PanelInfo("Software", None, rl.Rectangle(0, 0, 0, 0)), + PanelType.DEVICE: PanelInfo("Device", DeviceLayout(), rl.Rectangle(0, 0, 0, 0)), + PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayout(), rl.Rectangle(0, 0, 0, 0)), + PanelType.SOFTWARE: PanelInfo("Software", SoftwareLayout(), rl.Rectangle(0, 0, 0, 0)), PanelType.FIREHOSE: PanelInfo("Firehose", None, rl.Rectangle(0, 0, 0, 0)), - PanelType.NETWORK: PanelInfo("Network", None, rl.Rectangle(0, 0, 0, 0)), - PanelType.DEVELOPER: PanelInfo("Developer", None, rl.Rectangle(0, 0, 0, 0)), + PanelType.NETWORK: PanelInfo("Network", NetworkLayout(), rl.Rectangle(0, 0, 0, 0)), + PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayout(), rl.Rectangle(0, 0, 0, 0)), } self._font_medium = gui_app.font(FontWeight.MEDIUM) @@ -89,12 +93,15 @@ class SettingsLayout: rect.x + (rect.width - CLOSE_BTN_SIZE) / 2, rect.y + 45, CLOSE_BTN_SIZE, CLOSE_BTN_SIZE ) - close_color = CLOSE_BTN_PRESSED if self._close_btn_pressed else CLOSE_BTN_COLOR - rl.draw_rectangle_rounded(close_btn_rect, 0.5, 20, close_color) - close_text_size = rl.measure_text_ex(self._font_bold, SETTINGS_CLOSE_TEXT, 140, 0) + pressed = (rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and + rl.check_collision_point_rec(rl.get_mouse_position(), close_btn_rect)) + close_color = CLOSE_BTN_PRESSED if pressed else CLOSE_BTN_COLOR + rl.draw_rectangle_rounded(close_btn_rect, 1.0, 20, close_color) + + close_text_size = measure_text_cached(self._font_bold, SETTINGS_CLOSE_TEXT, 140) close_text_pos = rl.Vector2( close_btn_rect.x + (close_btn_rect.width - close_text_size.x) / 2, - close_btn_rect.y + (close_btn_rect.height - close_text_size.y) / 2 - 20, + close_btn_rect.y + (close_btn_rect.height - close_text_size.y) / 2, ) rl.draw_text_ex(self._font_bold, SETTINGS_CLOSE_TEXT, close_text_pos, 140, 0, TEXT_SELECTED) @@ -117,9 +124,8 @@ class SettingsLayout: # Button styling is_selected = panel_type == self._current_panel text_color = TEXT_SELECTED if is_selected else TEXT_NORMAL - # Draw button text (right-aligned) - text_size = rl.measure_text_ex(self._font_medium, panel_info.name, 65, 0) + text_size = measure_text_cached(self._font_medium, panel_info.name, 65) text_pos = rl.Vector2( button_rect.x + button_rect.width - text_size.x, button_rect.y + (button_rect.height - text_size.y) / 2 ) @@ -130,21 +136,27 @@ class SettingsLayout: i += 1 def _draw_current_panel(self, rect: rl.Rectangle): - content_rect = rl.Rectangle(rect.x + PANEL_MARGIN, rect.y + 25, rect.width - (PANEL_MARGIN * 2), rect.height - 50) - rl.draw_rectangle_rounded(content_rect, 0.03, 30, PANEL_COLOR) - gui_text_box( - content_rect, - f"Demo {self._panels[self._current_panel].name} Panel", - font_size=170, - color=rl.WHITE, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + rl.draw_rectangle_rounded( + rl.Rectangle(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20), 0.04, 30, PANEL_COLOR ) + content_rect = rl.Rectangle(rect.x + PANEL_MARGIN, rect.y + 25, rect.width - (PANEL_MARGIN * 2), rect.height - 50) + # rl.draw_rectangle_rounded(content_rect, 0.03, 30, PANEL_COLOR) + panel = self._panels[self._current_panel] + if panel.instance: + panel.instance.render(content_rect) + else: + gui_text_box( + content_rect, + f"Demo {self._panels[self._current_panel].name} Panel", + font_size=170, + color=rl.WHITE, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + ) def handle_mouse_release(self, mouse_pos: rl.Vector2) -> bool: # Check close button if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect): - self._close_btn_pressed = True if self._close_callback: self._close_callback() return True @@ -160,9 +172,6 @@ class SettingsLayout: def _switch_to_panel(self, panel_type: PanelType): if panel_type != self._current_panel: self._current_panel = panel_type - self._scroll_offset = 0.0 # Reset scroll when switching panels - self._transition_progress = 0.0 - self._transitioning = True def set_current_panel(self, index: int, param: str = ""): panel_types = list(self._panels.keys()) diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py new file mode 100644 index 0000000000..e57e13c148 --- /dev/null +++ b/selfdrive/ui/layouts/settings/software.py @@ -0,0 +1,21 @@ +from openpilot.system.ui.lib.list_view import ListView, button_item, text_item + +class SoftwareLayout: + def __init__(self): + items = [ + text_item("Current Version", ""), + button_item("Download", "CHECK", callback=self._on_download_update), + button_item("Install Update", "INSTALL", callback=self._on_install_update), + button_item("Target Branch", "SELECT", callback=self._on_select_branch), + button_item("Uninstall", "UNINSTALL", callback=self._on_uninstall), + ] + + self._list_widget = ListView(items) + + def render(self, rect): + self._list_widget.render(rect) + + def _on_download_update(self): pass + def _on_install_update(self): pass + def _on_select_branch(self): pass + def _on_uninstall(self): pass diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py new file mode 100644 index 0000000000..d966baf9cd --- /dev/null +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -0,0 +1,68 @@ +from openpilot.system.ui.lib.list_view import ListView, toggle_item +from openpilot.common.params import Params + +# Description constants +DESCRIPTIONS = { + "OpenpilotEnabledToggle": ( + "Use the openpilot system for adaptive cruise control and lane keep driver assistance. " + + "Your attention is required at all times to use this feature." + ), + "DisengageOnAccelerator": "When enabled, pressing the accelerator pedal will disengage openpilot.", + "IsLdwEnabled": ( + "Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " + + "without a turn signal activated while driving over 31 mph (50 km/h)." + ), + "AlwaysOnDM": "Enable driver monitoring even when openpilot is not engaged.", + 'RecordFront': "Upload data from the driver facing camera and help improve the driver monitoring algorithm.", + "IsMetric": "Display speed in km/h instead of mph.", +} + + +class TogglesLayout: + def __init__(self): + self._params = Params() + items = [ + toggle_item( + "Enable openpilot", + DESCRIPTIONS["OpenpilotEnabledToggle"], + self._params.get_bool("OpenpilotEnabledToggle"), + icon="chffr_wheel.png", + ), + toggle_item( + "Experimental Mode", + initial_state=self._params.get_bool("ExperimentalMode"), + icon="experimental_white.png", + ), + toggle_item( + "Disengage on Accelerator Pedal", + DESCRIPTIONS["DisengageOnAccelerator"], + self._params.get_bool("DisengageOnAccelerator"), + icon="disengage_on_accelerator.png", + ), + toggle_item( + "Enable Lane Departure Warnings", + DESCRIPTIONS["IsLdwEnabled"], + self._params.get_bool("IsLdwEnabled"), + icon="warning.png", + ), + toggle_item( + "Always-On Driver Monitoring", + DESCRIPTIONS["AlwaysOnDM"], + self._params.get_bool("AlwaysOnDM"), + icon="monitoring.png", + ), + toggle_item( + "Record and Upload Driver Camera", + DESCRIPTIONS["RecordFront"], + self._params.get_bool("RecordFront"), + icon="monitoring.png", + ), + toggle_item( + "Use Metric System", DESCRIPTIONS["IsMetric"], self._params.get_bool("IsMetric"), icon="monitoring.png" + ), + ] + + self._list_widget = ListView(items) + + def render(self, rect): + self._list_widget.render(rect) diff --git a/selfdrive/ui/layouts/sidebar.py b/selfdrive/ui/layouts/sidebar.py index eeb073e67b..343efe5851 100644 --- a/selfdrive/ui/layouts/sidebar.py +++ b/selfdrive/ui/layouts/sidebar.py @@ -5,6 +5,7 @@ from collections.abc import Callable from cereal import log from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached SIDEBAR_WIDTH = 300 METRIC_HEIGHT = 126 @@ -199,7 +200,7 @@ class Sidebar: # Draw text text = f"{metric.label}\n{metric.value}" - text_size = rl.measure_text_ex(self._font_bold, text, 35, 0) + text_size = measure_text_cached(self._font_bold, text, 35) text_pos = rl.Vector2( metric_rect.x + 22 + (metric_rect.width - 22 - text_size.x) / 2, metric_rect.y + (metric_rect.height - text_size.y) / 2 diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index fa7469d2dd..47769f268e 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -16,6 +16,8 @@ from openpilot.common.transformations.orientation import rot_from_euler OpState = log.SelfdriveState.OpenpilotState CALIBRATED = log.LiveCalibrationData.Status.calibrated +ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD +WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"] BORDER_COLORS = { @@ -28,6 +30,7 @@ BORDER_COLORS = { class AugmentedRoadView(CameraView): def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD): super().__init__("camerad", stream_type) + self._set_placeholder_color(BORDER_COLORS[UIStatus.DISENGAGED]) self.device_camera: DeviceCameraConfig | None = None self.view_from_calib = view_frame_from_device_frame.copy() @@ -35,6 +38,7 @@ class AugmentedRoadView(CameraView): self._last_calib_time: float = 0 self._last_rect_dims = (0.0, 0.0) + self._last_stream_type = stream_type self._cached_matrix: np.ndarray | None = None self._content_rect = rl.Rectangle() @@ -119,6 +123,7 @@ class AugmentedRoadView(CameraView): current_dims = (self._content_rect.width, self._content_rect.height) if (self._last_calib_time == calib_time and self._last_rect_dims == current_dims and + self._last_stream_type == self.stream_type and self._cached_matrix is not None): return self._cached_matrix @@ -154,9 +159,10 @@ class AugmentedRoadView(CameraView): except (ZeroDivisionError, OverflowError): x_offset, y_offset = 0, 0 - # Update cache values + # Cache the computed transformation matrix to avoid recalculations self._last_calib_time = calib_time self._last_rect_dims = current_dims + self._last_stream_type = self.stream_type self._cached_matrix = np.array([ [zoom * 2 * cx / w, 0, -x_offset / w * 2], [0, zoom * 2 * cy / h, -y_offset / h * 2], @@ -175,14 +181,15 @@ class AugmentedRoadView(CameraView): if __name__ == "__main__": gui_app.init_window("OnRoad Camera View") - road_camera_view = AugmentedRoadView(VisionStreamType.VISION_STREAM_ROAD) + road_camera_view = AugmentedRoadView(ROAD_CAM) print("***press space to switch camera view***") try: for _ in gui_app.render(): ui_state.update() if rl.is_key_released(rl.KeyboardKey.KEY_SPACE): - is_wide = road_camera_view.stream_type == VisionStreamType.VISION_STREAM_WIDE_ROAD - road_camera_view.switch_stream(VisionStreamType.VISION_STREAM_ROAD if is_wide else VisionStreamType.VISION_STREAM_WIDE_ROAD) + if WIDE_CAM in road_camera_view.available_streams: + stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM + road_camera_view.switch_stream(stream) road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) finally: road_camera_view.close() diff --git a/selfdrive/ui/onroad/cameraview.py b/selfdrive/ui/onroad/cameraview.py index d59e7b0a48..9f58133abf 100644 --- a/selfdrive/ui/onroad/cameraview.py +++ b/selfdrive/ui/onroad/cameraview.py @@ -57,9 +57,17 @@ else: class CameraView: def __init__(self, name: str, stream_type: VisionStreamType): - self.client = VisionIpcClient(name, stream_type, conflate=True) self._name = name + # Primary stream + self.client = VisionIpcClient(name, stream_type, conflate=True) self._stream_type = stream_type + self.available_streams: list[VisionStreamType] = [] + + # Target stream for switching + self._target_client: VisionIpcClient | None = None + self._target_stream_type: VisionStreamType | None = None + self._switching: bool = False + self._texture_needs_update = True self.last_connection_attempt: float = 0.0 @@ -74,6 +82,8 @@ class CameraView: self.egl_images: dict[int, EGLImage] = {} self.egl_texture: rl.Texture | None = None + self._placeholder_color : rl.Color | None = None + # Initialize EGL for zero-copy rendering on TICI if TICI: if not init_egl(): @@ -84,13 +94,25 @@ class CameraView: self.egl_texture = rl.load_texture_from_image(temp_image) rl.unload_image(temp_image) + def _set_placeholder_color(self, color: rl.Color): + """Set a placeholder color to be drawn when no frame is available.""" + self._placeholder_color = color + def switch_stream(self, stream_type: VisionStreamType) -> None: - if self._stream_type != stream_type: - cloudlog.debug(f'switching stream from {self._stream_type} to {stream_type}') - self._clear_textures() - self.frame = None - self._stream_type = stream_type - self.client = VisionIpcClient(self._name, stream_type, conflate=True) + if self._stream_type == stream_type: + return + + if self._switching and self._target_stream_type == stream_type: + return + + cloudlog.debug(f'Preparing switch from {self._stream_type} to {stream_type}') + + if self._target_client: + del self._target_client + + self._target_stream_type = stream_type + self._target_client = VisionIpcClient(self._name, stream_type, conflate=True) + self._switching = True @property def stream_type(self) -> VisionStreamType: @@ -129,7 +151,11 @@ class CameraView: ]) def render(self, rect: rl.Rectangle): + if self._switching: + self._handle_switch() + if not self._ensure_connection(): + self._draw_placeholder(rect) return # Try to get a new buffer without blocking @@ -139,6 +165,7 @@ class CameraView: self.frame = buffer if not self.frame: + self._draw_placeholder(rect) return transform = self._calc_frame_matrix(rect) @@ -163,6 +190,10 @@ class CameraView: else: self._render_textures(src_rect, dst_rect) + def _draw_placeholder(self, rect: rl.Rectangle): + if self._placeholder_color: + rl.draw_rectangle_rec(rect, self._placeholder_color) + def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None: """Render using EGL for direct buffer access""" if self.frame is None or self.egl_texture is None: @@ -214,6 +245,7 @@ class CameraView: def _ensure_connection(self) -> bool: if not self.client.is_connected(): self.frame = None + self.available_streams.clear() # Throttle connection attempts current_time = rl.get_time() @@ -225,16 +257,57 @@ class CameraView: return False cloudlog.debug(f"Connected to {self._name} stream: {self._stream_type}, buffers: {self.client.num_buffers}") - self._clear_textures() + self._initialize_textures() + self.available_streams = self.client.available_streams(self._name, block=False) + return True + + def _handle_switch(self) -> None: + """Check if target stream is ready and switch immediately.""" + if not self._target_client or not self._switching: + return + + # Try to connect target if needed + if not self._target_client.is_connected(): + if not self._target_client.connect(False) or not self._target_client.num_buffers: + return + + cloudlog.debug(f"Target stream connected: {self._target_stream_type}") + + # Check if target has frames ready + target_frame = self._target_client.recv(timeout_ms=0) + if target_frame: + self.frame = target_frame # Update current frame to target frame + self._complete_switch() + + def _complete_switch(self) -> None: + """Instantly switch to target stream.""" + cloudlog.debug(f"Switching to {self._target_stream_type}") + # Clean up current resources + if self.client: + del self.client + + # Switch to target + self.client = self._target_client + self._stream_type = self._target_stream_type + self._texture_needs_update = True + + # Reset state + self._target_client = None + self._target_stream_type = None + self._switching = False + + # Initialize textures for new stream + self._initialize_textures() + + def _initialize_textures(self): + self._clear_textures() if not TICI: self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) - return True - def _clear_textures(self): if self.texture_y and self.texture_y.id: rl.unload_texture(self.texture_y) diff --git a/selfdrive/ui/onroad/model_renderer.py b/selfdrive/ui/onroad/model_renderer.py index 38951fb504..ad037f8af3 100644 --- a/selfdrive/ui/onroad/model_renderer.py +++ b/selfdrive/ui/onroad/model_renderer.py @@ -7,6 +7,7 @@ from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import DEFAULT_FPS from openpilot.system.ui.lib.shader_polygon import draw_polygon +from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT CLIP_MARGIN = 500 @@ -51,7 +52,7 @@ class ModelRenderer: self._lane_line_probs = np.zeros(4, dtype=np.float32) self._road_edge_stds = np.zeros(2, dtype=np.float32) self._lead_vehicles = [LeadVehicle(), LeadVehicle()] - self._path_offset_z = 1.22 + self._path_offset_z = HEIGHT_INIT[0] # Initialize ModelPoints objects self._path = ModelPoints() @@ -99,7 +100,10 @@ class ModelRenderer: # Update state self._experimental_mode = sm['selfdriveState'].experimentalMode - self._path_offset_z = sm['liveCalibration'].height[0] + + live_calib = sm['liveCalibration'] + self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0] + if sm.updated['carParams']: self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl diff --git a/selfdrive/ui/qt/offroad/offroad_home.cc b/selfdrive/ui/qt/offroad/offroad_home.cc index 6fcb6a71ae..9ca41e5567 100644 --- a/selfdrive/ui/qt/offroad/offroad_home.cc +++ b/selfdrive/ui/qt/offroad/offroad_home.cc @@ -18,7 +18,7 @@ OffroadHome::OffroadHome(QWidget* parent) : QFrame(parent) { main_layout->setContentsMargins(40, 40, 40, 40); // top header - QHBoxLayout* header_layout = new QHBoxLayout(); + header_layout = new QHBoxLayout(); header_layout->setContentsMargins(0, 0, 0, 0); header_layout->setSpacing(16); diff --git a/selfdrive/ui/qt/offroad/offroad_home.h b/selfdrive/ui/qt/offroad/offroad_home.h index 40b63ce8d8..cac37d58cd 100644 --- a/selfdrive/ui/qt/offroad/offroad_home.h +++ b/selfdrive/ui/qt/offroad/offroad_home.h @@ -39,11 +39,13 @@ public: protected: QHBoxLayout *home_layout; + QHBoxLayout *header_layout; + + void showEvent(QShowEvent *event) override; + void refresh(); private: - void showEvent(QShowEvent *event) override; void hideEvent(QHideEvent *event) override; - void refresh(); Params params; diff --git a/selfdrive/ui/sunnypilot/SConscript b/selfdrive/ui/sunnypilot/SConscript index 796107da77..5d67f4157d 100644 --- a/selfdrive/ui/sunnypilot/SConscript +++ b/selfdrive/ui/sunnypilot/SConscript @@ -19,12 +19,15 @@ qt_src = [ "sunnypilot/qt/sidebar.cc", "sunnypilot/qt/window.cc", "sunnypilot/qt/home.cc", + "sunnypilot/qt/offroad/exit_offroad_button.cc", "sunnypilot/qt/offroad/offroad_home.cc", "sunnypilot/qt/offroad/settings/device_panel.cc", "sunnypilot/qt/offroad/settings/lateral_panel.cc", "sunnypilot/qt/offroad/settings/longitudinal_panel.cc", "sunnypilot/qt/offroad/settings/max_time_offroad.cc", + "sunnypilot/qt/offroad/settings/brightness.cc", "sunnypilot/qt/offroad/settings/models_panel.cc", + "sunnypilot/qt/offroad/settings/osm_panel.cc", "sunnypilot/qt/offroad/settings/settings.cc", "sunnypilot/qt/offroad/settings/software_panel.cc", "sunnypilot/qt/offroad/settings/sunnylink_panel.cc", @@ -53,6 +56,10 @@ network_src = [ "sunnypilot/qt/network/sunnylink/services/user_service.cc", ] +osm_panel_qt_src = [ + "sunnypilot/qt/offroad/settings/osm/models_fetcher.cc", +] + vehicle_panel_qt_src = [ "sunnypilot/qt/offroad/settings/vehicle/brand_settings_factory.cc", "sunnypilot/qt/offroad/settings/vehicle/brand_settings_interface.cc", @@ -74,8 +81,9 @@ brand_settings_qt_src = [ "sunnypilot/qt/offroad/settings/vehicle/volkswagen_settings.cc", ] + sp_widgets_src = widgets_src + network_src -sp_qt_src = qt_src + lateral_panel_qt_src + vehicle_panel_qt_src + brand_settings_qt_src +sp_qt_src = qt_src + lateral_panel_qt_src + vehicle_panel_qt_src + brand_settings_qt_src + osm_panel_qt_src sp_qt_util = qt_util Export('sp_widgets_src', 'sp_qt_src', "sp_qt_util") diff --git a/selfdrive/ui/sunnypilot/qt/common/json_fetcher.h b/selfdrive/ui/sunnypilot/qt/common/json_fetcher.h new file mode 100644 index 0000000000..5691f600a6 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/common/json_fetcher.h @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + * + * This file is part of sunnypilot and is licensed under the MIT License. + * See the LICENSE.md file in the root directory for more details. + */ + +#pragma once + +#include +#include +#include +#include + +class JsonFetcher { +public: + static QJsonObject getJsonFromURL(const QString &url) { + const auto qurl = QUrl(url); + QNetworkAccessManager manager; + const QNetworkRequest request(qurl); + QNetworkReply *reply = manager.get(request); + QEventLoop loop; + + // Send GET request + + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "Failed to fetch data from URL: " << reply->errorString(); + return QJsonObject(); + } + + const QByteArray responseData = reply->readAll(); + const QJsonDocument doc = QJsonDocument::fromJson(responseData); + QJsonObject json = doc.object(); + + reply->deleteLater(); + return json; + } +}; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/exit_offroad_button.cc b/selfdrive/ui/sunnypilot/qt/offroad/exit_offroad_button.cc new file mode 100644 index 0000000000..0c7902476a --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/exit_offroad_button.cc @@ -0,0 +1,100 @@ +#include +#include +#include +#include +#include + +#include "selfdrive/ui/ui.h" +#include "selfdrive/ui/qt/widgets/input.h" +#include "selfdrive/ui/sunnypilot/qt/offroad/exit_offroad_button.h" + + +ExitOffroadButton::ExitOffroadButton(QWidget *parent) : QPushButton(parent), glowTimer(new QTimer(this)) { + setMouseTracking(true); + + connect(glowTimer, &QTimer::timeout, this, [this]() { + // Pulse alpha up and down + glowAlpha += glowDelta; + if (glowAlpha > 220 || glowAlpha < 10) { + glowDelta *= -1; + } + update(); // trigger repaint + }); + + glowTimer->start(45); + + pixmap = QPixmap("../../sunnypilot/selfdrive/assets/offroad/icon_exit_offroad.png").scaledToWidth(img_width, Qt::SmoothTransformation); + + // go to toggles and expand experimental mode description + connect(this, &QPushButton::clicked, [=]() { + if (ConfirmationDialog::confirm(tr("Are you sure you want to exit Always Offroad mode?"), tr("Confirm"), this)) { + params.remove("OffroadMode"); + } + }); + + setFixedHeight(125); + QHBoxLayout *main_layout = new QHBoxLayout; + main_layout->setContentsMargins(horizontal_padding, 0, horizontal_padding, 0); + + mode_label = new QLabel(tr("EXIT ALWAYS OFFROAD MODE")); + mode_icon = new QLabel; + mode_icon->setSizePolicy(QSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed)); + mode_icon->setPixmap(pixmap); + + main_layout->addWidget(mode_label, 1, Qt::AlignLeft); + main_layout->addWidget(mode_icon, 0, Qt::AlignRight); + + setLayout(main_layout); + + setStyleSheet(R"( + QPushButton { + border: none; + } + + QLabel { + font-size: 45px; + font-weight: 300; + text-align: left; + font-family: JetBrainsMono; + color: #000000; + } + )"); +} + +void drawPulsingGlowOverlay(QPainter &p, QPainterPath path, int glowAlpha) { + // Draw pulsing glow effect clipped to button area + p.save(); + p.setClipPath(path); + p.setCompositionMode(QPainter::CompositionMode_HardLight); + + const QColor animatedGlowColor(255, 255, 255, std::min(255, glowAlpha)); + QPen glowPen(animatedGlowColor, 8); + glowPen.setJoinStyle(Qt::RoundJoin); + p.setPen(glowPen); + p.drawPath(path); + + p.restore(); +} + +void ExitOffroadButton::paintEvent(QPaintEvent *event) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + QPainterPath path; + path.addRoundedRect(rect(), 10, 10); + + // gradient + bool pressed = isDown(); + QLinearGradient gradient(rect().left(), 0, rect().right(), 0); + gradient.setColorAt(0, QColor(35, 149, 255, pressed ? 0xcc : 0xff)); + gradient.setColorAt(0.3, QColor(35, 149, 255, pressed ? 0xcc : 0xff)); + gradient.setColorAt(1, QColor(20, 255, 171, pressed ? 0xcc : 0xff)); + p.fillPath(path, gradient); + + drawPulsingGlowOverlay(p, path, glowAlpha); + + // vertical line + p.setPen(QPen(QColor(0, 0, 0, 0x4d), 3, Qt::SolidLine)); + int line_x = rect().right() - img_width - (2 * horizontal_padding); + p.drawLine(line_x, rect().bottom(), line_x, rect().top()); +} diff --git a/selfdrive/ui/sunnypilot/qt/offroad/exit_offroad_button.h b/selfdrive/ui/sunnypilot/qt/offroad/exit_offroad_button.h new file mode 100644 index 0000000000..673d9fff73 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/exit_offroad_button.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +#include "common/params.h" + +class ExitOffroadButton : public QPushButton { + Q_OBJECT + +private: + QTimer *glowTimer; + int glowAlpha = 100; // Current alpha of glow + int glowDelta = 10; // Change per tick + +public: + explicit ExitOffroadButton(QWidget* parent = 0); + + Params params; + bool offroad_mode; + int img_width = 100; + int horizontal_padding = 30; + QPixmap pixmap; + QLabel *mode_label; + QLabel *mode_icon; + +protected: + void paintEvent(QPaintEvent *event) override; +}; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/offroad_home.cc b/selfdrive/ui/sunnypilot/qt/offroad/offroad_home.cc index 05b1d80846..cc82e6ddb9 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/offroad_home.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/offroad_home.cc @@ -5,16 +5,45 @@ * See the LICENSE.md file in the root directory for more details. */ -#include "selfdrive/ui/sunnypilot/qt/offroad/offroad_home.h" - #include +#include "selfdrive/ui/sunnypilot/qt/offroad/offroad_home.h" #include "selfdrive/ui/sunnypilot/qt/widgets/drive_stats.h" OffroadHomeSP::OffroadHomeSP(QWidget *parent) : OffroadHome(parent) { - QStackedWidget *left_widget = new QStackedWidget(this); - left_widget->addWidget(new DriveStats(this)); + QFrame *left_widget = new QFrame(this); + QVBoxLayout *left_layout = new QVBoxLayout(left_widget); + left_layout->setContentsMargins(0, 0, 0, 0); + left_layout->setSpacing(30); + + btn_exit_offroad = new ExitOffroadButton(this); + QObject::connect(btn_exit_offroad, &ExitOffroadButton::clicked, [=]() { + refreshOffroadStatus(); + }); + left_layout->addWidget(btn_exit_offroad); + + left_layout->addWidget(new DriveStats(this)); left_widget->setStyleSheet("border-radius: 10px;"); home_layout->insertWidget(0, left_widget); + + offroad_notif = new QPushButton(tr("ALWAYS OFFROAD ACTIVE")); + offroad_notif->setVisible(false); + offroad_notif->setStyleSheet("background-color: #E22C2C;"); + header_layout->insertWidget(0, offroad_notif, 0, Qt::AlignHCenter | Qt::AlignLeft); + + QObject::connect(deviceSP(), &DeviceSP::displayPowerChanged, this, &OffroadHomeSP::refreshOffroadStatus); + +} + +void OffroadHomeSP::showEvent(QShowEvent *event) { + refreshOffroadStatus(); + OffroadHome::showEvent(event); +} + +void OffroadHomeSP::refreshOffroadStatus() { + bool is_offroad = params.getBool("OffroadMode"); + btn_exit_offroad->setVisible(is_offroad); + offroad_notif->setVisible(is_offroad); + OffroadHome::refresh(); } diff --git a/selfdrive/ui/sunnypilot/qt/offroad/offroad_home.h b/selfdrive/ui/sunnypilot/qt/offroad/offroad_home.h index 9710150d57..30816f666c 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/offroad_home.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/offroad_home.h @@ -8,10 +8,19 @@ #pragma once #include "selfdrive/ui/qt/offroad/offroad_home.h" +#include "selfdrive/ui/sunnypilot/qt/offroad/exit_offroad_button.h" class OffroadHomeSP : public OffroadHome { Q_OBJECT public: explicit OffroadHomeSP(QWidget *parent = 0); + +private: + ExitOffroadButton *btn_exit_offroad; + QPushButton *offroad_notif; + Params params; + + void showEvent(QShowEvent *event) override; + void refreshOffroadStatus(); }; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/brightness.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/brightness.cc new file mode 100644 index 0000000000..ffd3ce1d8b --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/brightness.cc @@ -0,0 +1,50 @@ +/** + * 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. + */ + +#include "selfdrive/ui/sunnypilot/qt/offroad/settings/brightness.h" + +// Map of Brightness Options +const QMap Brightness::brightness_options = { + {"0", "1"}, // Auto (Dark) + {"1", "0"}, // Auto + {"2", "10"}, + {"3", "20"}, + {"4", "30"}, + {"5", "40"}, + {"6", "50"}, + {"7", "60"}, + {"8", "70"}, + {"9", "80"}, + {"10", "90"}, + {"11", "100"} +}; + +Brightness::Brightness() : OptionControlSP( + "Brightness", + tr("Brightness"), + tr("Overrides the brightness of the device."), + "../assets/offroad/icon_blank.png", + {0, 11}, 1, true, &brightness_options) { + + refresh(); +} + +void Brightness::refresh() { + const int brightness = QString::fromStdString(params.get("Brightness")).toInt(); + + QString label; + if (brightness == 1) { + label = tr("Auto (Dark)"); + } else if (brightness == 0) { + label = tr("Auto"); + } else { + const int value = brightness; + label = QString("%1").arg(value); + } + + setLabel(label); +} diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/brightness.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/brightness.h new file mode 100644 index 0000000000..20d0a291d9 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/brightness.h @@ -0,0 +1,25 @@ +/** + * 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. + */ + +#pragma once + +#include "selfdrive/ui/sunnypilot/ui.h" +#include "selfdrive/ui/sunnypilot/qt/offroad/settings/settings.h" +#include "selfdrive/ui/sunnypilot/qt/widgets/controls.h" + +class Brightness : public OptionControlSP { + Q_OBJECT + +public: + static const QMap brightness_options; + + Brightness(); + void refresh(); + +private: + Params params; +}; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/device_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/device_panel.cc index e6855c4b4e..b89116d424 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/device_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/device_panel.cc @@ -80,6 +80,19 @@ DevicePanelSP::DevicePanelSP(SettingsWindowSP *parent) : DevicePanel(parent) { connect(maxTimeOffroad, &OptionControlSP::updateLabels, maxTimeOffroad, &MaxTimeOffroad::refresh); addItem(maxTimeOffroad); + toggleDeviceBootMode = new ButtonParamControlSP("DeviceBootMode", tr("Wake-Up Behavior"), "", "", {"Default", "Offroad"}, 375, true); + addItem(toggleDeviceBootMode); + + connect(toggleDeviceBootMode, &ButtonParamControlSP::buttonClicked, this, [=](int index) { + params.put("DeviceBootMode", QString::number(index).toStdString()); + updateState(); + }); + + // Brightness + brightness = new Brightness(); + connect(brightness, &OptionControlSP::updateLabels, brightness, &Brightness::refresh); + addItem(brightness); + addItem(device_grid_layout); // offroad mode and power buttons @@ -179,4 +192,10 @@ void DevicePanelSP::updateState() { bool offroad_mode_param = params.getBool("OffroadMode"); offroadBtn->setText(offroad_mode_param ? tr("Exit Always Offroad") : tr("Always Offroad")); offroadBtn->setStyleSheet(offroad_mode_param ? alwaysOffroadStyle : autoOffroadStyle); + + DeviceSleepModeStatus currStatus = DeviceSleepModeStatus::DEFAULT; + if (params.get("DeviceBootMode") == "1") { + currStatus = DeviceSleepModeStatus::OFFROAD; + } + toggleDeviceBootMode->setDescription(deviceSleepModeDescription(currStatus)); } diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/device_panel.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/device_panel.h index 7b7412a739..2cd3281d1f 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/device_panel.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/device_panel.h @@ -8,9 +8,15 @@ #pragma once #include "selfdrive/ui/sunnypilot/qt/offroad/settings/max_time_offroad.h" +#include "selfdrive/ui/sunnypilot/qt/offroad/settings/brightness.h" #include "selfdrive/ui/sunnypilot/qt/offroad/settings/settings.h" #include "selfdrive/ui/sunnypilot/qt/widgets/controls.h" +enum class DeviceSleepModeStatus { + DEFAULT, + OFFROAD, +}; + class DevicePanelSP : public DevicePanel { Q_OBJECT @@ -25,6 +31,8 @@ private: std::map buttons; PushButtonSP *offroadBtn; MaxTimeOffroad *maxTimeOffroad; + ButtonParamControlSP *toggleDeviceBootMode; + Brightness *brightness; const QString alwaysOffroadStyle = R"( PushButtonSP { @@ -85,4 +93,20 @@ private: background-color: #FF2424; } )"; + + static QString deviceSleepModeDescription(DeviceSleepModeStatus status = DeviceSleepModeStatus::DEFAULT) { + QString def_str = tr("⁍ Default: Device will boot/wake-up normally & will be ready to engage."); + QString offrd_str = tr("⁍ Offroad: Device will be in Always Offroad mode after boot/wake-up."); + + if (status == DeviceSleepModeStatus::DEFAULT) { + def_str = "" + def_str + ""; + } else if (status == DeviceSleepModeStatus::OFFROAD) { + offrd_str = "" + offrd_str + ""; + } + + return QString("%1

%2
%3") + .arg(tr("Controls state of the device after boot/sleep.")) + .arg(def_str) + .arg(offrd_str); + } }; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc index d33cb550ce..44a6bd3b48 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc @@ -31,6 +31,13 @@ ModelsPanel::ModelsPanel(QWidget *parent) : QWidget(parent) { }); connect(uiStateSP(), &UIStateSP::uiUpdate, this, &ModelsPanel::updateLabels); list->addItem(currentModelLblBtn); + + // LiveDelay toggle + list->addItem(new ParamControlSP("LagdToggle", + tr("Live Learning Steer Delay"), + tr("Enable this for the car to learn and adapt its steering response time. " + "Disable to use a fixed steering response time. Keeping this on provides the stock openpilot experience."), + "../assets/offroad/icon_shell.png")); } @@ -141,7 +148,7 @@ void ModelsPanel::handleCurrentModelLblBtnClicked() { // Sort bundles by index in descending order QStringList bundleNames; // Add "Default" as the first option - bundleNames.append(tr("Use Default")); + bundleNames.append(DEFAULT_MODEL); auto indices = index_to_bundle.keys(); std::sort(indices.begin(), indices.end(), std::greater()); @@ -159,7 +166,7 @@ void ModelsPanel::handleCurrentModelLblBtnClicked() { } // Handle "Stock" selection differently - if (selectedBundleName == tr("Use Default")) { + if (selectedBundleName == DEFAULT_MODEL) { params.remove("ModelManager_ActiveBundle"); currentModelLblBtn->setValue(tr("Default")); showResetParamsDialog(); diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/osm/locations_fetcher.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/osm/locations_fetcher.h new file mode 100644 index 0000000000..32d169ad15 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/osm/locations_fetcher.h @@ -0,0 +1,63 @@ +/** + * 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. + */ + +#pragma once + +#include // for std::sort +#include +#include +#include + +#include +#include + +#include "selfdrive/ui/sunnypilot/qt/common/json_fetcher.h" + +static const std::tuple defaultLocation = std::make_tuple("== None ==", ""); + +// New class LocationsFetcher that handles web requests and JSON parsing +class LocationsFetcher { +public: + inline std::vector > + getLocationsFromURL(const QUrl &url, const std::tuple &customLocation = defaultLocation) const { + // Initialize an empty vector to hold the locations + std::vector > locations; + + JsonFetcher fetcher; + QJsonObject json = fetcher.getJsonFromURL(url.toString()); + + for (auto it = json.begin(); it != json.end(); ++it) { + QString code = it.key(); + QJsonObject obj = it.value().toObject(); + QString fullName = obj["full_name"].toString(); + + locations.push_back(std::make_tuple(fullName, code, QString(), QString())); + } + // Sort locations by full name + std::sort(locations.begin(), locations.end(), [](const auto &lhs, const auto &rhs) { + return std::get<0>(lhs) < std::get<0>(rhs); // Compare full names + }); + // Optionally, you can now add defaultName entry at the beginning + locations.insert(locations.begin(), std::tuple_cat(customLocation, std::make_tuple("", ""))); + return locations; + } + + inline std::vector > + getLocationsFromURL(const QString &url, const std::tuple &customLocation = defaultLocation) const { + return getLocationsFromURL(QUrl(url), customLocation); + } + + inline std::vector > + getOsmLocations(const std::tuple &customLocation = defaultLocation) const { + return getLocationsFromURL( "https://raw.githubusercontent.com/pfeiferj/openpilot-mapd/main/nation_bounding_boxes.json", customLocation); + } + + inline std::vector > + getUsStatesLocations(const std::tuple &customLocation = defaultLocation) const { + return getLocationsFromURL( "https://raw.githubusercontent.com/pfeiferj/openpilot-mapd/main/us_states_bounding_boxes.json", customLocation); + } +}; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/osm/models_fetcher.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/osm/models_fetcher.cc new file mode 100644 index 0000000000..b67e41e09d --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/osm/models_fetcher.cc @@ -0,0 +1,158 @@ +/** + * 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. + */ + +#include "selfdrive/ui/sunnypilot/qt/offroad/settings/osm/models_fetcher.h" + +#include + +ModelsFetcher::ModelsFetcher(QObject *parent) : QObject(parent) { + manager = new QNetworkAccessManager(this); +} + +QByteArray ModelsFetcher::verifyFileHash(const QString &filePath, const QString &expectedHash, bool &hashMatches) { + hashMatches = false; // Default to false + QByteArray fileData; + + if (expectedHash.isEmpty()) { + // If no hash is provided, assume verification isn't required but return the file data + hashMatches = true; + } else { + QFile file(filePath); + if (file.open(QIODevice::ReadOnly)) { + QCryptographicHash hash(QCryptographicHash::Sha256); // Or your chosen algorithm + fileData = file.readAll(); // Read the file data once + hash.addData(fileData); + file.close(); + + QString currentHash = QString(hash.result().toHex()); + hashMatches = (currentHash == expectedHash); + } + } + + // Return the file data if hash matches or no hash was provided; empty otherwise + return hashMatches ? fileData : QByteArray(); +} + + +void ModelsFetcher::download(const DownloadInfo &downloadInfo, const QString &filename, const QString &destinationPath) { + QString fullPath = destinationPath + "/" + filename; + QFileInfo fileInfo(fullPath); + bool hashMatches = false; + QByteArray data = verifyFileHash(fullPath, downloadInfo.sha256, hashMatches); + + if (fileInfo.exists() && hashMatches) { + // Hash matches or no hash provided, and we have the file data + LOGD("File already downloaded and verified: %s", filename.toStdString().c_str()); + emit downloadProgress(100); + emit downloadComplete(data, true); // Use the data returned from verifyFileHash + return; // Exit early + } + + // Proceed with download if file does not exist or hash verification failed + QNetworkRequest request(downloadInfo.url); + QNetworkReply *reply = manager->get(request); + connect(reply, &QNetworkReply::downloadProgress, this, &ModelsFetcher::onDownloadProgress); + connect(reply, &QNetworkReply::finished, this, [this, reply, destinationPath, filename, downloadInfo]() { + onFinished(reply, destinationPath, filename, downloadInfo.sha256); + }); +} + +QString extractFileName(const QString &contentDisposition) { + const QString filenameTag = "filename="; + const int idx = contentDisposition.indexOf(filenameTag); + if (idx < 0) { + return QString(); + } + + QString filename = contentDisposition.mid(idx + filenameTag.length()); + if (filename.startsWith("\"") && filename.endsWith("\"")) { + return filename.mid(1, filename.size() - 2); + } + + return filename; +} + +void ModelsFetcher::onFinished(QNetworkReply *reply, const QString &destinationPath, const QString &filename, const QString &expectedHash) { + // Handle download error + if (reply->error()) { + return; // Possibly emit a signal or log an error as per your error handling policy + } + + const QByteArray data = reply->readAll(); + + QString finalFilename = filename; + if (finalFilename.isEmpty()) { + finalFilename = extractFileName(reply->header(QNetworkRequest::ContentDispositionHeader).toString()); + } + + QString finalPath = QDir(destinationPath).filePath(finalFilename); + + // Save the downloaded file + QFile file(finalPath); + //ensure if the path exists and if not create it + if (!QDir().mkpath(destinationPath)) { + LOGE("Unable to create directory: %s", destinationPath.toStdString().c_str()); + emit downloadFailed(filename); + return; // Stop further processing + } + + //Retry the file open and write 3 times with a little delay between each retry + for (int i = 0; i < 3; i++) { + if (file.isOpen()) break; + + file.open(QIODevice::WriteOnly); + if (!file.isOpen()) QThread::msleep(100); + } + + // If the file is still not open, log an error and emit a failure signal + if (!file.isOpen()) { + LOGE("Unable to open file for writing: %s", finalPath.toStdString().c_str()); + emit downloadFailed(filename); + return; // Stop further processing + } + + file.write(data); + file.close(); + + bool hashMatches = false; + verifyFileHash(finalPath, expectedHash, hashMatches); + + // Verify the file hash if expectedHash is provided + if (!expectedHash.isEmpty() && !hashMatches) { + LOGE("The downloaded file didn't pass the hash validation!: %s", filename.toStdString().c_str()); + // Hash verification failed, handle accordingly + // This could involve deleting the file, logging an error, or emitting a failure signal + QFile::remove(finalPath); // Example action: Remove the invalid file + emit downloadFailed(filename); + return; // Stop further processing + } + + emit downloadComplete(data, false); // Emit your success signal +} + +void ModelsFetcher::onDownloadProgress(qint64 bytesReceived, qint64 bytesTotal) { + const double progress = (bytesReceived * 100.0) / bytesTotal; + emit downloadProgress(progress); +} + +std::vector ModelsFetcher::getModelsFromURL(const QUrl &url) { + std::vector models; + JsonFetcher fetcher; + QJsonObject json = fetcher.getJsonFromURL(url.toString()); + for (auto it = json.begin(); it != json.end(); ++it) { + models.push_back(Model(it.value().toObject())); + } + return models; +} + +std::vector ModelsFetcher::getModelsFromURL(const QString &url) { + return getModelsFromURL(QUrl(url)); +} + +std::vector ModelsFetcher::getModelsFromURL() { + return getModelsFromURL("https://docs.sunnypilot.ai/models_v5.json"); +} diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/osm/models_fetcher.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/osm/models_fetcher.h new file mode 100644 index 0000000000..73890e9d5a --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/osm/models_fetcher.h @@ -0,0 +1,143 @@ +/** + * 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. + */ + +#pragma once + +#include // for std::sort +#include +#include +#include + +#include +#include +#include + +#include "common/swaglog.h" +#include "common/util.h" +#include "selfdrive/ui/sunnypilot/ui.h" +#include "selfdrive/ui/qt/util.h" +#include "selfdrive/ui/sunnypilot/qt/common/json_fetcher.h" +#ifdef SUNNYPILOT +#include "selfdrive/ui/sunnypilot/qt/widgets/controls.h" +#else +#include "selfdrive/ui/qt/widgets/controls.h" +#endif +#include "system/hardware/hw.h" + +static const QString MODELS_PATH = Hardware::PC() ? QDir::homePath() + "/.comma/media/0/models/" : "/data/media/0/models/"; + +struct DownloadInfo { + QString url; + QString sha256; +}; + +// New class ModelsFetcher with a new function that handles web requests and JSON parsing for the new JSON structure +class Model { +public: + explicit Model(const QJsonObject &json) { + displayName = json["display_name"].toString(); + fullName = json["full_name"].toString(); + fileName = json["file_name"].toString(); + + // Parse downloadUri as an object + QJsonObject downloadUriObj = json["download_uri"].toObject(); + downloadUri.url = downloadUriObj["url"].toString(); + downloadUri.sha256 = downloadUriObj["sha256"].toString(); + + fullNameNav = json["full_name_nav"].toString(); + fileNameNav = json["file_name_nav"].toString(); + + // Parse downloadUriNav as an object + QJsonObject downloadUriNavObj = json["download_uri_nav"].toObject(); + downloadUriNav.url = downloadUriNavObj["url"].toString(); + downloadUriNav.sha256 = downloadUriNavObj["sha256"].toString(); + + fullNameMetadata = json["full_name_metadata"].toString(); + fileNameMetadata = json["file_name_metadata"].toString(); + + // Parse downloadUriMetadata as an object + QJsonObject downloadUriMetadataObj = json["download_uri_metadata"].toObject(); + downloadUriMetadata.url = downloadUriMetadataObj["url"].toString(); + downloadUriMetadata.sha256 = downloadUriMetadataObj["sha256"].toString(); + + index = json["index"].toString(); + environment = json["environment"].toString(); + generation = json["generation"].toString(); + } + + // Method to convert model back to QJsonObject, if needed + QJsonObject toJson() const { + QJsonObject json; + json["display_name"] = displayName; + json["full_name"] = fullName; + json["file_name"] = fileName; + + QJsonObject uriObj; + uriObj["url"] = downloadUri.url; + uriObj["sha256"] = downloadUri.sha256; + json["download_uri"] = uriObj; + + QJsonObject uriNavObj; + uriNavObj["url"] = downloadUriNav.url; + uriNavObj["sha256"] = downloadUriNav.sha256; + json["download_uri_nav"] = uriNavObj; + + QJsonObject uriMetadataObj; + uriMetadataObj["url"] = downloadUriMetadata.url; + uriMetadataObj["sha256"] = downloadUriMetadata.sha256; + json["download_uri_metadata"] = uriMetadataObj; + + json["full_name_nav"] = fullNameNav; + json["file_name_nav"] = fileNameNav; + json["full_name_metadata"] = fullNameMetadata; + json["file_name_metadata"] = fileNameMetadata; + json["index"] = index; + json["environment"] = environment; + json["generation"] = generation; + return json; + } + + QString displayName; + QString fullName; + QString fileName; + DownloadInfo downloadUri; + DownloadInfo downloadUriNav; + DownloadInfo downloadUriMetadata; + + QString fullNameNav; + QString fileNameNav; + QString fullNameMetadata; + QString fileNameMetadata; + QString index; + QString environment; + QString generation; +}; + +class ModelsFetcher : public QObject { + Q_OBJECT + +public: + explicit ModelsFetcher(QObject *parent = nullptr); + void download(const DownloadInfo &url, const QString &filename = "", const QString &destinationPath = MODELS_PATH); + static std::vector getModelsFromURL(const QUrl &url); + static std::vector getModelsFromURL(const QString &url); + static std::vector getModelsFromURL(); + +signals: + void downloadProgress(double percentage); + void downloadComplete(const QByteArray &data, bool fromCache = false); + void downloadFailed(const QString &filename); + +private: + // static bool verifyFileHash(const QString& filePath, const QString& expectedHash); + static QByteArray verifyFileHash(const QString &filePath, const QString &expectedHash, bool &hashMatches); + void onDownloadProgress(qint64 bytesReceived, qint64 bytesTotal); + void onFinished(QNetworkReply *reply, const QString &destinationPath, const QString &filename, + const QString &expectedHash); + + QNetworkAccessManager *manager; +}; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/osm_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/osm_panel.cc new file mode 100644 index 0000000000..332fd71a18 --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/osm_panel.cc @@ -0,0 +1,283 @@ +/** + * 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. + */ + +#include "selfdrive/ui/sunnypilot/qt/offroad/settings/osm_panel.h" + +#include +#include +#include + +#include "common/swaglog.h" +#include "selfdrive/ui/sunnypilot/qt/widgets/scrollview.h" + +OsmPanel::OsmPanel(QWidget *parent) : QFrame(parent) { + main_layout = new QStackedLayout(this); + + const auto list = new ListWidgetSP(this, false); + list->addItem(mapdVersion = new LabelControlSP(tr("Mapd Version"), "Loading...")); + list->addItem(setupOsmDeleteMapsButton(parent)); + list->addItem(offlineMapsETA = new LabelControlSP(tr("Offline Maps ETA"), "")); + list->addItem(offlineMapsElapsed = new LabelControlSP(tr("Time Elapsed"), "")); + list->addItem(setupOsmUpdateButton(parent)); + list->addItem(setupOsmDownloadButton(parent)); + list->addItem(setupUsStatesButton(parent)); + + connect(uiStateSP(), &UIStateSP::offroadTransition, [=](bool offroad) { + updateLabels(); + }); + + timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, QOverload<>::of(&OsmPanel::updateLabels)); + timer->start(FAST_REFRESH_INTERVAL); // Time specified in milliseconds. + updateLabels(); + + osmScreen = new QWidget(this); + auto *vlayout = new QVBoxLayout(osmScreen); + vlayout->setContentsMargins(50, 20, 50, 20); + vlayout->addWidget(new ScrollViewSP(list, this), 1); + main_layout->addWidget(osmScreen); +} + +ButtonControlSP *OsmPanel::setupOsmDeleteMapsButton(QWidget *parent) { + osmDeleteMapsBtn = new ButtonControlSP(tr("Downloaded Maps"), tr("DELETE")); // Updated on updateLabels() + connect(osmDeleteMapsBtn, &ButtonControlSP::clicked, [=]() { + if (showConfirmationDialog(parent, tr("This will delete ALL downloaded maps\n\nAre you sure you want to delete all the maps?"), tr("Yes, delete all the maps."))) { + QtConcurrent::run([=]() { + QDir dir(MAP_PATH); + osmDeleteMapsBtn->setEnabled(false); + osmDeleteMapsBtn->setText("⌛"); + dir.removeRecursively(); + updateMapSize(); + osmDeleteMapsBtn->setEnabled(true); + osmDeleteMapsBtn->setText(tr("DELETE")); + }); + updateLabels(); + } + }); + return osmDeleteMapsBtn; +} + +ButtonControlSP *OsmPanel::setupOsmUpdateButton(QWidget *parent) { + osmUpdateBtn = new ButtonControlSP(tr("Database Update"), tr("CHECK")); // Updated on updateLabels() + connect(osmUpdateBtn, &ButtonControlSP::clicked, [=]() { + if (osm_download_in_progress && !download_failed_state) { + updateLabels(); + } else if (showConfirmationDialog(parent)) { + osm_download_in_progress = true; + params.putBool("OsmDbUpdatesCheck", true); + updateLabels(); + } + }); + return osmUpdateBtn; +} + +ButtonControlSP *OsmPanel::setupOsmDownloadButton(QWidget *parent) { + osmDownloadBtn = new ButtonControlSP(tr("Country"), tr("SELECT")); + connect(osmDownloadBtn, &ButtonControlSP::clicked, [=]() { + osmDownloadBtn->setEnabled(false); + osmDownloadBtn->setValue(tr("Fetching Country list...")); + const std::vector > locations = getOsmLocations(); + osmDownloadBtn->setEnabled(true); + osmDownloadBtn->setValue(""); + const QString initTitle = QString::fromStdString(params.get("OsmLocationTitle")); + const QString currentTitle = ((initTitle == "== None ==") || (initTitle.length() == 0)) ? "== None ==" : initTitle; + + QStringList locationTitles; + for (auto &loc: locations) { + locationTitles.push_back(std::get<0>(loc)); + } + + const QString selection = MultiOptionDialog::getSelection(tr("Country"), locationTitles, currentTitle, this); + if (!selection.isEmpty()) { + params.put("OsmLocal", "1"); + params.put("OsmLocationTitle", selection.toStdString()); + for (auto &loc: locations) { + if (std::get<0>(loc) == selection) { + params.put("OsmLocationName", std::get<1>(loc).toStdString()); + break; + } + } + if (params.get("OsmLocationName") == "US") { + usStatesBtn->click(); + return; + } else if (selection != "== None ==") { + if (showConfirmationDialog(parent)) { + osm_download_in_progress = true; + params.putBool("OsmDbUpdatesCheck", true); + updateLabels(); + } + } + } + updateLabels(); + }); + return osmDownloadBtn; +} + +ButtonControlSP *OsmPanel::setupUsStatesButton(QWidget *parent) { + usStatesBtn = new ButtonControlSP(tr("State"), tr("SELECT")); + connect(usStatesBtn, &ButtonControlSP::clicked, [=]() { + const std::tuple allStatesOption = std::make_tuple("All States (~4.8 GB)", "All"); + usStatesBtn->setEnabled(false); + usStatesBtn->setValue(tr("Fetching State list...")); + const std::vector > locations = + getUsStatesLocations(allStatesOption); + usStatesBtn->setEnabled(true); + usStatesBtn->setValue(""); + const QString initTitle = QString::fromStdString(params.get("OsmStateTitle")); + const QString currentTitle = ((initTitle == std::get<0>(allStatesOption)) || (initTitle.length() == 0)) ? tr("All") : initTitle; + + QStringList locationTitles; + for (auto &loc: locations) { + locationTitles.push_back(std::get<0>(loc)); + } + + const QString selection = MultiOptionDialog::getSelection(tr("State"), locationTitles, currentTitle, this); + if (!selection.isEmpty()) { + params.put("OsmStateTitle", selection.toStdString()); + for (auto &loc: locations) { + if (std::get<0>(loc) == selection) { + params.put("OsmStateName", std::get<1>(loc).toStdString()); + break; + } + } + usStatesBtn->setValue(selection); + if (showConfirmationDialog(parent)) { + osm_download_in_progress = true; + params.putBool("OsmDbUpdatesCheck", true); + updateLabels(); + } + } + updateLabels(); + }); + usStatesBtn->setVisible(false); // initially hidden + return usStatesBtn; +} + +void OsmPanel::showEvent(QShowEvent *event) { + updateLabels(); // For snappier feeling + if (!timer->isActive()) { + timer->start(FAST_REFRESH_INTERVAL); + } +} + +void OsmPanel::hideEvent(QHideEvent *event) { + if (timer->isActive()) { + timer->stop(); + } +} + + +void OsmPanel::updateLabels() { + if (!isVisible()) { + return; + } + mapd_version = params.get("MapdVersion"); + mapdVersion->setText(mapd_version.c_str()); + + updateMapSize(); + osm_download_locations = mem_params.get("OSMDownloadLocations"); + osm_download_in_progress = !osm_download_locations.empty(); + + timer->setInterval(osm_download_in_progress ? FAST_REFRESH_INTERVAL : SLOW_REFRESH_INTERVAL); + LOGT("Timer Interval %d", timer->interval()); + + const std::string osmLastDownloadTimeStr = params.get("OsmDownloadedDate"); + if (!lastDownloadedTimePoint.has_value() && !osmLastDownloadTimeStr.empty()) { + const double osmLastDownloadTime = std::stod(osmLastDownloadTimeStr); + lastDownloadedTimePoint = std::chrono::system_clock::from_time_t(static_cast(osmLastDownloadTime)); + } + + osmDownloadBtn->setEnabled(!osm_download_in_progress); + usStatesBtn->setEnabled(!osm_download_in_progress); + + updateDownloadProgress(); + + const QString locationName = QString::fromStdString(params.get("OsmLocationName")); + const bool isUs = !locationName.isEmpty() && locationName == "US"; + usStatesBtn->setVisible(isUs); + + if (!locationName.isEmpty()) { + if (!isUs) { + params.remove("OsmStateName"); + params.remove("OsmStateTitle"); + } + osmUpdateBtn->setVisible(true); + } else { + params.remove("OsmLocal"); + params.remove("OsmLocationName"); + params.remove("OsmLocationTitle"); + params.remove("OsmStateName"); + params.remove("OsmStateTitle"); + osmUpdateBtn->setVisible(false); + usStatesBtn->setVisible(false); + } + + osmDownloadBtn->setValue(QString::fromStdString(params.get("OsmLocationTitle"))); + usStatesBtn->setValue(QString::fromStdString(params.get("OsmStateTitle"))); + update(); +} + +void OsmPanel::updateDownloadProgress() { + const auto pending_update_check = params.getBool("OsmDbUpdatesCheck"); + const QJsonObject osmDownloadProgress = QJsonDocument::fromJson(params.get("OSMDownloadProgress").c_str()).object(); + if (osm_download_in_progress && lastDownloadedTimePoint.has_value()) { + offlineMapsETA->setVisible(true); + offlineMapsElapsed->setVisible(true); + offlineMapsETA->setText(calculateETA(osmDownloadProgress, lastDownloadedTimePoint.value())); + offlineMapsElapsed->setText(calculateElapsedTime(osmDownloadProgress, lastDownloadedTimePoint.value())); + } else { + offlineMapsETA->setVisible(false); + offlineMapsElapsed->setVisible(false); + } + + const int total_files = extractIntFromJson(osmDownloadProgress, "total_files"); + const int downloaded_files = extractIntFromJson(osmDownloadProgress, "downloaded_files"); + download_failed_state = total_files && osm_download_in_progress && !lastDownloadedTimePoint.has_value() && downloaded_files < total_files; + + QString updateButtonText = processUpdateStatus(pending_update_check, total_files, downloaded_files, osmDownloadProgress, download_failed_state); + + osmUpdateBtn->setValue(updateButtonText); + osmUpdateBtn->setText(osm_download_in_progress && !download_failed_state ? tr("REFRESH") : tr("UPDATE")); + osmDeleteMapsBtn->setValue(formatSize(mapsDirSize)); +} + +int OsmPanel::extractIntFromJson(const QJsonObject &json, const QString &key) { + return (json.contains(key)) ? json[key].toInt() : 0; +} + +QString OsmPanel::processUpdateStatus(bool pending_update, int total_files, int downloaded_files, const QJsonObject &json, bool failed_state) { + if (pending_update && !osm_download_in_progress && !total_files) { + lastDownloadedTimePoint.reset(); + return tr("Download starting..."); + } else if (failed_state) { + return tr("Error: Invalid download. Retry."); + } else if (osm_download_in_progress && total_files > downloaded_files) { + return formatDownloadStatus(json); + } else if (osm_download_in_progress && downloaded_files >= total_files) { + osm_download_in_progress = false; + lastDownloadedTimePoint.reset(); + return tr("Download complete!"); + } + + if (lastDownloadedTimePoint.has_value()) { + QDateTime dateTime = QDateTime::fromTime_t(std::chrono::system_clock::to_time_t(lastDownloadedTimePoint.value())); //fromMSecsSinceEpoch(duration); + dateTime = dateTime.toLocalTime(); + return QString("%1").arg(dateTime.toString("yyyy-MM-dd HH:mm:ss")); + } + + return ""; +} + +void OsmPanel::updateMapSize() { + if (mapSizeFuture.has_value() && mapSizeFuture.value().isFinished()) { + mapsDirSize = mapSizeFuture.value().result(); + } + + if (!mapSizeFuture.has_value() || !mapSizeFuture.value().isRunning()) { + mapSizeFuture = QtConcurrent::run(getDirSize, MAP_PATH); + } +} diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/osm_panel.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/osm_panel.h new file mode 100644 index 0000000000..171129a11c --- /dev/null +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/osm_panel.h @@ -0,0 +1,232 @@ +/** + * 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. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "selfdrive/ui/qt/network/wifi_manager.h" +#include "selfdrive/ui/sunnypilot/qt/widgets/controls.h" +#include "selfdrive/ui/sunnypilot/qt/offroad/settings/osm/locations_fetcher.h" +#include "selfdrive/ui/qt/util.h" +#include "selfdrive/ui/sunnypilot/ui.h" +#include "system/hardware/hw.h" + +constexpr int FAST_REFRESH_INTERVAL = 1000; // ms +constexpr int SLOW_REFRESH_INTERVAL = 5000; // ms + +static const QString MAP_PATH = Hardware::PC() ? QDir::homePath() + "/.comma/media/0/osm/offline/" : "/data/media/0/osm/offline/"; + +class OsmPanel : public QFrame { + Q_OBJECT + +public: + explicit OsmPanel(QWidget *parent = nullptr); + +private: + QStackedLayout *main_layout = nullptr; + QWidget *osmScreen = nullptr; + Params params; + Params mem_params{Hardware::PC() ? "" : "/dev/shm/params"}; + std::map toggles; + std::optional > mapSizeFuture; + const SubMaster &sm = *uiStateSP()->sm; + + + bool is_onroad = false; + std::string mapd_version; + + bool isWifi() const { return sm["deviceState"].getDeviceState().getNetworkType() == cereal::DeviceState::NetworkType::WIFI; } + bool isMetered() const { return sm["deviceState"].getDeviceState().getNetworkMetered(); } + bool osm_download_in_progress = false; + bool download_failed_state = false; + quint64 mapsDirSize = 0; + + QLabel *osmUpdateLbl; + ButtonControlSP *osmDownloadBtn; + ButtonControlSP *osmUpdateBtn; + ButtonControlSP *usStatesBtn; + ButtonControlSP *osmDeleteMapsBtn; + + ButtonControlSP *setupOsmDeleteMapsButton(QWidget *parent);; + ButtonControlSP *setupOsmUpdateButton(QWidget *parent); + ButtonControlSP *setupOsmDownloadButton(QWidget *parent); + ButtonControlSP *setupUsStatesButton(QWidget *parent); + + QTimer *timer; + std::string osm_download_locations; + // void updateButtonControlSP(ButtonControlSP *btnControl, QWidget *parent, const QString &initTitle, const QString &allStatesOption); + + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + void updateLabels(); + void updateDownloadProgress(); + static int extractIntFromJson(const QJsonObject &json, const QString &key); + QString processUpdateStatus(bool pending_update_check, int total_files, int downloaded_files, const QJsonObject &json, bool failed_state); + + ConfirmationDialog *confirmationDialog; + LabelControlSP *mapdVersion; + LabelControlSP *offlineMapsStatus; + LabelControlSP *offlineMapsETA; + LabelControlSP *offlineMapsElapsed; + std::optional lastDownloadedTimePoint; + LocationsFetcher locationsFetcher; + + void updateMapSize(); + + bool showConfirmationDialog(QWidget *parent, + const QString &message = QString(), + const QString &confirmButtonText = QString()) const { + const auto _is_metered = isMetered(); + const QString warning_message = _is_metered ? tr("\n\nWarning: You are on a metered connection!") : QString(); + QString final_message = message.isEmpty() ? tr("This will start the download process and it might take a while to complete.") : message; + final_message += warning_message; // Append the warning message if the connection is metered + + const QString final_buttonText = confirmButtonText.isEmpty() ? (_is_metered ? tr("Continue on Metered") : tr("Start Download")) : confirmButtonText; + + return ConfirmationDialog::confirm(final_message, final_buttonText, parent); + } + + // Refactored methods + std::vector > getOsmLocations(const std::tuple &customLocation = defaultLocation) const { + return locationsFetcher.getOsmLocations(customLocation); + } + + std::vector > getUsStatesLocations(const std::tuple &customLocation = defaultLocation) const { + return locationsFetcher.getUsStatesLocations(customLocation); + } + + static QString formatTime(const long timeInSeconds) { + const long minutes = timeInSeconds / 60; + const long seconds = timeInSeconds % 60; + + QString formattedTime; + if (minutes > 0) { + formattedTime = QString::number(minutes) + tr("m "); + } + formattedTime += QString::number(seconds) + tr("s"); + return formattedTime; + } + + static QString calculateElapsedTime(const QJsonObject &jsonData, const std::chrono::system_clock::time_point &startTime) { + using namespace std::chrono; + if (!jsonData.contains("total_files") || !jsonData.contains("downloaded_files")) + return tr("Calculating..."); + + const int totalFiles = jsonData["total_files"].toInt(); + const int downloadedFiles = jsonData["downloaded_files"].toInt(); + + if (downloadedFiles >= totalFiles || totalFiles <= 0) return tr("Downloaded"); + + const long elapsed = duration_cast(system_clock::now() - startTime).count(); + + if (elapsed == 0 || downloadedFiles == 0) return tr("Calculating..."); + + return formatTime(elapsed); + } + + static QString calculateETA(const QJsonObject &jsonData, const std::chrono::system_clock::time_point &startTime) { + using namespace std::chrono; + static steady_clock::time_point lastUpdateTime = steady_clock::now(); + static std::deque rateHistory; + + constexpr int minDataPoints = 3; + constexpr int historySize = 10; + + static QString lastETA = tr("Calculating ETA..."); + + if (duration_cast(steady_clock::now() - lastUpdateTime).count() < 1) { + return lastETA; + } + + if (!jsonData.contains("total_files") || !jsonData.contains("downloaded_files")) + return lastETA; + + const int totalFiles = jsonData["total_files"].toInt(); + const int downloadedFiles = jsonData["downloaded_files"].toInt(); + + if (totalFiles <= 0 || downloadedFiles >= totalFiles) { + return totalFiles <= 0 ? tr("Ready") : tr("Downloaded"); + } + + const long elapsed = duration_cast(system_clock::now() - startTime).count(); + if (elapsed == 0 || downloadedFiles == 0) return lastETA; + + const double rate = downloadedFiles / static_cast(elapsed); + if (rateHistory.size() >= historySize) rateHistory.pop_front(); + rateHistory.push_back(rate); + + if (rateHistory.size() < minDataPoints) return lastETA; + + double weightedSum = 0; + for (int i = 0, weight = 1; i < rateHistory.size(); ++i, ++weight) { + weightedSum += rateHistory[i] * weight; + } + const double avgRate = 2 * weightedSum / (rateHistory.size() * (rateHistory.size() + 1)); + + const long remainingTime = static_cast((totalFiles - downloadedFiles) / avgRate); + if (remainingTime <= 0) return lastETA; + + lastETA = tr("Time remaining: ") + formatTime(remainingTime); + lastUpdateTime = steady_clock::now(); + return lastETA; + } + + static QString formatDownloadStatus(const QJsonObject &json) { + if (!json.contains("total_files") || !json.contains("downloaded_files")) + return ""; + + const int total_files = json["total_files"].toInt(); + const int downloaded_files = json["downloaded_files"].toInt(); + + if (total_files <= 0) return tr("Ready"); + if (downloaded_files >= total_files) return tr("Downloaded"); + + const int percentage = static_cast(100.0 * downloaded_files / total_files); + return QString::asprintf("%d/%d (%d%%)", downloaded_files, total_files, percentage); + } + + QString formatSize(quint64 size) const { + if (size == 0 && (!mapSizeFuture.has_value() || mapSizeFuture.value().isRunning())) { + return tr("Calculating..."); + } + + constexpr qint64 kb = 1024; + constexpr qint64 mb = 1024 * kb; + constexpr qint64 gb = 1024 * mb; + + if (size < gb) { + const double sizeMB = size / static_cast(mb); + return QString::number(sizeMB, 'f', 2) + " MB"; + } else { + const double sizeGB = size / static_cast(gb); + return QString::number(sizeGB, 'f', 2) + " GB"; + } + } + + static quint64 getDirSize(QString dirPath) { + quint64 size = 0; + const QString actualDirPath = dirPath.startsWith("~") ? dirPath.replace(0, 1, QDir::homePath()) : dirPath; + QDirIterator it(actualDirPath, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks, QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + if (it.fileInfo().isFile()) { + size += it.fileInfo().size(); + } + } + return size; + } +}; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/settings.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/settings.cc index e91de4c903..12d7bd74f7 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/settings.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/settings.cc @@ -18,6 +18,7 @@ #include "selfdrive/ui/sunnypilot/qt/offroad/settings/sunnylink_panel.h" #include "selfdrive/ui/sunnypilot/qt/offroad/settings/lateral_panel.h" #include "selfdrive/ui/sunnypilot/qt/offroad/settings/longitudinal_panel.h" +#include "selfdrive/ui/sunnypilot/qt/offroad/settings/osm_panel.h" #include "selfdrive/ui/sunnypilot/qt/offroad/settings/trips_panel.h" #include "selfdrive/ui/sunnypilot/qt/offroad/settings/vehicle_panel.h" #include "selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.h" @@ -85,6 +86,7 @@ SettingsWindowSP::SettingsWindowSP(QWidget *parent) : SettingsWindow(parent) { PanelInfo(" " + tr("Steering"), new LateralPanel(this), "../../sunnypilot/selfdrive/assets/offroad/icon_lateral.png"), PanelInfo(" " + tr("Cruise"), new LongitudinalPanel(this), "../assets/icons/speed_limit.png"), PanelInfo(" " + tr("Visuals"), new VisualsPanel(this), "../../sunnypilot/selfdrive/assets/offroad/icon_visuals.png"), + PanelInfo(" " + tr("OSM"), new OsmPanel(this), "../../sunnypilot/selfdrive/assets/offroad/icon_map.png"), PanelInfo(" " + tr("Trips"), new TripsPanel(this), "../../sunnypilot/selfdrive/assets/offroad/icon_trips.png"), PanelInfo(" " + tr("Vehicle"), new VehiclePanel(this), "../../sunnypilot/selfdrive/assets/offroad/icon_vehicle.png"), PanelInfo(" " + tr("Firehose"), new FirehosePanel(this), "../../sunnypilot/selfdrive/assets/offroad/icon_firehose.svg"), diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.cc index cf9729be18..0d42bd74f4 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.cc @@ -8,5 +8,63 @@ #include "selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.h" VisualsPanel::VisualsPanel(QWidget *parent) : QWidget(parent) { + param_watcher = new ParamWatcher(this); + connect(param_watcher, &ParamWatcher::paramChanged, [=](const QString ¶m_name, const QString ¶m_value) { + paramsRefresh(); + }); + + main_layout = new QStackedLayout(this); + ListWidgetSP *list = new ListWidgetSP(this, false); + sunnypilotScreen = new QWidget(this); + QVBoxLayout* vlayout = new QVBoxLayout(sunnypilotScreen); + vlayout->setContentsMargins(50, 20, 50, 20); + + std::vector > toggle_defs{ + { + "BlindSpot", + tr("Show Blind Spot Warnings"), + tr("Enabling this will display warnings when a vehicle is detected in your blind spot as long as your car has BSM supported."), + "../assets/offroad/icon_monitoring.png", + false, + }, + }; + + for (auto &[param, title, desc, icon, needs_restart] : toggle_defs) { + auto toggle = new ParamControlSP(param, title, desc, icon, this); + + bool locked = params.getBool((param + "Lock").toStdString()); + toggle->setEnabled(!locked); + + if (needs_restart && !locked) { + toggle->setDescription(toggle->getDescription() + tr(" Changing this setting will restart openpilot if the car is powered on.")); + + QObject::connect(uiState(), &UIState::engagedChanged, [toggle](bool engaged) { + toggle->setEnabled(!engaged); + }); + + QObject::connect(toggle, &ParamControlSP::toggleFlipped, [=](bool state) { + params.putBool("OnroadCycleRequested", true); + }); + } + + list->addItem(toggle); + toggles[param.toStdString()] = toggle; + param_watcher->addParam(param); + } + + sunnypilotScroller = new ScrollViewSP(list, this); + vlayout->addWidget(sunnypilotScroller); + + main_layout->addWidget(sunnypilotScreen); +} + +void VisualsPanel::paramsRefresh() { + if (!isVisible()) { + return; + } + + for (auto toggle : toggles) { + toggle.second->refresh(); + } } diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.h index 9f58104c4e..42e0688957 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/visuals_panel.h @@ -8,6 +8,9 @@ #pragma once #include "selfdrive/ui/sunnypilot/qt/offroad/settings/settings.h" +#include "selfdrive/ui/sunnypilot/qt/widgets/scrollview.h" + +class ScrollViewSP; class VisualsPanel : public QWidget { Q_OBJECT @@ -15,4 +18,13 @@ class VisualsPanel : public QWidget { public: explicit VisualsPanel(QWidget *parent = nullptr); + void paramsRefresh(); + +protected: + QStackedLayout* main_layout = nullptr; + QWidget* sunnypilotScreen = nullptr; + ScrollViewSP *sunnypilotScroller = nullptr; + Params params; + std::map toggles; + ParamWatcher * param_watcher; }; diff --git a/selfdrive/ui/sunnypilot/qt/onroad/model.cc b/selfdrive/ui/sunnypilot/qt/onroad/model.cc index 617b64f58e..af0177c344 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/model.cc +++ b/selfdrive/ui/sunnypilot/qt/onroad/model.cc @@ -6,3 +6,47 @@ */ #include "selfdrive/ui/sunnypilot/qt/onroad/model.h" + + +void ModelRendererSP::update_model(const cereal::ModelDataV2::Reader &model, const cereal::RadarState::LeadData::Reader &lead) { + ModelRenderer::update_model(model, lead); + const auto &model_position = model.getPosition(); + const auto &lane_lines = model.getLaneLines(); + float max_distance = std::clamp(*(model_position.getX().end() - 1), MIN_DRAW_DISTANCE, MAX_DRAW_DISTANCE); + int max_idx = get_path_length_idx(lane_lines[0], max_distance); + // update blindspot vertices + float max_distance_barrier = 100; + int max_idx_barrier = std::min(max_idx, get_path_length_idx(lane_lines[0], max_distance_barrier)); + mapLineToPolygon(model.getLaneLines()[1], 0.2, -0.05, &left_blindspot_vertices, max_idx_barrier); + mapLineToPolygon(model.getLaneLines()[2], 0.2, -0.05, &right_blindspot_vertices, max_idx_barrier); +} + +void ModelRendererSP::drawPath(QPainter &painter, const cereal::ModelDataV2::Reader &model, const QRect &surface_rect) { + auto *s = uiState(); + auto &sm = *(s->sm); + bool blindspot = Params().getBool("BlindSpot"); + + if (blindspot) { + bool left_blindspot = sm["carState"].getCarState().getLeftBlindspot(); + bool right_blindspot = sm["carState"].getCarState().getRightBlindspot(); + + //painter.setBrush(QColor::fromRgbF(1.0, 0.0, 0.0, 0.4)); // Red with alpha for blind spot + + if (left_blindspot && !left_blindspot_vertices.isEmpty()) { + QLinearGradient gradient(0, 0, surface_rect.width(), 0); // Horizontal gradient from left to right + gradient.setColorAt(0.0, QColor(255, 165, 0, 102)); // Orange with alpha + gradient.setColorAt(1.0, QColor(255, 255, 0, 102)); // Yellow with alpha + painter.setBrush(gradient); + painter.drawPolygon(left_blindspot_vertices); + } + + if (right_blindspot && !right_blindspot_vertices.isEmpty()) { + QLinearGradient gradient(surface_rect.width(), 0, 0, 0); // Horizontal gradient from right to left + gradient.setColorAt(0.0, QColor(255, 165, 0, 102)); // Orange with alpha + gradient.setColorAt(1.0, QColor(255, 255, 0, 102)); // Yellow with alpha + painter.setBrush(gradient); + painter.drawPolygon(right_blindspot_vertices); + } + } + ModelRenderer::drawPath(painter, model, surface_rect.height()); +} diff --git a/selfdrive/ui/sunnypilot/qt/onroad/model.h b/selfdrive/ui/sunnypilot/qt/onroad/model.h index 8569e58f66..24404f32f0 100644 --- a/selfdrive/ui/sunnypilot/qt/onroad/model.h +++ b/selfdrive/ui/sunnypilot/qt/onroad/model.h @@ -12,4 +12,11 @@ class ModelRendererSP : public ModelRenderer { public: ModelRendererSP() = default; + +private: + void update_model(const cereal::ModelDataV2::Reader &model, const cereal::RadarState::LeadData::Reader &lead) override; + void drawPath(QPainter &painter, const cereal::ModelDataV2::Reader &model, const QRect &rect) override; + + QPolygonF left_blindspot_vertices; + QPolygonF right_blindspot_vertices; }; diff --git a/selfdrive/ui/sunnypilot/qt/widgets/controls.h b/selfdrive/ui/sunnypilot/qt/widgets/controls.h index e31c4047e9..c8849faf53 100644 --- a/selfdrive/ui/sunnypilot/qt/widgets/controls.h +++ b/selfdrive/ui/sunnypilot/qt/widgets/controls.h @@ -247,14 +247,11 @@ public: if (inline_layout) { button_param_layout->setMargin(0); button_param_layout->setSpacing(0); + spacingItem = nullptr; if (!title.isEmpty()) { main_layout->removeWidget(title_label); hlayout->addWidget(title_label, 1); } - if (spacingItem != nullptr && main_layout->indexOf(spacingItem) != -1) { - main_layout->removeItem(spacingItem); - spacingItem = nullptr; - } } button_group = new QButtonGroup(this); diff --git a/selfdrive/ui/sunnypilot/qt/widgets/drive_stats.cc b/selfdrive/ui/sunnypilot/qt/widgets/drive_stats.cc index 032abdbbfb..bfdcac2564 100644 --- a/selfdrive/ui/sunnypilot/qt/widgets/drive_stats.cc +++ b/selfdrive/ui/sunnypilot/qt/widgets/drive_stats.cc @@ -34,7 +34,7 @@ DriveStats::DriveStats(QWidget* parent) : QFrame(parent) { int row = 0; grid_layout->addWidget(newLabel(title, "title"), row++, 0, 1, 3); - grid_layout->addItem(new QSpacerItem(0, 50), row++, 0, 1, 1); + grid_layout->addItem(new QSpacerItem(0, 30), row++, 0, 1, 1); grid_layout->addWidget(labels.routes = newLabel("0", "number"), row, 0, Qt::AlignLeft); grid_layout->addWidget(labels.distance = newLabel("0", "number"), row, 1, Qt::AlignLeft); diff --git a/selfdrive/ui/sunnypilot/ui.cc b/selfdrive/ui/sunnypilot/ui.cc index 586a03b1dd..b2701356cc 100644 --- a/selfdrive/ui/sunnypilot/ui.cc +++ b/selfdrive/ui/sunnypilot/ui.cc @@ -41,6 +41,7 @@ void UIStateSP::update() { DeviceSP::DeviceSP(QObject *parent) : Device(parent) { QObject::connect(uiStateSP(), &UIStateSP::uiUpdate, this, &DeviceSP::update); + QObject::connect(this, &Device::displayPowerChanged, this, &DeviceSP::handleDisplayPowerChanged); } UIStateSP *uiStateSP() { @@ -62,3 +63,10 @@ DeviceSP *deviceSP() { static DeviceSP _device; return &_device; } + +void DeviceSP::handleDisplayPowerChanged(bool on) { + // if enabled, trigger offroad mode when device goes to sleep + if (params.get("DeviceBootMode") == "1" && not on) { + params.putBool("OffroadMode", true); + } +} diff --git a/selfdrive/ui/sunnypilot/ui.h b/selfdrive/ui/sunnypilot/ui.h index a94dfca3b3..cf8de1c4bb 100644 --- a/selfdrive/ui/sunnypilot/ui.h +++ b/selfdrive/ui/sunnypilot/ui.h @@ -84,6 +84,10 @@ class DeviceSP : public Device { public: DeviceSP(QObject *parent = 0); + +private: + Params params; + void handleDisplayPowerChanged(bool on); }; DeviceSP *deviceSP(); diff --git a/selfdrive/ui/ui.cc b/selfdrive/ui/ui.cc index 06208638db..c9dc70cdff 100644 --- a/selfdrive/ui/ui.cc +++ b/selfdrive/ui/ui.cc @@ -80,8 +80,12 @@ void UIState::updateStatus() { status = STATUS_OVERRIDE; } else { if (mads.getAvailable()) { - if (mads.getEnabled()) { - status = ss.getEnabled() ? STATUS_ENGAGED : STATUS_LAT_ONLY; + if (mads.getEnabled() && ss.getEnabled()) { + status = STATUS_ENGAGED; + } else if (mads.getEnabled()) { + status = STATUS_LAT_ONLY; + } else if (ss.getEnabled()) { + status = STATUS_LONG_ONLY; } else { status = STATUS_DISENGAGED; } @@ -167,6 +171,8 @@ void Device::resetInteractiveTimeout(int timeout) { } void Device::updateBrightness(const UIState &s) { + int brightness; + int brightness_override = QString::fromStdString(Params().get("Brightness")).toInt(); float clipped_brightness = offroad_brightness; if (s.scene.started && s.scene.light_sensor >= 0) { clipped_brightness = s.scene.light_sensor; @@ -178,11 +184,19 @@ void Device::updateBrightness(const UIState &s) { clipped_brightness = std::pow((clipped_brightness + 16.0) / 116.0, 3.0); } - // Scale back to 10% to 100% - clipped_brightness = std::clamp(100.0f * clipped_brightness, 10.0f, 100.0f); + if (brightness_override == 1) { + clipped_brightness = std::clamp(100.0f * clipped_brightness, 1.0f, 100.0f); // Scale back to 1% to 100% + } else if (brightness_override == 0) { + clipped_brightness = std::clamp(100.0f * clipped_brightness, 10.0f, 100.0f); // Scale back to 10% to 100% + } + } + + if (brightness_override == 0 || brightness_override == 1) { + brightness = brightness_filter.update(clipped_brightness); + } else { + brightness = brightness_override; } - int brightness = brightness_filter.update(clipped_brightness); if (!awake) { brightness = 0; } diff --git a/selfdrive/ui/ui.py b/selfdrive/ui/ui.py index 8a3e34466f..e77e053cb7 100755 --- a/selfdrive/ui/ui.py +++ b/selfdrive/ui/ui.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 import pyray as rl +from openpilot.common.watchdog import kick_watchdog from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.main import MainLayout from openpilot.selfdrive.ui.ui_state import ui_state + def main(): gui_app.init_window("UI") main_layout = MainLayout() @@ -15,6 +17,8 @@ def main(): main_layout.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + kick_watchdog() + if __name__ == "__main__": main() diff --git a/selfdrive/ui/widgets/offroad_alerts.py b/selfdrive/ui/widgets/offroad_alerts.py new file mode 100644 index 0000000000..df88dc8554 --- /dev/null +++ b/selfdrive/ui/widgets/offroad_alerts.py @@ -0,0 +1,328 @@ +import json +import pyray as rl +from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from openpilot.common.params import Params +from openpilot.system.hardware import HARDWARE +from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.lib.application import gui_app, FontWeight + +class AlertColors: + HIGH_SEVERITY = rl.Color(226, 44, 44, 255) + LOW_SEVERITY = rl.Color(41, 41, 41, 255) + BACKGROUND = rl.Color(57, 57, 57, 255) + BUTTON = rl.WHITE + BUTTON_TEXT = rl.BLACK + SNOOZE_BG = rl.Color(79, 79, 79, 255) + TEXT = rl.WHITE + + +class AlertConstants: + BUTTON_SIZE = (400, 125) + SNOOZE_BUTTON_SIZE = (550, 125) + REBOOT_BUTTON_SIZE = (600, 125) + MARGIN = 50 + SPACING = 30 + FONT_SIZE = 48 + BORDER_RADIUS = 30 + ALERT_HEIGHT = 120 + ALERT_SPACING = 20 + + +@dataclass +class AlertData: + key: str + text: str + severity: int + visible: bool = False + + +class AbstractAlert(ABC): + def __init__(self, has_reboot_btn: bool = False): + self.params = Params() + self.has_reboot_btn = has_reboot_btn + self.dismiss_callback: Callable | None = None + + self.dismiss_btn_rect = rl.Rectangle(0, 0, *AlertConstants.BUTTON_SIZE) + self.snooze_btn_rect = rl.Rectangle(0, 0, *AlertConstants.SNOOZE_BUTTON_SIZE) + self.reboot_btn_rect = rl.Rectangle(0, 0, *AlertConstants.REBOOT_BUTTON_SIZE) + + self.snooze_visible = False + self.content_rect = rl.Rectangle(0, 0, 0, 0) + self.scroll_panel_rect = rl.Rectangle(0, 0, 0, 0) + self.scroll_panel = GuiScrollPanel() + + def set_dismiss_callback(self, callback: Callable): + self.dismiss_callback = callback + + @abstractmethod + def refresh(self) -> bool: + pass + + @abstractmethod + def get_content_height(self) -> float: + pass + + def handle_input(self, mouse_pos: rl.Vector2, mouse_clicked: bool) -> bool: + # TODO: fix scroll_panel.is_click_valid() + if not mouse_clicked: + return False + + if rl.check_collision_point_rec(mouse_pos, self.dismiss_btn_rect): + if self.dismiss_callback: + self.dismiss_callback() + return True + + if self.snooze_visible and rl.check_collision_point_rec(mouse_pos, self.snooze_btn_rect): + self.params.put_bool("SnoozeUpdate", True) + if self.dismiss_callback: + self.dismiss_callback() + return True + + if self.has_reboot_btn and rl.check_collision_point_rec(mouse_pos, self.reboot_btn_rect): + HARDWARE.reboot() + return True + + return False + + def render(self, rect: rl.Rectangle): + rl.draw_rectangle_rounded(rect, AlertConstants.BORDER_RADIUS / rect.width, 10, AlertColors.BACKGROUND) + + footer_height = AlertConstants.BUTTON_SIZE[1] + AlertConstants.SPACING + content_height = rect.height - 2 * AlertConstants.MARGIN - footer_height + + self.content_rect = rl.Rectangle( + rect.x + AlertConstants.MARGIN, + rect.y + AlertConstants.MARGIN, + rect.width - 2 * AlertConstants.MARGIN, + content_height, + ) + self.scroll_panel_rect = rl.Rectangle( + self.content_rect.x, self.content_rect.y, self.content_rect.width, self.content_rect.height + ) + + self._render_scrollable_content() + self._render_footer(rect) + + def _render_scrollable_content(self): + content_total_height = self.get_content_height() + content_bounds = rl.Rectangle(0, 0, self.scroll_panel_rect.width, content_total_height) + scroll_offset = self.scroll_panel.handle_scroll(self.scroll_panel_rect, content_bounds) + + rl.begin_scissor_mode( + int(self.scroll_panel_rect.x), + int(self.scroll_panel_rect.y), + int(self.scroll_panel_rect.width), + int(self.scroll_panel_rect.height), + ) + + content_rect_with_scroll = rl.Rectangle( + self.scroll_panel_rect.x, + self.scroll_panel_rect.y + scroll_offset.y, + self.scroll_panel_rect.width, + content_total_height, + ) + + self._render_content(content_rect_with_scroll) + rl.end_scissor_mode() + + @abstractmethod + def _render_content(self, content_rect: rl.Rectangle): + pass + + def _render_footer(self, rect: rl.Rectangle): + footer_y = rect.y + rect.height - AlertConstants.MARGIN - AlertConstants.BUTTON_SIZE[1] + font = gui_app.font(FontWeight.MEDIUM) + + self.dismiss_btn_rect.x = rect.x + AlertConstants.MARGIN + self.dismiss_btn_rect.y = footer_y + rl.draw_rectangle_rounded(self.dismiss_btn_rect, 0.3, 10, AlertColors.BUTTON) + + text = "Close" + text_width = measure_text_cached(font, text, AlertConstants.FONT_SIZE).x + text_x = self.dismiss_btn_rect.x + (AlertConstants.BUTTON_SIZE[0] - text_width) // 2 + text_y = self.dismiss_btn_rect.y + (AlertConstants.BUTTON_SIZE[1] - AlertConstants.FONT_SIZE) // 2 + rl.draw_text_ex( + font, text, rl.Vector2(int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.BUTTON_TEXT + ) + + if self.snooze_visible: + self.snooze_btn_rect.x = rect.x + rect.width - AlertConstants.MARGIN - AlertConstants.SNOOZE_BUTTON_SIZE[0] + self.snooze_btn_rect.y = footer_y + rl.draw_rectangle_rounded(self.snooze_btn_rect, 0.3, 10, AlertColors.SNOOZE_BG) + + text = "Snooze Update" + text_width = measure_text_cached(font, text, AlertConstants.FONT_SIZE).x + text_x = self.snooze_btn_rect.x + (AlertConstants.SNOOZE_BUTTON_SIZE[0] - text_width) // 2 + text_y = self.snooze_btn_rect.y + (AlertConstants.SNOOZE_BUTTON_SIZE[1] - AlertConstants.FONT_SIZE) // 2 + rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.TEXT) + + elif self.has_reboot_btn: + self.reboot_btn_rect.x = rect.x + rect.width - AlertConstants.MARGIN - AlertConstants.REBOOT_BUTTON_SIZE[0] + self.reboot_btn_rect.y = footer_y + rl.draw_rectangle_rounded(self.reboot_btn_rect, 0.3, 10, AlertColors.BUTTON) + + text = "Reboot and Update" + text_width = measure_text_cached(font, text, AlertConstants.FONT_SIZE).x + text_x = self.reboot_btn_rect.x + (AlertConstants.REBOOT_BUTTON_SIZE[0] - text_width) // 2 + text_y = self.reboot_btn_rect.y + (AlertConstants.REBOOT_BUTTON_SIZE[1] - AlertConstants.FONT_SIZE) // 2 + rl.draw_text_ex( + font, text, rl.Vector2(int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.BUTTON_TEXT + ) + + +class OffroadAlert(AbstractAlert): + def __init__(self): + super().__init__(has_reboot_btn=False) + self.sorted_alerts: list[AlertData] = [] + + def refresh(self): + if not self.sorted_alerts: + self._build_alerts() + + active_count = 0 + connectivity_needed = False + + for alert_data in self.sorted_alerts: + text = "" + bytes_data = self.params.get(alert_data.key) + + if bytes_data: + try: + alert_json = json.loads(bytes_data) + text = alert_json.get("text", "").replace("{}", alert_json.get("extra", "")) + except json.JSONDecodeError: + text = "" + + alert_data.text = text + alert_data.visible = bool(text) + + if alert_data.visible: + active_count += 1 + + if alert_data.key == "Offroad_ConnectivityNeeded" and alert_data.visible: + connectivity_needed = True + + self.snooze_visible = connectivity_needed + return active_count + + def get_content_height(self) -> float: + if not self.sorted_alerts: + return 0 + + total_height = 20 + font = gui_app.font(FontWeight.NORMAL) + + for alert_data in self.sorted_alerts: + if not alert_data.visible: + continue + + text_width = int(self.content_rect.width - 90) + wrapped_lines = wrap_text(font, alert_data.text, AlertConstants.FONT_SIZE, text_width) + line_count = len(wrapped_lines) + text_height = line_count * (AlertConstants.FONT_SIZE + 5) + alert_item_height = max(text_height + 40, AlertConstants.ALERT_HEIGHT) + total_height += alert_item_height + AlertConstants.ALERT_SPACING + + if total_height > 20: + total_height = total_height - AlertConstants.ALERT_SPACING + 20 + + return total_height + + def _build_alerts(self): + self.sorted_alerts = [] + try: + with open("../selfdrived/alerts_offroad.json", "rb") as f: + alerts_config = json.load(f) + for key, config in sorted(alerts_config.items(), key=lambda x: x[1].get("severity", 0), reverse=True): + severity = config.get("severity", 0) + alert_data = AlertData(key=key, text="", severity=severity) + self.sorted_alerts.append(alert_data) + except (FileNotFoundError, json.JSONDecodeError): + pass + + def _render_content(self, content_rect: rl.Rectangle): + y_offset = 20 + font = gui_app.font(FontWeight.NORMAL) + + for alert_data in self.sorted_alerts: + if not alert_data.visible: + continue + + bg_color = AlertColors.HIGH_SEVERITY if alert_data.severity > 0 else AlertColors.LOW_SEVERITY + text_width = int(content_rect.width - 90) + wrapped_lines = wrap_text(font, alert_data.text, AlertConstants.FONT_SIZE, text_width) + line_count = len(wrapped_lines) + text_height = line_count * (AlertConstants.FONT_SIZE + 5) + alert_item_height = max(text_height + 40, AlertConstants.ALERT_HEIGHT) + + alert_rect = rl.Rectangle( + content_rect.x + 10, + content_rect.y + y_offset, + content_rect.width - 30, + alert_item_height, + ) + + rl.draw_rectangle_rounded(alert_rect, 0.2, 10, bg_color) + + text_x = alert_rect.x + 30 + text_y = alert_rect.y + 20 + + for i, line in enumerate(wrapped_lines): + rl.draw_text_ex( + font, + line, + rl.Vector2(text_x, text_y + i * (AlertConstants.FONT_SIZE + 5)), + AlertConstants.FONT_SIZE, + 0, + AlertColors.TEXT, + ) + + y_offset += alert_item_height + AlertConstants.ALERT_SPACING + + +class UpdateAlert(AbstractAlert): + def __init__(self): + super().__init__(has_reboot_btn=True) + self.release_notes = "" + self._wrapped_release_notes = "" + self._cached_content_height: float = 0.0 + + def refresh(self) -> bool: + update_available: bool = self.params.get_bool("UpdateAvailable") + if update_available: + self.release_notes = self.params.get("UpdaterNewReleaseNotes", encoding='utf-8') + self._cached_content_height = 0 + + return update_available + + def get_content_height(self) -> float: + if not self.release_notes: + return 100 + + if self._cached_content_height == 0: + self._wrapped_release_notes = self.release_notes + size = measure_text_cached(gui_app.font(FontWeight.NORMAL), self._wrapped_release_notes, AlertConstants.FONT_SIZE) + self._cached_content_height = max(size.y + 60, 100) + + return self._cached_content_height + + def _render_content(self, content_rect: rl.Rectangle): + if self.release_notes: + rl.draw_text_ex( + gui_app.font(FontWeight.NORMAL), + self._wrapped_release_notes, + rl.Vector2(content_rect.x + 30, content_rect.y + 30), + AlertConstants.FONT_SIZE, + 0.0, + AlertColors.TEXT, + ) + else: + no_notes_text = "No release notes available." + text_width = rl.measure_text(no_notes_text, AlertConstants.FONT_SIZE) + text_x = content_rect.x + (content_rect.width - text_width) // 2 + text_y = content_rect.y + 50 + rl.draw_text(no_notes_text, int(text_x), int(text_y), AlertConstants.FONT_SIZE, AlertColors.TEXT) diff --git a/sunnypilot/mapd/__init__.py b/sunnypilot/mapd/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/mapd/live_map_data/__init__.py b/sunnypilot/mapd/live_map_data/__init__.py new file mode 100644 index 0000000000..557ad06515 --- /dev/null +++ b/sunnypilot/mapd/live_map_data/__init__.py @@ -0,0 +1,22 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.common.swaglog import cloudlog + +LOOK_AHEAD_HORIZON_TIME = 15. # s. Time horizon for look ahead of turn speed sections to provide on liveMapDataSP msg. +_DEBUG = False +_CLOUDLOG_DEBUG = False +ROAD_NAME_TIMEOUT = 30 # secs +R = 6373000.0 # approximate radius of earth in mts +QUERY_RADIUS = 3000 # mts. Radius to use on OSM data queries. +QUERY_RADIUS_OFFLINE = 2250 # mts. Radius to use on offline OSM data queries. + + +def get_debug(msg, log_to_cloud=True): + if _CLOUDLOG_DEBUG and log_to_cloud: + cloudlog.debug(msg) + if _DEBUG: + print(msg) diff --git a/sunnypilot/mapd/live_map_data/base_map_data.py b/sunnypilot/mapd/live_map_data/base_map_data.py new file mode 100644 index 0000000000..536d7720b7 --- /dev/null +++ b/sunnypilot/mapd/live_map_data/base_map_data.py @@ -0,0 +1,76 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import time +from abc import abstractmethod, ABC + +from cereal import messaging +from openpilot.common.gps import get_gps_location_service +from openpilot.common.params import Params +from openpilot.sunnypilot.navd.helpers import Coordinate, coordinate_from_param + + +class BaseMapData(ABC): + def __init__(self): + self.params = Params() + + self.gps_location_service = get_gps_location_service(self.params) + self.sm = messaging.SubMaster(['livePose', 'carControl'] + [self.gps_location_service]) + self.pm = messaging.PubMaster(['liveMapDataSP']) + + self.last_position = coordinate_from_param("LastGPSPosition", self.params) + self.last_altitude = None + + @abstractmethod + def update_location(self) -> None: + pass + + @abstractmethod + def get_current_speed_limit(self) -> float: + pass + + @abstractmethod + def get_next_speed_limit_and_distance(self) -> tuple[float, float]: + pass + + @abstractmethod + def get_current_road_name(self) -> str: + pass + + def get_current_location(self) -> None: + gps = self.sm[self.gps_location_service] + + # ignore the message if the fix is invalid + gps_ok = self.sm.updated[self.gps_location_service] or (time.monotonic() - self.sm.logMonoTime[self.gps_location_service] / 1e9) > 2.0 + if not gps_ok and self.sm['livePose'].inputsOK: + return None + + # livePose has these data, but aren't on cereal + self.last_position = Coordinate(gps.latitude, gps.longitude) + self.last_altitude = gps.altitude + + def publish(self) -> None: + speed_limit = self.get_current_speed_limit() + next_speed_limit, next_speed_limit_distance = self.get_next_speed_limit_and_distance() + + mapd_sp_send = messaging.new_message('liveMapDataSP') + mapd_sp_send.valid = self.sm.all_checks(service_list=[self.gps_location_service, 'livePose']) + live_map_data = mapd_sp_send.liveMapDataSP + + live_map_data.speedLimitValid = bool(speed_limit > 0) + live_map_data.speedLimit = speed_limit + live_map_data.speedLimitAheadValid = bool(next_speed_limit > 0) + live_map_data.speedLimitAhead = next_speed_limit + live_map_data.speedLimitAheadDistance = next_speed_limit_distance + live_map_data.roadName = self.get_current_road_name() + + self.pm.send('liveMapDataSP', mapd_sp_send) + + def tick(self) -> None: + self.sm.update() + self.get_current_location() + self.update_location() + self.publish() diff --git a/sunnypilot/mapd/live_map_data/debug.py b/sunnypilot/mapd/live_map_data/debug.py new file mode 100644 index 0000000000..da9f4d7771 --- /dev/null +++ b/sunnypilot/mapd/live_map_data/debug.py @@ -0,0 +1,57 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +# DISCLAIMER: This code is intended principally for development and debugging purposes. +# Although it provides a standalone entry point to the program, users should refer +# to the actual implementations for consumption. Usage outside of development scenarios +# is not advised and could lead to unpredictable results. + +import threading +import traceback + +from cereal import messaging +from openpilot.common.gps import get_gps_location_service +from openpilot.common.params import Params +from openpilot.common.realtime import config_realtime_process +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.common import Policy +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit_controller.speed_limit_resolver import SpeedLimitResolver +from openpilot.sunnypilot.mapd.live_map_data import get_debug + + +def excepthook(args): + get_debug(f'MapD: Threading exception:\n{args}') + traceback.print_exception(args.exc_type, args.exc_value, args.exc_traceback) + + +def live_map_data_sp_thread(): + config_realtime_process([0, 1, 2, 3], 5) + + params = Params() + gps_location_service = get_gps_location_service(params) + + while True: + live_map_data_sp_thread_debug(gps_location_service) + + +def live_map_data_sp_thread_debug(gps_location_service): + _sub_master = messaging.SubMaster(['carState', 'livePose', 'liveMapDataSP', 'longitudinalPlanSP', gps_location_service]) + _sub_master.update() + + v_ego = _sub_master['carState'].vEgo + long_spl = _sub_master['longitudinalPlanSP'].speedLimit + _policy = Policy.car_state_priority + _resolver = SpeedLimitResolver(_policy) + _speed_limit, _distance, _source = _resolver.resolve(v_ego, long_spl, _sub_master) + print(_speed_limit, _distance, _source, " <-> ", long_spl) + + +def main(): + threading.excepthook = excepthook + live_map_data_sp_thread() + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/mapd/live_map_data/osm_map_data.py b/sunnypilot/mapd/live_map_data/osm_map_data.py new file mode 100644 index 0000000000..5f74a5d011 --- /dev/null +++ b/sunnypilot/mapd/live_map_data/osm_map_data.py @@ -0,0 +1,51 @@ +""" +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 json +import platform + +from openpilot.common.params import Params +from openpilot.sunnypilot.mapd.live_map_data.base_map_data import BaseMapData +from openpilot.sunnypilot.navd.helpers import Coordinate + + +class OsmMapData(BaseMapData): + def __init__(self): + super().__init__() + self.params = Params() + self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params + + def update_location(self) -> None: + if self.last_position is None or self.last_altitude is None: + return + + params = { + "latitude": self.last_position.latitude, + "longitude": self.last_position.longitude, + "altitude": self.last_altitude, + } + + self.mem_params.put("LastGPSPosition", json.dumps(params)) + + def get_current_speed_limit(self) -> float: + return float(self.mem_params.get("MapSpeedLimit", encoding='utf8') or 0.0) + + def get_current_road_name(self) -> str: + return self.mem_params.get("RoadName", encoding='utf8') or "" + + def get_next_speed_limit_and_distance(self) -> tuple[float, float]: + next_speed_limit_section_str = self.mem_params.get("NextMapSpeedLimit", encoding='utf8') + next_speed_limit_section = json.loads(next_speed_limit_section_str) if next_speed_limit_section_str else {} + next_speed_limit = next_speed_limit_section.get('speedlimit', 0.0) + next_speed_limit_latitude = next_speed_limit_section.get('latitude') + next_speed_limit_longitude = next_speed_limit_section.get('longitude') + next_speed_limit_distance = 0.0 + + if next_speed_limit_latitude and next_speed_limit_longitude: + next_speed_limit_coordinates = Coordinate(next_speed_limit_latitude, next_speed_limit_longitude) + next_speed_limit_distance = (self.last_position or Coordinate(0, 0)).distance_to(next_speed_limit_coordinates) + + return next_speed_limit, next_speed_limit_distance diff --git a/sunnypilot/mapd/live_map_data/standalone.py b/sunnypilot/mapd/live_map_data/standalone.py new file mode 100644 index 0000000000..f73ecc7724 --- /dev/null +++ b/sunnypilot/mapd/live_map_data/standalone.py @@ -0,0 +1,42 @@ +""" +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. +""" +# DISCLAIMER: This code is intended principally for development and debugging purposes. +# Although it provides a standalone entry point to the program, users should refer +# to the actual implementations for consumption. Usage outside of development scenarios +# is not advised and could lead to unpredictable results. + +import threading +import traceback + +from openpilot.common.realtime import Ratekeeper, config_realtime_process +from openpilot.sunnypilot.mapd.live_map_data import get_debug +from openpilot.sunnypilot.mapd.live_map_data.osm_map_data import OsmMapData + + +def excepthook(args): + get_debug(f'MapD: Threading exception:\n{args}') + traceback.print_exception(args.exc_type, args.exc_value, args.exc_traceback) + + +def live_map_data_sp_thread(): + config_realtime_process([0, 1, 2, 3], 5) + + live_map_sp = OsmMapData() + rk = Ratekeeper(1, print_delay_threshold=None) + + while True: + live_map_sp.tick() + rk.keep_time() + + +def main(): + threading.excepthook = excepthook + live_map_data_sp_thread() + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/mapd/mapd_installer.py b/sunnypilot/mapd/mapd_installer.py new file mode 100755 index 0000000000..0be30b5b62 --- /dev/null +++ b/sunnypilot/mapd/mapd_installer.py @@ -0,0 +1,152 @@ +#!/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 logging +import os +import stat +import time +import traceback +import requests +from pathlib import Path +from urllib.request import urlopen + +from cereal import messaging +from openpilot.common.params import Params +from openpilot.sunnypilot.mapd.mapd_manager import MAPD_PATH, MAPD_BIN_DIR +from openpilot.system.hardware.hw import Paths +from openpilot.system.ui.spinner import Spinner +from openpilot.system.version import is_prebuilt +import openpilot.system.sentry as sentry + +VERSION = 'v1.9.0' +URL = f"https://github.com/pfeiferj/openpilot-mapd/releases/download/{VERSION}/mapd" + + +class MapdInstallManager: + def __init__(self, spinner_ref: Spinner): + self._spinner = spinner_ref + + def download(self) -> None: + self.ensure_directories_exist() + self._download_file() + self.update_installed_version(VERSION) + + def check_and_download(self) -> None: + if self.download_needed(): + self.download() + + @staticmethod + def download_needed() -> bool: + return not os.path.exists(MAPD_PATH) or MapdInstallManager.get_installed_version() != VERSION + + @staticmethod + def ensure_directories_exist() -> None: + if not os.path.exists(Paths.mapd_root()): + os.makedirs(Paths.mapd_root()) + if not os.path.exists(MAPD_BIN_DIR): + os.makedirs(MAPD_BIN_DIR) + + @staticmethod + def _safe_write_and_set_executable(file_path: Path, content: bytes) -> None: + with open(file_path, 'wb') as output: + output.write(content) + output.flush() + os.fsync(output.fileno()) + current_permissions = stat.S_IMODE(os.lstat(file_path).st_mode) + os.chmod(file_path, current_permissions | stat.S_IEXEC) + + def _download_file(self, num_retries=5) -> None: + temp_file = Path(MAPD_PATH + ".tmp") + download_timeout = 60 + for cnt in range(num_retries): + try: + response = requests.get(URL, stream=True, timeout=download_timeout) + response.raise_for_status() + self._safe_write_and_set_executable(temp_file, response.content) + # No exceptions encountered. Safe to replace original file. + temp_file.replace(MAPD_PATH) + return + except requests.exceptions.ReadTimeout: + self._spinner.update(f"ReadTimeout caught. Timeout is [{download_timeout}]. Retrying download... [{cnt}]") + time.sleep(0.5) + except requests.exceptions.RequestException as e: + self._spinner.update(f"RequestException caught: {e}. Retrying download... [{cnt}]") + time.sleep(0.5) + + # Delete temp file if the process was not successful. + if temp_file.exists(): + temp_file.unlink() + logging.error("Failed to download file after all retries") + + @staticmethod + def update_installed_version(version: str) -> None: + Params().put("MapdVersion", version) + + @staticmethod + def get_installed_version() -> str: + return Params().get("MapdVersion", encoding="utf-8") or "" + + def wait_for_internet_connection(self, return_on_failure: bool = False) -> bool: + max_retries = 10 + for retries in range(max_retries + 1): + self._spinner.update(f"Waiting for internet connection... [{retries}/{max_retries}]") + time.sleep(2) + try: + _ = urlopen('https://sentry.io', timeout=10) + return True + except Exception as e: + print(f'Wait for internet failed: {e}') + if return_on_failure and retries == max_retries: + return False + + return False + + def non_prebuilt_install(self) -> None: + sm = messaging.SubMaster(['deviceState']) + metered = sm['deviceState'].networkMetered + + if metered: + self._spinner.update("Can't proceed with mapd install since network is metered!") + time.sleep(5) + return + + try: + self.ensure_directories_exist() + if not self.download_needed(): + self._spinner.update("Mapd is good!") + time.sleep(0.1) + return + + if self.wait_for_internet_connection(return_on_failure=True): + self._spinner.update(f"Downloading pfeiferj's mapd [{install_manager.get_installed_version()}] => [{VERSION}].") + time.sleep(0.1) + self.check_and_download() + self._spinner.close() + + except Exception: + for i in range(6): + self._spinner.update("Failed to download OSM maps won't work until properly downloaded!" + + "Try again manually rebooting. " + + f"Boot will continue in {5 - i}s...") + time.sleep(1) + + sentry.init(sentry.SentryProject.SELFDRIVE) + traceback.print_exc() + sentry.capture_exception() + + +if __name__ == "__main__": + spinner = Spinner() + install_manager = MapdInstallManager(spinner) + install_manager.ensure_directories_exist() + if is_prebuilt(): + debug_msg = f"[DEBUG] This is prebuilt, no mapd install required. VERSION: [{VERSION}], Param [{install_manager.get_installed_version()}]" + spinner.update(debug_msg) + install_manager.update_installed_version(VERSION) + else: + spinner.update(f"Checking if mapd is installed and valid. Prebuilt [{is_prebuilt()}]") + install_manager.non_prebuilt_install() diff --git a/sunnypilot/mapd/mapd_manager.py b/sunnypilot/mapd/mapd_manager.py new file mode 100755 index 0000000000..342ba3fe7b --- /dev/null +++ b/sunnypilot/mapd/mapd_manager.py @@ -0,0 +1,147 @@ +#!/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 json +import time +import platform +import os +import glob +import shutil + +from openpilot.common.basedir import BASEDIR +from openpilot.common.params import Params +from openpilot.common.realtime import Ratekeeper, config_realtime_process +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert +from openpilot.sunnypilot.mapd.live_map_data.osm_map_data import OsmMapData +from openpilot.system.hardware.hw import Paths + +# PFEIFER - MAPD {{ +params = Params() +mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else params +# }} PFEIFER - MAPD + +MAPD_BIN_DIR = os.path.join(BASEDIR, 'third_party/mapd_pfeiferj') +MAPD_PATH = os.path.join(MAPD_BIN_DIR, 'mapd') + + +def get_files_for_cleanup() -> list[str]: + paths = [ + f"{Paths.mapd_root()}/db", + f"{Paths.mapd_root()}/v*" + ] + files_to_remove = [] + for path in paths: + if os.path.exists(path): + files = glob.glob(path + '/**', recursive=True) + files_to_remove.extend(files) + # check for version and mapd files + if not os.path.isfile(MAPD_PATH): + files_to_remove.append(MAPD_PATH) + return files_to_remove + + +def cleanup_old_osm_data(files_to_remove: list[str]) -> None: + for file in files_to_remove: + # Remove trailing slash if path is file + if file.endswith('/') and os.path.isfile(file[:-1]): + file = file[:-1] + # Try to remove as file or symbolic link first + if os.path.islink(file) or os.path.isfile(file): + os.remove(file) + elif os.path.isdir(file): # If it's a directory + shutil.rmtree(file, ignore_errors=False) + + +def request_refresh_osm_location_data(nations: list[str], states: list[str] = None) -> None: + params.put("OsmDownloadedDate", str(time.time())) + params.put_bool("OsmDbUpdatesCheck", False) + + osm_download_locations = json.dumps({ + "nations": nations, + "states": states or [] + }) + + print(f"Downloading maps for {osm_download_locations}") + mem_params.put("OSMDownloadLocations", osm_download_locations) + + +def filter_nations_and_states(nations: list[str], states: list[str] = None) -> tuple[list[str], list[str]]: + """Filters and prepares nation and state data for OSM map download. + + If the nation is 'US' and a specific state is provided, the nation 'US' is removed from the list. + If the nation is 'US' and the state is 'All', the 'All' is removed from the list. + The idea behind these filters is that if a specific state in the US is provided, + there's no need to download map data for the entire US. Conversely, + if the state is unspecified (i.e., 'All'), we intend to download map data for the whole US, + and 'All' isn't a valid state name, so it's removed. + + Parameters: + nations (list): A list of nations for which the map data is to be downloaded. + states (list, optional): A list of states for which the map data is to be downloaded. Defaults to None. + + Returns: + tuple: Two lists. The first list is filtered nations and the second list is filtered states. + """ + + if "US" in nations and states and not any(x.lower() == "all" for x in states): + # If a specific state in the US is provided, remove 'US' from nations + nations.remove("US") + elif "US" in nations and states and any(x.lower() == "all" for x in states): + # If 'All' is provided as a state (case invariant), remove those instances from states + states = [x for x in states if x.lower() != "all"] + elif "US" not in nations and states and any(x.lower() == "all" for x in states): + states.remove("All") + return nations, states or [] + + +def update_osm_db() -> None: + # last_downloaded_date = float(params.get('OsmDownloadedDate', encoding='utf-8') or 0.0) + # if params.get_bool("OsmDbUpdatesCheck") or time.time() - last_downloaded_date >= 604800: # 7 days * 24 hours/day * 60 + if params.get_bool("OsmDbUpdatesCheck"): + cleanup_old_osm_data(get_files_for_cleanup()) + country = params.get('OsmLocationName', encoding='utf-8') + state = params.get('OsmStateName', encoding='utf-8') or "All" + filtered_nations, filtered_states = filter_nations_and_states([country], [state]) + request_refresh_osm_location_data(filtered_nations, filtered_states) + + if not mem_params.get("OSMDownloadBounds"): + mem_params.put("OSMDownloadBounds", "") + + if not mem_params.get("LastGPSPosition"): + mem_params.put("LastGPSPosition", "{}") + + +def main_thread(): + config_realtime_process([0, 1, 2, 3], 5) + + rk = Ratekeeper(1, print_delay_threshold=None) + live_map_sp = OsmMapData() + + # Create folder needed for OSM + try: + os.mkdir(Paths.mapd_root()) + except FileExistsError: + pass + except PermissionError: + cloudlog.exception(f"mapd: failed to make {Paths.mapd_root()}") + + while True: + show_alert = get_files_for_cleanup() and params.get_bool("OsmLocal") + set_offroad_alert("Offroad_OSMUpdateRequired", show_alert, "This alert will be cleared when new maps are downloaded.") + + update_osm_db() + live_map_sp.tick() + rk.keep_time() + + +def main(): + main_thread() + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/modeld/modeld.py b/sunnypilot/modeld/modeld.py index 2ac3f5700e..d4bd96ff66 100755 --- a/sunnypilot/modeld/modeld.py +++ b/sunnypilot/modeld/modeld.py @@ -25,6 +25,7 @@ from openpilot.sunnypilot.modeld.parse_model_outputs import Parser from openpilot.sunnypilot.modeld.fill_model_msg import fill_model_msg, fill_pose_msg, PublishState from openpilot.sunnypilot.modeld.constants import ModelConstants, Plan from openpilot.sunnypilot.models.helpers import get_active_bundle, get_model_path, load_metadata, prepare_inputs, load_meta_constants +from openpilot.sunnypilot.models.modeld_lagd import ModeldLagd from openpilot.sunnypilot.modeld.models.commonmodel_pyx import ModelFrame, CLContext @@ -201,8 +202,9 @@ def main(demo=False): cloudlog.info("modeld got CarParams: %s", CP.brand) + modeld_lagd = ModeldLagd() + # Enable lagd support for sunnypilot modeld - steer_delay = sm["liveDelay"].lateralDelay + model.LAT_SMOOTH_SECONDS long_delay = CP.longitudinalActuatorDelay + model.LONG_SMOOTH_SECONDS prev_action = log.ModelDataV2.Action() @@ -246,6 +248,8 @@ def main(demo=False): v_ego = sm["carState"].vEgo is_rhd = sm["driverMonitoringState"].isRHD frame_id = sm["roadCameraState"].frameId + steer_delay = modeld_lagd.lagd_main(CP, sm, model) + if sm.updated["liveCalibration"] and sm.seen['roadCameraState'] and sm.seen['deviceState']: device_from_calib_euler = np.array(sm["liveCalibration"].rpyCalib, dtype=np.float32) dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] diff --git a/sunnypilot/modeld_v2/modeld.py b/sunnypilot/modeld_v2/modeld.py index 4c8acafcad..fe322f462e 100755 --- a/sunnypilot/modeld_v2/modeld.py +++ b/sunnypilot/modeld_v2/modeld.py @@ -23,6 +23,7 @@ from openpilot.sunnypilot.modeld_v2.models.commonmodel_pyx import DrivingModelFr from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants from openpilot.sunnypilot.models.helpers import get_active_bundle +from openpilot.sunnypilot.models.modeld_lagd import ModeldLagd from openpilot.sunnypilot.models.runners.helpers import get_model_runner PROCESS_NAME = "selfdrive.modeld.modeld" @@ -238,6 +239,9 @@ def main(demo=False): CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) cloudlog.info("modeld got CarParams: %s", CP.brand) + + modeld_lagd = ModeldLagd() + # TODO Move smooth seconds to action function long_delay = CP.longitudinalActuatorDelay + model.LONG_SMOOTH_SECONDS prev_action = log.ModelDataV2.Action() @@ -282,7 +286,7 @@ def main(demo=False): is_rhd = sm["driverMonitoringState"].isRHD frame_id = sm["roadCameraState"].frameId v_ego = max(sm["carState"].vEgo, 0.) - steer_delay = sm["liveDelay"].lateralDelay + model.LAT_SMOOTH_SECONDS + steer_delay = modeld_lagd.lagd_main(CP, sm, model) if sm.updated["liveCalibration"] and sm.seen['roadCameraState'] and sm.seen['deviceState']: device_from_calib_euler = np.array(sm["liveCalibration"].rpyCalib, dtype=np.float32) dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] diff --git a/sunnypilot/models/modeld_lagd.py b/sunnypilot/models/modeld_lagd.py new file mode 100644 index 0000000000..9e538123a8 --- /dev/null +++ b/sunnypilot/models/modeld_lagd.py @@ -0,0 +1,26 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog + + +class ModeldLagd: + def __init__(self): + self.params = Params() + + def lagd_main(self, CP, sm, model): + if self.params.get_bool("LagdToggle"): + lateral_delay = sm["liveDelay"].lateralDelay + lat_smooth = model.LAT_SMOOTH_SECONDS + result = lateral_delay + lat_smooth + cloudlog.debug(f"LAGD USING LIVE DELAY: {lateral_delay:.3f} + {lat_smooth:.3f} = {result:.3f}") + return result + steer_actuator_delay = CP.steerActuatorDelay + lat_smooth = model.LAT_SMOOTH_SECONDS + result = (steer_actuator_delay + 0.2) + lat_smooth + cloudlog.debug(f"LAGD USING STEER ACTUATOR: {steer_actuator_delay:.3f} + 0.2 + {lat_smooth:.3f} = {result:.3f}") + return result diff --git a/sunnypilot/navd/helpers.py b/sunnypilot/navd/helpers.py new file mode 100644 index 0000000000..85894fb2ab --- /dev/null +++ b/sunnypilot/navd/helpers.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import json +import math +import numpy as np +from typing import Any, cast + +from openpilot.common.conversions import Conversions +from openpilot.common.params import Params + +DIRECTIONS = ('left', 'right', 'straight') +MODIFIABLE_DIRECTIONS = ('left', 'right') + +EARTH_MEAN_RADIUS = 6371007.2 +SPEED_CONVERSIONS = { + 'km/h': Conversions.KPH_TO_MS, + 'mph': Conversions.MPH_TO_MS, +} + + +class Coordinate: + def __init__(self, latitude: float, longitude: float) -> None: + self.latitude = latitude + self.longitude = longitude + self.annotations: dict[str, float] = {} + + @classmethod + def from_mapbox_tuple(cls, t: tuple[float, float]) -> Coordinate: + return cls(t[1], t[0]) + + def as_dict(self) -> dict[str, float]: + return {'latitude': self.latitude, 'longitude': self.longitude} + + def __str__(self) -> str: + return f'Coordinate({self.latitude}, {self.longitude})' + + def __repr__(self) -> str: + return self.__str__() + + def __eq__(self, other) -> bool: + if not isinstance(other, Coordinate): + return False + return (self.latitude == other.latitude) and (self.longitude == other.longitude) + + def __sub__(self, other: Coordinate) -> Coordinate: + return Coordinate(self.latitude - other.latitude, self.longitude - other.longitude) + + def __add__(self, other: Coordinate) -> Coordinate: + return Coordinate(self.latitude + other.latitude, self.longitude + other.longitude) + + def __mul__(self, c: float) -> Coordinate: + return Coordinate(self.latitude * c, self.longitude * c) + + def dot(self, other: Coordinate) -> float: + return self.latitude * other.latitude + self.longitude * other.longitude + + def distance_to(self, other: Coordinate) -> float: + # Haversine formula + dlat = math.radians(other.latitude - self.latitude) + dlon = math.radians(other.longitude - self.longitude) + + haversine_dlat = math.sin(dlat / 2.0) + haversine_dlat *= haversine_dlat + haversine_dlon = math.sin(dlon / 2.0) + haversine_dlon *= haversine_dlon + + y = haversine_dlat \ + + math.cos(math.radians(self.latitude)) \ + * math.cos(math.radians(other.latitude)) \ + * haversine_dlon + x = 2 * math.asin(math.sqrt(y)) + return x * EARTH_MEAN_RADIUS + + +def minimum_distance(a: Coordinate, b: Coordinate, p: Coordinate): + if a.distance_to(b) < 0.01: + return a.distance_to(p) + + ap = p - a + ab = b - a + t = np.clip(ap.dot(ab) / ab.dot(ab), 0.0, 1.0) + projection = a + ab * t + return projection.distance_to(p) + + +def distance_along_geometry(geometry: list[Coordinate], pos: Coordinate) -> float: + if len(geometry) <= 2: + return geometry[0].distance_to(pos) + + # 1. Find segment that is closest to current position + # 2. Total distance is sum of distance to start of closest segment + # + all previous segments + total_distance = 0.0 + total_distance_closest = 0.0 + closest_distance = 1e9 + + for i in range(len(geometry) - 1): + d = minimum_distance(geometry[i], geometry[i + 1], pos) + + if d < closest_distance: + closest_distance = d + total_distance_closest = total_distance + geometry[i].distance_to(pos) + + total_distance += geometry[i].distance_to(geometry[i + 1]) + + return total_distance_closest + + +def coordinate_from_param(param: str, params: Params = None) -> Coordinate | None: + if params is None: + params = Params() + + json_str = params.get(param) + if json_str is None: + return None + + pos = json.loads(json_str) + if 'latitude' not in pos or 'longitude' not in pos: + return None + + return Coordinate(pos['latitude'], pos['longitude']) + + +def string_to_direction(direction: str) -> str: + for d in DIRECTIONS: + if d in direction: + if 'slight' in direction and d in MODIFIABLE_DIRECTIONS: + return 'slight' + d.capitalize() + return d + return 'none' + + +def maxspeed_to_ms(maxspeed: dict[str, str | float]) -> float: + unit = cast(str, maxspeed['unit']) + speed = cast(float, maxspeed['speed']) + return SPEED_CONVERSIONS[unit] * speed + + +def field_valid(dat: dict, field: str) -> bool: + return field in dat and dat[field] is not None + + +def parse_banner_instructions(banners: Any, distance_to_maneuver: float = 0.0) -> dict[str, Any] | None: + if not len(banners): + return None + + instruction = {} + + # A segment can contain multiple banners, find one that we need to show now + current_banner = banners[0] + for banner in banners: + if distance_to_maneuver < banner['distanceAlongGeometry']: + current_banner = banner + + # Only show banner when close enough to maneuver + instruction['showFull'] = distance_to_maneuver < current_banner['distanceAlongGeometry'] + + # Primary + p = current_banner['primary'] + if field_valid(p, 'text'): + instruction['maneuverPrimaryText'] = p['text'] + if field_valid(p, 'type'): + instruction['maneuverType'] = p['type'] + if field_valid(p, 'modifier'): + instruction['maneuverModifier'] = p['modifier'] + + # Secondary + if field_valid(current_banner, 'secondary'): + instruction['maneuverSecondaryText'] = current_banner['secondary']['text'] + + # Lane lines + if field_valid(current_banner, 'sub'): + lanes = [] + for component in current_banner['sub']['components']: + if component['type'] != 'lane': + continue + + lane = { + 'active': component['active'], + 'directions': [string_to_direction(d) for d in component['directions']], + } + + if field_valid(component, 'active_direction'): + lane['activeDirection'] = string_to_direction(component['active_direction']) + + lanes.append(lane) + instruction['lanes'] = lanes + + return instruction diff --git a/sunnypilot/selfdrive/assets/images/spinner_sunnypilot.png b/sunnypilot/selfdrive/assets/images/spinner_sunnypilot.png new file mode 100644 index 0000000000..edc5c6e5c2 --- /dev/null +++ b/sunnypilot/selfdrive/assets/images/spinner_sunnypilot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9dbacb55581a5ea8cbcd5cea0560ec21d96ac7185463a40fff0f81bebf044ffa +size 23187 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_exit_offroad.png b/sunnypilot/selfdrive/assets/offroad/icon_exit_offroad.png new file mode 100644 index 0000000000..1b8308a0ac --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_exit_offroad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d44f71f7603b106986fff7835f35c4c18c4c619a29e6292f8626ae3eedd85e57 +size 7811 diff --git a/sunnypilot/selfdrive/assets/offroad/icon_map.png b/sunnypilot/selfdrive/assets/offroad/icon_map.png new file mode 100644 index 0000000000..82c0236a48 --- /dev/null +++ b/sunnypilot/selfdrive/assets/offroad/icon_map.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57a92adcf88c7223b07697f8c2b315f4f4a34b32a866284610d5250144863c6f +size 28235 diff --git a/system/hardware/hardwared.py b/system/hardware/hardwared.py index 38cbe8c991..223faeaafd 100755 --- a/system/hardware/hardwared.py +++ b/system/hardware/hardwared.py @@ -323,7 +323,6 @@ def hardware_thread(end_event, hw_queue) -> None: offroad_mode = params.get_bool("OffroadMode") startup_conditions["not_always_offroad"] = not offroad_mode onroad_conditions["not_always_offroad"] = not offroad_mode - set_offroad_alert("OffroadMode_Status", offroad_mode) # if the temperature enters the danger zone, go offroad to cool down onroad_conditions["device_temp_good"] = thermal_status < ThermalStatus.danger diff --git a/system/hardware/hw.py b/system/hardware/hw.py index 412c1f867e..d24857e8bd 100644 --- a/system/hardware/hw.py +++ b/system/hardware/hw.py @@ -81,3 +81,10 @@ class Paths: return str(Path(Paths.comma_home()) / "community" / "crashes") else: return "/data/community/crashes" + + @staticmethod + def mapd_root() -> str: + if PC: + return str(Path(Paths.comma_home()) / "media" / "0" / "osm") + else: + return "/data/media/0/osm" diff --git a/system/manager/manager.py b/system/manager/manager.py index 46b849108c..31bb43e3cf 100755 --- a/system/manager/manager.py +++ b/system/manager/manager.py @@ -18,6 +18,8 @@ from openpilot.common.swaglog import cloudlog, add_file_handler from openpilot.system.version import get_build_metadata, terms_version, training_version from openpilot.system.hardware.hw import Paths +from openpilot.sunnypilot.mapd.mapd_installer import VERSION + def manager_init() -> None: save_bootlog() @@ -44,21 +46,30 @@ def manager_init() -> None: sunnypilot_default_params: list[tuple[str, str | bytes]] = [ ("AutoLaneChangeTimer", "0"), ("AutoLaneChangeBsmDelay", "0"), + ("BlindSpot", "0"), ("BlinkerMinLateralControlSpeed", "20"), # MPH or km/h ("BlinkerPauseLateralControl", "0"), + ("DeviceBootMode", "0"), ("DynamicExperimentalControl", "0"), ("HyundaiLongitudinalTuning", "0"), + ("LagdToggle", "1"), ("Mads", "1"), ("MadsMainCruiseAllowed", "1"), ("MadsSteeringMode", "0"), ("MadsUnifiedEngagementMode", "1"), + ("MapdVersion", f"{VERSION}"), ("MaxTimeOffroad", "1800"), + ("Brightness", "0"), ("ModelManager_LastSyncTime", "0"), ("ModelManager_ModelsCache", ""), ("NeuralNetworkLateralControl", "0"), ("QuietMode", "0"), ] + # device boot mode + if params.get("DeviceBootMode") == b"1": # start in always offroad mode + params.put_bool("OffroadMode", True) + if params.get_bool("RecordFrontLock"): params.put_bool("RecordFront", True) diff --git a/system/manager/process.py b/system/manager/process.py index 7f8044461d..0ebddae049 100644 --- a/system/manager/process.py +++ b/system/manager/process.py @@ -16,9 +16,8 @@ import openpilot.system.sentry as sentry from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog -from openpilot.system.hardware.hw import Paths +from openpilot.common.watchdog import WATCHDOG_FN -WATCHDOG_FN = f"{Paths.shm_path()}/wd_" ENABLE_WATCHDOG = os.getenv("NO_WATCHDOG") is None diff --git a/system/manager/process_config.py b/system/manager/process_config.py index d834a8438f..2e3f754a76 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -1,10 +1,14 @@ import os import operator +import platform from cereal import car, custom from openpilot.common.params import Params from openpilot.system.hardware import PC, TICI from openpilot.system.manager.process import PythonProcess, NativeProcess, DaemonProcess +from openpilot.system.hardware.hw import Paths + +from openpilot.sunnypilot.mapd.mapd_manager import MAPD_PATH from sunnypilot.models.helpers import get_active_model_runner from sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, use_sunnylink_uploader @@ -100,8 +104,8 @@ procs = [ NativeProcess("camerad", "system/camerad", ["./camerad"], driverview, enabled=not WEBCAM), PythonProcess("webcamerad", "tools.webcam.camerad", driverview, enabled=WEBCAM), - NativeProcess("logcatd", "system/logcatd", ["./logcatd"], only_onroad), - NativeProcess("proclogd", "system/proclogd", ["./proclogd"], only_onroad), + NativeProcess("logcatd", "system/logcatd", ["./logcatd"], only_onroad, platform.system() != "Darwin"), + NativeProcess("proclogd", "system/proclogd", ["./proclogd"], only_onroad, platform.system() != "Darwin"), PythonProcess("micd", "system.micd", iscar), PythonProcess("timed", "system.timed", always_run, enabled=not PC), @@ -156,6 +160,10 @@ procs += [ # Backup PythonProcess("backup_manager", "sunnypilot.sunnylink.backups.manager", and_(only_offroad, sunnylink_ready_shim)), + + # mapd + NativeProcess("mapd", Paths.mapd_root(), [MAPD_PATH], always_run), + PythonProcess("mapd_manager", "sunnypilot.mapd.mapd_manager", always_run), ] if os.path.exists("./github_runner.sh"): diff --git a/system/ui/lib/button.py b/system/ui/lib/button.py index 9ca82c9732..024e360448 100644 --- a/system/ui/lib/button.py +++ b/system/ui/lib/button.py @@ -1,6 +1,7 @@ import pyray as rl from enum import IntEnum from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached class ButtonStyle(IntEnum): @@ -99,7 +100,7 @@ def gui_button( # Handle icon and text positioning font = gui_app.font(font_weight) - text_size = rl.measure_text_ex(font, text, font_size, 0) + text_size = measure_text_cached(font, text, font_size) text_pos = rl.Vector2(0, rect.y + (rect.height - text_size.y) // 2) # Vertical centering # Draw icon if provided diff --git a/system/ui/lib/inputbox.py b/system/ui/lib/inputbox.py index 367e0dc7e4..ae036c03c2 100644 --- a/system/ui/lib/inputbox.py +++ b/system/ui/lib/inputbox.py @@ -1,6 +1,7 @@ import pyray as rl import time from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.text_measure import measure_text_cached PASSWORD_MASK_CHAR = "•" @@ -60,7 +61,7 @@ class InputBox: padding = 10 if self._cursor_position > 0: - cursor_x = rl.measure_text_ex(font, display_text[: self._cursor_position], self._font_size, 0).x + cursor_x = measure_text_cached(font, display_text[: self._cursor_position], self._font_size).x else: cursor_x = 0 @@ -141,7 +142,7 @@ class InputBox: if self._show_cursor: cursor_x = rect.x + padding if len(display_text) > 0 and self._cursor_position > 0: - cursor_x += rl.measure_text_ex(font, display_text[: self._cursor_position], font_size, 0).x + cursor_x += measure_text_cached(font, display_text[: self._cursor_position], font_size).x # Apply text offset to cursor position cursor_x -= self._text_offset @@ -182,7 +183,7 @@ class InputBox: min_distance = float('inf') for i in range(len(self._input_text) + 1): - char_width = rl.measure_text_ex(font, display_text[:i], font_size, 0).x + char_width = measure_text_cached(font, display_text[:i], font_size).x distance = abs(relative_x - char_width) if distance < min_distance: min_distance = distance diff --git a/system/ui/lib/label.py b/system/ui/lib/label.py index 00a4eda7c6..82533660de 100644 --- a/system/ui/lib/label.py +++ b/system/ui/lib/label.py @@ -1,5 +1,6 @@ import pyray as rl from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR +from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.utils import GuiStyleContext @@ -14,7 +15,7 @@ def gui_label( elide_right: bool = True ): font = gui_app.font(font_weight) - text_size = rl.measure_text_ex(font, text, font_size, 0) + text_size = measure_text_cached(font, text, font_size) display_text = text # Elide text to fit within the rectangle @@ -24,13 +25,13 @@ def gui_label( while left < right: mid = (left + right) // 2 candidate = text[:mid] + ellipsis - candidate_size = rl.measure_text_ex(font, candidate, font_size, 0) + candidate_size = measure_text_cached(font, candidate, font_size) if candidate_size.x <= rect.width: left = mid + 1 else: right = mid display_text = text[: left - 1] + ellipsis if left > 0 else ellipsis - text_size = rl.measure_text_ex(font, display_text, font_size, 0) + text_size = measure_text_cached(font, display_text, font_size) # Calculate horizontal position based on alignment text_x = rect.x + { diff --git a/system/ui/lib/list_view.py b/system/ui/lib/list_view.py new file mode 100644 index 0000000000..9ca2363bc7 --- /dev/null +++ b/system/ui/lib/list_view.py @@ -0,0 +1,380 @@ +import os +import pyray as rl +from dataclasses import dataclass +from collections.abc import Callable +from abc import ABC, abstractmethod +from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.lib.button import gui_button +from openpilot.system.ui.lib.toggle import Toggle +from openpilot.system.ui.lib.toggle import WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT + + +LINE_PADDING = 40 +LINE_COLOR = rl.GRAY +ITEM_PADDING = 20 +ITEM_SPACING = 80 +ITEM_BASE_HEIGHT = 170 +ITEM_TEXT_FONT_SIZE = 50 +ITEM_TEXT_COLOR = rl.WHITE +ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255) +ITEM_DESC_FONT_SIZE = 40 +ITEM_DESC_V_OFFSET = 130 +RIGHT_ITEM_PADDING = 20 +ICON_SIZE = 80 +BUTTON_WIDTH = 250 +BUTTON_HEIGHT = 100 +BUTTON_BORDER_RADIUS = 50 +BUTTON_FONT_SIZE = 35 +BUTTON_FONT_WEIGHT = FontWeight.MEDIUM + + +# Abstract base class for right-side items +class RightItem(ABC): + def __init__(self, width: int = 100): + self.width = width + self.enabled = True + + @abstractmethod + def draw(self, rect: rl.Rectangle) -> bool: + pass + + @abstractmethod + def get_width(self) -> int: + pass + + +class ToggleRightItem(RightItem): + def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH): + super().__init__(width) + self.toggle = Toggle(initial_state=initial_state) + self.state = initial_state + self.enabled = True + + def draw(self, rect: rl.Rectangle) -> bool: + if self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)): + self.state = not self.state + return True + return False + + def get_width(self) -> int: + return self.width + + def set_state(self, state: bool): + self.state = state + self.toggle.set_state(state) + + def get_state(self) -> bool: + return self.state + + def set_enabled(self, enabled: bool): + self.enabled = enabled + + +class ButtonRightItem(RightItem): + def __init__(self, text: str, width: int = BUTTON_WIDTH): + super().__init__(width) + self.text = text + self.enabled = True + + def draw(self, rect: rl.Rectangle) -> bool: + return ( + gui_button( + rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT), + self.text, + border_radius=BUTTON_BORDER_RADIUS, + font_weight=BUTTON_FONT_WEIGHT, + font_size=BUTTON_FONT_SIZE, + is_enabled=self.enabled, + ) + == 1 + ) + + def get_width(self) -> int: + return self.width + + def set_enabled(self, enabled: bool): + self.enabled = enabled + + +class TextRightItem(RightItem): + def __init__(self, text: str, color: rl.Color = ITEM_TEXT_COLOR, font_size: int = ITEM_TEXT_FONT_SIZE): + self.text = text + self.color = color + self.font_size = font_size + + font = gui_app.font(FontWeight.NORMAL) + text_width = measure_text_cached(font, text, font_size).x + super().__init__(int(text_width + 20)) + + def draw(self, rect: rl.Rectangle) -> bool: + font = gui_app.font(FontWeight.NORMAL) + text_size = measure_text_cached(font, self.text, self.font_size) + + # Center the text in the allocated rectangle + text_x = rect.x + (rect.width - text_size.x) / 2 + text_y = rect.y + (rect.height - text_size.y) / 2 + + rl.draw_text_ex(font, self.text, rl.Vector2(text_x, text_y), self.font_size, 0, self.color) + return False + + def get_width(self) -> int: + return self.width + + def set_text(self, text: str): + self.text = text + font = gui_app.font(FontWeight.NORMAL) + text_width = measure_text_cached(font, text, self.font_size).x + self.width = int(text_width + 20) + + +@dataclass +class ListItem: + title: str + icon: str | None = None + description: str | None = None + description_visible: bool = False + rect: "rl.Rectangle | None" = None + callback: Callable | None = None + right_item: RightItem | None = None + + # Cached properties for performance + _wrapped_description: str | None = None + _description_height: float = 0 + + def get_right_item(self) -> RightItem | None: + return self.right_item + + def get_item_height(self, font: rl.Font, max_width: int) -> float: + if self.description_visible and self.description: + if not self._wrapped_description: + wrapped_lines = wrap_text(font, self.description, ITEM_DESC_FONT_SIZE, max_width) + self._wrapped_description = "\n".join(wrapped_lines) + self._description_height = len(wrapped_lines) * 20 + 10 # Line height + padding + return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_SPACING + return ITEM_BASE_HEIGHT + + def get_content_width(self, total_width: int) -> int: + if self.right_item: + return total_width - self.right_item.get_width() - RIGHT_ITEM_PADDING + return total_width + + def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: + if not self.right_item: + return rl.Rectangle(0, 0, 0, 0) + + right_width = self.right_item.get_width() + right_x = item_rect.x + item_rect.width - right_width + right_y = item_rect.y + return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT) + + +class ListView: + def __init__(self, items: list[ListItem]): + self._items: list[ListItem] = items + self._last_dim: tuple[float, float] = (0, 0) + self.scroll_panel = GuiScrollPanel() + + self._font_normal = gui_app.font(FontWeight.NORMAL) + + # Interaction state + self._hovered_item: int = -1 + self._last_mouse_pos = rl.Vector2(0, 0) + + self._total_height: float = 0 + self._visible_range = (0, 0) + + def invalid_height_cache(self): + self._last_dim = (0, 0) + + def render(self, rect: rl.Rectangle): + if self._last_dim != (rect.width, rect.height): + self._update_item_rects(rect) + self._last_dim = (rect.width, rect.height) + + # Update layout and handle scrolling + content_rect = rl.Rectangle(rect.x, rect.y, rect.width, self._total_height) + scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect) + + # Handle mouse interaction + if self.scroll_panel.is_click_valid(): + self._handle_mouse_interaction(rect, scroll_offset) + + # Set scissor mode for clipping + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + + # Calculate visible range for performance + self._calculate_visible_range(rect, -scroll_offset.y) + + # Render only visible items + for i in range(self._visible_range[0], min(self._visible_range[1], len(self._items))): + item = self._items[i] + if item.rect: + adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height) + self._render_item(item, adjusted_rect, i) + + if i != len(self._items) - 1: + rl.draw_line_ex( + rl.Vector2(adjusted_rect.x + LINE_PADDING, adjusted_rect.y + adjusted_rect.height - 1), + rl.Vector2( + adjusted_rect.x + adjusted_rect.width - LINE_PADDING * 2, adjusted_rect.y + adjusted_rect.height - 1 + ), + 1.0, + LINE_COLOR, + ) + rl.end_scissor_mode() + + def _render_item(self, item: ListItem, rect: rl.Rectangle, index: int): + content_x = rect.x + ITEM_PADDING + text_x = content_x + + # Calculate available width for main content + content_width = item.get_content_width(int(rect.width - ITEM_PADDING * 2)) + + # Draw icon if present + if item.icon: + icon_texture = gui_app.texture(os.path.join("icons", item.icon), ICON_SIZE, ICON_SIZE) + rl.draw_texture( + icon_texture, int(content_x), int(rect.y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE + ) + text_x += ICON_SIZE + ITEM_PADDING + + # Draw main text + text_size = measure_text_cached(self._font_normal, item.title, ITEM_TEXT_FONT_SIZE) + item_y = rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2 + rl.draw_text_ex(self._font_normal, item.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR) + + # Draw description if visible (adjust width for right item) + if item.description_visible and item._wrapped_description: + desc_y = rect.y + ITEM_DESC_V_OFFSET + desc_max_width = int(content_width - (text_x - content_x)) + + # Re-wrap description if needed due to right item + if (item.right_item and item.description) and not item._wrapped_description: + wrapped_lines = wrap_text(self._font_normal, item.description, ITEM_DESC_FONT_SIZE, desc_max_width) + item._wrapped_description = "\n".join(wrapped_lines) + + rl.draw_text_ex( + self._font_normal, + item._wrapped_description, + rl.Vector2(text_x, desc_y), + ITEM_DESC_FONT_SIZE, + 0, + ITEM_DESC_TEXT_COLOR, + ) + + # Draw right item if present + if item.right_item: + right_rect = item.get_right_item_rect(rect) + # Adjust for scroll offset + right_rect.y = right_rect.y + if item.right_item.draw(right_rect): + # Right item was clicked/activated + if item.callback: + item.callback() + + def _update_item_rects(self, container_rect: rl.Rectangle) -> None: + current_y: float = 0.0 + self._total_height = 0 + + for item in self._items: + content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2)) + item_height = item.get_item_height(self._font_normal, content_width) + item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, item_height) + current_y += item_height + self._total_height += item_height + + def _calculate_visible_range(self, rect: rl.Rectangle, scroll_offset: float): + if not self._items: + self._visible_range = (0, 0) + return + + visible_top = scroll_offset + visible_bottom = scroll_offset + rect.height + + start_idx = 0 + end_idx = len(self._items) + + # Find first visible item + for i, item in enumerate(self._items): + if item.rect and item.rect.y + item.rect.height >= visible_top: + start_idx = max(0, i - 1) + break + + # Find last visible item + for i in range(start_idx, len(self._items)): + item = self._items[i] + if item.rect and item.rect.y > visible_bottom: + end_idx = min(len(self._items), i + 2) + break + + self._visible_range = (start_idx, end_idx) + + def _handle_mouse_interaction(self, rect: rl.Rectangle, scroll_offset: rl.Vector2): + mouse_pos = rl.get_mouse_position() + + self._hovered_item = -1 + if not rl.check_collision_point_rec(mouse_pos, rect): + return + + content_mouse_y = mouse_pos.y - rect.y - scroll_offset.y + + for i, item in enumerate(self._items): + if item.rect: + # Check if mouse is within this item's bounds in content space + if ( + mouse_pos.x >= rect.x + and mouse_pos.x <= rect.x + rect.width + and content_mouse_y >= item.rect.y + and content_mouse_y <= item.rect.y + item.rect.height + ): + item_screen_y = item.rect.y + scroll_offset.y + if item_screen_y < rect.height and item_screen_y + item.rect.height > 0: + self._hovered_item = i + break + + # Handle click on main item (not right item) + if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._hovered_item >= 0: + item = self._items[self._hovered_item] + + # Check if click was on right item area + if item.right_item and item.rect: + adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height) + right_rect = item.get_right_item_rect(adjusted_rect) + if rl.check_collision_point_rec(mouse_pos, right_rect): + # Click was handled by right item, don't process main item click + return + + # Toggle description visibility if item has description + if item.description: + item.description_visible = not item.description_visible + # Force layout update when description visibility changes + self._last_dim = (0, 0) + + # Call item callback + if item.callback: + item.callback() + + +# Factory functions +def simple_item(title: str, callback: Callable | None = None) -> ListItem: + return ListItem(title=title, callback=callback) + + +def toggle_item( + title: str, description: str = None, initial_state: bool = False, callback: Callable | None = None, icon: str = "" +) -> ListItem: + toggle = ToggleRightItem(initial_state=initial_state) + return ListItem(title=title, description=description, right_item=toggle, icon=icon, callback=callback) + + +def button_item(title: str, button_text: str, description: str = None, callback: Callable | None = None) -> ListItem: + button = ButtonRightItem(text=button_text) + return ListItem(title=title, description=description, right_item=button, callback=callback) + + +def text_item(title: str, value: str, description: str = None, callback: Callable | None = None) -> ListItem: + text_item = TextRightItem(text=value, color=rl.Color(170, 170, 170, 255)) + return ListItem(title=title, description=description, right_item=text_item, callback=callback) diff --git a/system/ui/lib/text_measure.py b/system/ui/lib/text_measure.py index 9cdd566409..c172f94251 100644 --- a/system/ui/lib/text_measure.py +++ b/system/ui/lib/text_measure.py @@ -4,10 +4,11 @@ _cache: dict[int, rl.Vector2] = {} def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: int = 0) -> rl.Vector2: + """Caches text measurements to avoid redundant calculations.""" key = hash((font.texture.id, text, font_size, spacing)) if key in _cache: return _cache[key] - result = rl.measure_text_ex(font, text, font_size, spacing) + result = rl.measure_text_ex(font, text, font_size, spacing) # noqa: TID251 _cache[key] = result return result diff --git a/system/ui/lib/toggle.py b/system/ui/lib/toggle.py index 5ae2500406..5b3fbf277c 100644 --- a/system/ui/lib/toggle.py +++ b/system/ui/lib/toggle.py @@ -1,56 +1,75 @@ import pyray as rl -ON_COLOR = rl.Color(0, 255, 0, 255) +ON_COLOR = rl.Color(51, 171, 76, 255) OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255) KNOB_COLOR = rl.WHITE +DISABLED_ON_COLOR = rl.Color(0x22, 0x77, 0x22, 255) # Dark green when disabled + on +DISABLED_OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255) +DISABLED_KNOB_COLOR = rl.Color(0x88, 0x88, 0x88, 255) WIDTH, HEIGHT = 160, 80 BG_HEIGHT = 60 ANIMATION_SPEED = 8.0 class Toggle: - def __init__(self, x, y, initial_state=False): + def __init__(self, initial_state=False): self._state = initial_state - self._rect = rl.Rectangle(x, y, WIDTH, HEIGHT) + self._enabled = True + self._rect = rl.Rectangle(0, 0, WIDTH, HEIGHT) self._progress = 1.0 if initial_state else 0.0 self._target = self._progress def handle_input(self): + if not self._enabled: + return 0 + if rl.is_mouse_button_pressed(rl.MOUSE_LEFT_BUTTON): if rl.check_collision_point_rec(rl.get_mouse_position(), self._rect): self._state = not self._state self._target = 1.0 if self._state else 0.0 + return 1 + return 0 def get_state(self): return self._state + def set_state(self, state: bool): + self._state = state + self._target = 1.0 if state else 0.0 + + def set_enabled(self, enabled: bool): + self._enabled = enabled + + def is_enabled(self): + return self._enabled + def update(self): if abs(self._progress - self._target) > 0.01: delta = rl.get_frame_time() * ANIMATION_SPEED self._progress += delta if self._progress < self._target else -delta self._progress = max(0.0, min(1.0, self._progress)) - def render(self): - self. update() + def render(self, rect: rl.Rectangle): + self._rect.x, self._rect.y = rect.x, rect.y + self.update() + + if self._enabled: + bg_color = self._blend_color(OFF_COLOR, ON_COLOR, self._progress) + knob_color = KNOB_COLOR + else: + bg_color = self._blend_color(DISABLED_OFF_COLOR, DISABLED_ON_COLOR, self._progress) + knob_color = DISABLED_KNOB_COLOR + # Draw background bg_rect = rl.Rectangle(self._rect.x + 5, self._rect.y + 10, WIDTH - 10, BG_HEIGHT) - bg_color = self._blend_color(OFF_COLOR, ON_COLOR, self._progress) rl.draw_rectangle_rounded(bg_rect, 1.0, 10, bg_color) # Draw knob knob_x = self._rect.x + HEIGHT / 2 + (WIDTH - HEIGHT) * self._progress knob_y = self._rect.y + HEIGHT / 2 - rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, KNOB_COLOR) + rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, knob_color) + + return self.handle_input() def _blend_color(self, c1, c2, t): return rl.Color(int(c1.r + (c2.r - c1.r) * t), int(c1.g + (c2.g - c1.g) * t), int(c1.b + (c2.b - c1.b) * t), 255) - - -if __name__ == "__main__": - from openpilot.system.ui.lib.application import gui_app - - gui_app.init_window("Text toggle example") - toggle = Toggle(100, 100) - for _ in gui_app.render(): - toggle.handle_input() - toggle.render() diff --git a/system/ui/lib/wrap_text.py b/system/ui/lib/wrap_text.py new file mode 100644 index 0000000000..fdfb1970aa --- /dev/null +++ b/system/ui/lib/wrap_text.py @@ -0,0 +1,87 @@ +import pyray as rl +from openpilot.system.ui.lib.text_measure import measure_text_cached + +def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -> list[str]: + if not word: + return [] + + parts = [] + remaining = word + + while remaining: + if measure_text_cached(font, remaining, font_size).x <= max_width: + parts.append(remaining) + break + + # Binary search for the longest substring that fits + left, right = 1, len(remaining) + best_fit = 1 + + while left <= right: + mid = (left + right) // 2 + substring = remaining[:mid] + width = measure_text_cached(font, substring, font_size).x + + if width <= max_width: + best_fit = mid + left = mid + 1 + else: + right = mid - 1 + + # Add the part that fits + parts.append(remaining[:best_fit]) + remaining = remaining[best_fit:] + + return parts + + +def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[str]: + if not text or max_width <= 0: + return [] + + words = text.split() + if not words: + return [] + + lines: list[str] = [] + current_line: list[str] = [] + current_width = 0 + space_width = int(measure_text_cached(font, " ", font_size).x) + + for word in words: + word_width = int(measure_text_cached(font, word, font_size).x) + + # Check if word alone exceeds max width (need to break the word) + if word_width > max_width: + # Finish current line if it has content + if current_line: + lines.append(" ".join(current_line)) + current_line = [] + current_width = 0 + + # Break the long word into parts + lines.extend(_break_long_word(font, word, font_size, max_width)) + continue + + # Calculate width if we add this word + needed_width = current_width + if current_line: # Need space before word + needed_width += space_width + needed_width += word_width + + # Check if word fits on current line + if needed_width <= max_width: + current_line.append(word) + current_width = needed_width + else: + # Start new line with this word + if current_line: + lines.append(" ".join(current_line)) + current_line = [word] + current_width = word_width + + # Add remaining words + if current_line: + lines.append(" ".join(current_line)) + + return lines diff --git a/system/ui/spinner.py b/system/ui/spinner.py index 2af24c4e51..ed605885b0 100755 --- a/system/ui/spinner.py +++ b/system/ui/spinner.py @@ -4,9 +4,12 @@ import threading import time from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.window import BaseWindow from openpilot.system.ui.text import wrap_text +from openpilot.system.ui.sunnypilot.lib.application import gui_app_sp + # Constants PROGRESS_BAR_WIDTH = 1000 PROGRESS_BAR_HEIGHT = 20 @@ -24,7 +27,7 @@ def clamp(value, min_value, max_value): class SpinnerRenderer: def __init__(self): - self._comma_texture = gui_app.texture("images/spinner_comma.png", TEXTURE_SIZE, TEXTURE_SIZE) + self._comma_texture = gui_app_sp.sp_texture("images/spinner_sunnypilot.png", TEXTURE_SIZE, TEXTURE_SIZE) self._spinner_texture = gui_app.texture("images/spinner_track.png", TEXTURE_SIZE, TEXTURE_SIZE, alpha_premultiply=True) self._rotation = 0.0 self._progress: int | None = None @@ -78,7 +81,7 @@ class SpinnerRenderer: rl.draw_rectangle_rounded(bar, 1, 10, rl.WHITE) elif wrapped_lines: for i, line in enumerate(wrapped_lines): - text_size = rl.measure_text_ex(gui_app.font(), line, FONT_SIZE, 0.0) + text_size = measure_text_cached(gui_app.font(), line, FONT_SIZE) rl.draw_text_ex(gui_app.font(), line, rl.Vector2(center.x - text_size.x / 2, y_pos + i * LINE_HEIGHT), FONT_SIZE, 0.0, rl.WHITE) diff --git a/system/ui/sunnypilot/lib/application.py b/system/ui/sunnypilot/lib/application.py new file mode 100644 index 0000000000..10227ce9fd --- /dev/null +++ b/system/ui/sunnypilot/lib/application.py @@ -0,0 +1,21 @@ +from openpilot.system.ui.lib.application import GuiApplication +from importlib.resources import as_file, files + +ASSETS_DIR_SP = files("openpilot.sunnypilot.selfdrive").joinpath("assets") + +class GuiApplicationSP(GuiApplication): + + def __init__(self, width: int, height: int): + super().__init__(width, height) + + def sp_texture(self, asset_path: str, width: int, height: int, alpha_premultiply=False, keep_aspect_ratio=True): + cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}" + if cache_key in self._textures: + return self._textures[cache_key] + + with as_file(ASSETS_DIR_SP.joinpath(asset_path)) as fspath: + texture_obj = self._load_texture_from_image(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio) + self._textures[cache_key] = texture_obj + return texture_obj + +gui_app_sp = GuiApplicationSP(2160, 1080) diff --git a/system/ui/text.py b/system/ui/text.py index 82e64d836f..63d00fbde3 100755 --- a/system/ui/text.py +++ b/system/ui/text.py @@ -3,6 +3,7 @@ import re import time import pyray as rl from openpilot.system.hardware import HARDWARE, PC +from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.button import gui_button, ButtonStyle from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.application import gui_app @@ -33,7 +34,7 @@ def wrap_text(text, font_size, max_width): while len(words): word = words.pop(0) test_line = current_line + word + (words.pop(0) if words else "") - if rl.measure_text_ex(font, test_line, font_size, 0).x <= max_width: + if measure_text_cached(font, test_line, font_size).x <= max_width: current_line = test_line else: lines.append(current_line) diff --git a/third_party/mapd_pfeiferj/README.md b/third_party/mapd_pfeiferj/README.md new file mode 100644 index 0000000000..a814f11260 --- /dev/null +++ b/third_party/mapd_pfeiferj/README.md @@ -0,0 +1,2 @@ +# MAPD implementation by pfeiferj +https://github.com/pfeiferj/openpilot-mapd/releases/ diff --git a/third_party/mapd_pfeiferj/mapd b/third_party/mapd_pfeiferj/mapd new file mode 100755 index 0000000000..d24d9b757f Binary files /dev/null and b/third_party/mapd_pfeiferj/mapd differ diff --git a/tools/lib/framereader.py b/tools/lib/framereader.py index 76fb6786ae..a67baa8c27 100644 --- a/tools/lib/framereader.py +++ b/tools/lib/framereader.py @@ -209,7 +209,7 @@ class BaseFrameReader: def close(self): pass - def get(self, num, count=1, pix_fmt="yuv420p"): + def get(self, num, count=1, pix_fmt="rgb24"): raise NotImplementedError @@ -497,7 +497,7 @@ class GOPFrameReader(BaseFrameReader): return self.frame_cache[(num, pix_fmt)] - def get(self, num, count=1, pix_fmt="yuv420p"): + def get(self, num, count=1, pix_fmt="rgb24"): assert self.frame_count is not None if num + count > self.frame_count: @@ -523,12 +523,12 @@ class StreamFrameReader(StreamGOPReader, GOPFrameReader): GOPFrameReader.__init__(self, readahead, readbehind) -def GOPFrameIterator(gop_reader, pix_fmt): +def GOPFrameIterator(gop_reader, pix_fmt='rgb24'): dec = VideoStreamDecompressor(gop_reader.fn, gop_reader.vid_fmt, gop_reader.w, gop_reader.h, pix_fmt) yield from dec.read() -def FrameIterator(fn, pix_fmt, **kwargs): +def FrameIterator(fn, pix_fmt='rgb24', **kwargs): fr = FrameReader(fn, **kwargs) if isinstance(fr, GOPReader): yield from GOPFrameIterator(fr, pix_fmt) diff --git a/tools/webcam/README.md b/tools/webcam/README.md index f4b46d1f66..67ad2cc8cb 100644 --- a/tools/webcam/README.md +++ b/tools/webcam/README.md @@ -1,22 +1,22 @@ # Run openpilot with webcam on PC What's needed: -- Ubuntu 24.04 ([WSL2 is not supported](https://github.com/commaai/openpilot/issues/34216)) +- Ubuntu 24.04 ([WSL2 is not supported](https://github.com/commaai/openpilot/issues/34216)) or macOS - GPU (recommended) -- Two USB webcams, at least 720p and 78 degrees FOV (e.g. Logitech C920/C615) -- [Car harness](https://comma.ai/shop/products/comma-car-harness) with black panda to connect to your car -- [Panda paw](https://comma.ai/shop/products/panda-paw) or USB-A to USB-A cable to connect panda to your computer -That's it! +- One USB webcam, at least 720p and 78 degrees FOV (e.g. Logitech C920/C615, NexiGo N60) +- [Car harness](https://comma.ai/shop/products/comma-car-harness) +- [panda](https://comma.ai/shop/panda) +- USB-A to USB-A cable to connect panda to your computer ## Setup openpilot - Follow [this readme](../README.md) to install and build the requirements -- Install OpenCL Driver +- Install OpenCL Driver (Ubuntu) ``` sudo apt install pocl-opencl-icd ``` ## Connect the hardware -- Connect the road facing camera first, then the driver facing camera +- Connect the camera first - Connect your computer to panda ## GO @@ -24,12 +24,12 @@ sudo apt install pocl-opencl-icd USE_WEBCAM=1 system/manager/manager.py ``` - Start the car, then the UI should show the road webcam's view -- Adjust and secure the webcams. +- Adjust and secure the webcam - Finish calibration and engage! ## Specify Cameras -Use the `ROAD_CAM`, `DRIVER_CAM`, and optional `WIDE_CAM` environment variables to specify which camera is which (ie. `DRIVER_CAM=2` uses `/dev/video2` for the driver-facing camera): +Use the `ROAD_CAM` (default 0) and optional `DRIVER_CAM`, `WIDE_CAM` environment variables to specify which camera is which (ie. `ROAD_CAM=1` uses `/dev/video1`, on Ubuntu, for the road camera): ``` -USE_WEBCAM=1 ROAD_CAM=4 WIDE_CAM=6 system/manager/manager.py +USE_WEBCAM=1 ROAD_CAM=1 system/manager/manager.py ``` diff --git a/tools/webcam/camerad.py b/tools/webcam/camerad.py index a0916ed5ee..cb096dc00b 100755 --- a/tools/webcam/camerad.py +++ b/tools/webcam/camerad.py @@ -9,14 +9,19 @@ from cereal import messaging from openpilot.tools.webcam.camera import Camera from openpilot.common.realtime import Ratekeeper +ROAD_CAM = os.getenv("ROAD_CAM", "0") WIDE_CAM = os.getenv("WIDE_CAM") +DRIVER_CAM = os.getenv("DRIVER_CAM") + CameraType = namedtuple("CameraType", ["msg_name", "stream_type", "cam_id"]) + CAMERAS = [ - CameraType("roadCameraState", VisionStreamType.VISION_STREAM_ROAD, os.getenv("ROAD_CAM", "0")), - CameraType("driverCameraState", VisionStreamType.VISION_STREAM_DRIVER, os.getenv("DRIVER_CAM", "2")), + CameraType("roadCameraState", VisionStreamType.VISION_STREAM_ROAD, ROAD_CAM) ] if WIDE_CAM: CAMERAS.append(CameraType("wideRoadCameraState", VisionStreamType.VISION_STREAM_WIDE_ROAD, WIDE_CAM)) +if DRIVER_CAM: + CAMERAS.append(CameraType("driverCameraState", VisionStreamType.VISION_STREAM_DRIVER, DRIVER_CAM)) class Camerad: def __init__(self):