Merge branch 'master-new' into feature/external-storage

This commit is contained in:
royjr
2025-06-08 21:08:05 -04:00
committed by GitHub
92 changed files with 3742 additions and 149 deletions
+9 -1
View File
@@ -1,3 +1,11 @@
* @sunnypilot/dev-internal
/.github/ @devtekve @sunnyhaibin
/release/ci/ @devtekve @sunnyhaibin
/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
+3
View File
@@ -1,3 +1,6 @@
Version 0.9.10 (2025-06-30)
========================
Version 0.9.9 (2025-05-23)
========================
* New driving model
+1 -1
View File
@@ -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":
+7 -1
View File
@@ -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 {
+1 -1
View File
@@ -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;
+1
View File
@@ -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),
+27 -2
View File
@@ -71,7 +71,7 @@ inline static std::unordered_map<std::string, uint32_t> 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<std::string, uint32_t> 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<std::string, uint32_t> 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},
};
+1 -1
View File
@@ -1 +1 @@
#define COMMA_VERSION "0.9.9"
#define COMMA_VERSION "0.9.10"
+22
View File
@@ -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('<Q', int(current_time * 1e9)))
f.flush()
_LAST_KICK = current_time
except OSError:
pass
+1 -1
Submodule panda updated: 86cf5dc583...5ac4fa5bb0
+1
View File
@@ -260,6 +260,7 @@ lint.flake8-implicit-str-concat.allow-multiline = false
"tools".msg = "Use openpilot.tools"
"pytest.main".msg = "pytest.main requires special handling that is easy to mess up!"
"unittest".msg = "Use pytest"
"pyray.measure_text_ex".msg = "Use openpilot.system.ui.lib.text_measure"
[tool.coverage.run]
concurrency = ["multiprocessing", "thread"]
+3 -3
View File
@@ -45,8 +45,8 @@
"text": "sunnypilot detected a change in the device's mounting position. Ensure the device is fully seated in the mount and the mount is firmly secured to the windshield.",
"severity": 0
},
"OffroadMode_Status": {
"text": "sunnypilot is now in Always Offroad mode. sunnypilot won't start until Always Offroad mode is disabled. Go to \"Settings\" -> \"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
}
}
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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":
+204 -9
View File
@@ -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
+16 -12
View File
@@ -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)
+19
View File
@@ -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()
@@ -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
+47
View File
@@ -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
+35 -26
View File
@@ -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())
+21
View File
@@ -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
+68
View File
@@ -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)
+2 -1
View File
@@ -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
+11 -4
View File
@@ -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()
+83 -10
View File
@@ -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)
+6 -2
View File
@@ -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
+1 -1
View File
@@ -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);
+4 -2
View File
@@ -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;
+9 -1
View File
@@ -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")
@@ -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 <QNetworkReply>
#include <QJsonDocument>
#include <QJsonObject>
#include <QEventLoop>
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;
}
};
@@ -0,0 +1,100 @@
#include <QDebug>
#include <QHBoxLayout>
#include <QPainter>
#include <QPainterPath>
#include <QStyle>
#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());
}
@@ -0,0 +1,29 @@
#pragma once
#include <QLabel>
#include <QPushButton>
#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;
};
@@ -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 <QStackedWidget>
#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();
}
@@ -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();
};
@@ -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<QString, QString> 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);
}
@@ -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<QString, QString> brightness_options;
Brightness();
void refresh();
private:
Params params;
};
@@ -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));
}
@@ -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<QString, PushButtonSP*> 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 = "<font color='white'><b>" + def_str + "</b></font>";
} else if (status == DeviceSleepModeStatus::OFFROAD) {
offrd_str = "<font color='white'><b>" + offrd_str + "</b></font>";
}
return QString("%1<br><br>%2<br>%3")
.arg(tr("Controls state of the device after boot/sleep."))
.arg(def_str)
.arg(offrd_str);
}
};
@@ -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<uint32_t>());
@@ -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();
@@ -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 <algorithm> // for std::sort
#include <deque>
#include <vector>
#include <tuple>
#include <QDir>
#include <QJsonObject>
#include "selfdrive/ui/sunnypilot/qt/common/json_fetcher.h"
static const std::tuple<QString, QString> defaultLocation = std::make_tuple("== None ==", "");
// New class LocationsFetcher that handles web requests and JSON parsing
class LocationsFetcher {
public:
inline std::vector<std::tuple<QString, QString, QString, QString> >
getLocationsFromURL(const QUrl &url, const std::tuple<QString, QString> &customLocation = defaultLocation) const {
// Initialize an empty vector to hold the locations
std::vector<std::tuple<QString, QString, QString, QString> > 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<std::tuple<QString, QString, QString, QString> >
getLocationsFromURL(const QString &url, const std::tuple<QString, QString> &customLocation = defaultLocation) const {
return getLocationsFromURL(QUrl(url), customLocation);
}
inline std::vector<std::tuple<QString, QString, QString, QString> >
getOsmLocations(const std::tuple<QString, QString> &customLocation = defaultLocation) const {
return getLocationsFromURL( "https://raw.githubusercontent.com/pfeiferj/openpilot-mapd/main/nation_bounding_boxes.json", customLocation);
}
inline std::vector<std::tuple<QString, QString, QString, QString> >
getUsStatesLocations(const std::tuple<QString, QString> &customLocation = defaultLocation) const {
return getLocationsFromURL( "https://raw.githubusercontent.com/pfeiferj/openpilot-mapd/main/us_states_bounding_boxes.json", customLocation);
}
};
@@ -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 <QThread>
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<Model> ModelsFetcher::getModelsFromURL(const QUrl &url) {
std::vector<Model> 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<Model> ModelsFetcher::getModelsFromURL(const QString &url) {
return getModelsFromURL(QUrl(url));
}
std::vector<Model> ModelsFetcher::getModelsFromURL() {
return getModelsFromURL("https://docs.sunnypilot.ai/models_v5.json");
}
@@ -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 <algorithm> // for std::sort
#include <cassert>
#include <deque>
#include <vector>
#include <QDir>
#include <QJsonObject>
#include <QTimer>
#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<Model> getModelsFromURL(const QUrl &url);
static std::vector<Model> getModelsFromURL(const QString &url);
static std::vector<Model> 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;
};
@@ -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 <tuple>
#include <vector>
#include <string>
#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<std::tuple<QString, QString, QString, QString> > 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<QString, QString> allStatesOption = std::make_tuple("All States (~4.8 GB)", "All");
usStatesBtn->setEnabled(false);
usStatesBtn->setValue(tr("Fetching State list..."));
const std::vector<std::tuple<QString, QString, QString, QString> > 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<std::time_t>(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);
}
}
@@ -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 <deque>
#include <chrono>
#include <map>
#include <optional>
#include <string>
#include <tuple>
#include <vector>
#include <QDir>
#include <QFileInfo>
#include <QtConcurrent/QtConcurrent>
#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<std::string, ParamControlSP *> toggles;
std::optional<QFuture<quint64> > 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<std::chrono::system_clock::time_point> 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<std::tuple<QString, QString, QString, QString> > getOsmLocations(const std::tuple<QString, QString> &customLocation = defaultLocation) const {
return locationsFetcher.getOsmLocations(customLocation);
}
std::vector<std::tuple<QString, QString, QString, QString> > getUsStatesLocations(const std::tuple<QString, QString> &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<seconds>(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<double> rateHistory;
constexpr int minDataPoints = 3;
constexpr int historySize = 10;
static QString lastETA = tr("Calculating ETA...");
if (duration_cast<seconds>(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<seconds>(system_clock::now() - startTime).count();
if (elapsed == 0 || downloadedFiles == 0) return lastETA;
const double rate = downloadedFiles / static_cast<double>(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<long>((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<int>(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<double>(mb);
return QString::number(sizeMB, 'f', 2) + " MB";
} else {
const double sizeGB = size / static_cast<double>(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;
}
};
@@ -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"),
@@ -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 &param_name, const QString &param_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<std::tuple<QString, QString, QString, QString, bool> > 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();
}
}
@@ -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<std::string, ParamControlSP*> toggles;
ParamWatcher * param_watcher;
};
@@ -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());
}
@@ -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;
};
@@ -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);
@@ -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);
+8
View File
@@ -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);
}
}
+4
View File
@@ -84,6 +84,10 @@ class DeviceSP : public Device {
public:
DeviceSP(QObject *parent = 0);
private:
Params params;
void handleDisplayPowerChanged(bool on);
};
DeviceSP *deviceSP();
+19 -5
View File
@@ -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;
}
+4
View File
@@ -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()
+328
View File
@@ -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)
View File
+22
View File
@@ -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)
@@ -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()
+57
View File
@@ -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()
@@ -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
@@ -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()
+152
View File
@@ -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()
+147
View File
@@ -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()
+5 -1
View File
@@ -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))]
+5 -1
View File
@@ -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))]
+26
View File
@@ -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
+189
View File
@@ -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
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9dbacb55581a5ea8cbcd5cea0560ec21d96ac7185463a40fff0f81bebf044ffa
size 23187
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d44f71f7603b106986fff7835f35c4c18c4c619a29e6292f8626ae3eedd85e57
size 7811
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:57a92adcf88c7223b07697f8c2b315f4f4a34b32a866284610d5250144863c6f
size 28235
-1
View File
@@ -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
+7
View File
@@ -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"
+11
View File
@@ -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)
+1 -2
View File
@@ -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
+10 -2
View File
@@ -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"):
+2 -1
View File
@@ -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
+4 -3
View File
@@ -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
+4 -3
View File
@@ -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 + {
+380
View File
@@ -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)
+2 -1
View File
@@ -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
+36 -17
View File
@@ -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()
+87
View File
@@ -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
+5 -2
View File
@@ -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)
+21
View File
@@ -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)
+2 -1
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
# MAPD implementation by pfeiferj
https://github.com/pfeiferj/openpilot-mapd/releases/
BIN
View File
Binary file not shown.
+4 -4
View File
@@ -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)
+10 -10
View File
@@ -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
```
+7 -2
View File
@@ -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):