mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-08-05 15:35:40 +08:00
Merge branch 'master-new' into feature/external-storage
This commit is contained in:
+9
-1
@@ -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
|
||||
@@ -1,3 +1,6 @@
|
||||
Version 0.9.10 (2025-06-30)
|
||||
========================
|
||||
|
||||
Version 0.9.9 (2025-05-23)
|
||||
========================
|
||||
* New driving model
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
#define COMMA_VERSION "0.9.9"
|
||||
#define COMMA_VERSION "0.9.10"
|
||||
|
||||
@@ -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 opendbc_repo updated: e483ba24c5...3bd2bba8a7
+1
-1
Submodule panda updated: 86cf5dc583...5ac4fa5bb0
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 ¶m_name, const QString ¶m_value) {
|
||||
paramsRefresh();
|
||||
});
|
||||
|
||||
main_layout = new QStackedLayout(this);
|
||||
ListWidgetSP *list = new ListWidgetSP(this, false);
|
||||
|
||||
sunnypilotScreen = new QWidget(this);
|
||||
QVBoxLayout* vlayout = new QVBoxLayout(sunnypilotScreen);
|
||||
vlayout->setContentsMargins(50, 20, 50, 20);
|
||||
|
||||
std::vector<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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ class DeviceSP : public Device {
|
||||
|
||||
public:
|
||||
DeviceSP(QObject *parent = 0);
|
||||
|
||||
private:
|
||||
Params params;
|
||||
void handleDisplayPowerChanged(bool on);
|
||||
};
|
||||
|
||||
DeviceSP *deviceSP();
|
||||
|
||||
+19
-5
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Executable
+152
@@ -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()
|
||||
Executable
+147
@@ -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()
|
||||
@@ -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))]
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 + {
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
# MAPD implementation by pfeiferj
|
||||
https://github.com/pfeiferj/openpilot-mapd/releases/
|
||||
BIN
Binary file not shown.
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user