Files
sunnypilot-tesla/selfdrive/ui/sunnypilot/onroad/speed_limit.py
Jason Wen d5b25e14fd Merge branch 'upstream/openpilot/master' into sync-20260317
# Conflicts:
#	.github/workflows/auto_pr_review.yaml
#	.gitignore
#	opendbc_repo
#	panda
#	selfdrive/ui/mici/layouts/home.py
#	selfdrive/ui/mici/layouts/onboarding.py
#	selfdrive/ui/mici/layouts/settings/device.py
#	selfdrive/ui/tests/diff/replay.py
#	selfdrive/ui/translations/app_fr.po
#	system/ui/mici_setup.py
Sync: `commaai/opendbc:master` → `sunnypilot/opendbc:master`
Sync: `commaai/panda:master` → `sunnypilot/panda:master`
2026-03-17 23:02:10 -04:00

329 lines
13 KiB
Python

"""
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 dataclasses import dataclass
from enum import StrEnum
import pyray as rl
from cereal import custom
from openpilot.common.constants import CV
from openpilot.selfdrive.ui.onroad.hud_renderer import UI_CONFIG
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Mode as SpeedLimitMode
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.sunnypilot.lib.utils import AlertFadeAnimator
from openpilot.system.ui.widgets import Widget
METER_TO_FOOT = 3.28084
METER_TO_MILE = 0.000621371
AHEAD_THRESHOLD = 5
SET_SPEED_NA = 255
KM_TO_MILE = 0.621371
AssistState = custom.LongitudinalPlanSP.SpeedLimit.AssistState
SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimit.Source
@dataclass(frozen=True)
class Colors:
WHITE = rl.WHITE
BLACK = rl.BLACK
RED = rl.Color(235, 32, 32, 255)
GREY = rl.Color(145, 155, 149, 255)
DARK_GREY = rl.Color(77, 77, 77, 255)
SUB_BG = rl.Color(0, 0, 0, 180)
MUTCD_LINES = rl.Color(255, 255, 255, 100)
class IconSide(StrEnum):
left = 'left'
right = 'right'
class SpeedLimitAlertRenderer:
ARROW_SIZE = 90 if HARDWARE.get_device_type() == 'mici' else 200
def __init__(self):
self.arrow_up = gui_app.texture("../../sunnypilot/selfdrive/assets/img_plus_arrow_up.png", self.ARROW_SIZE, self.ARROW_SIZE)
self.arrow_down = gui_app.texture("../../sunnypilot/selfdrive/assets/img_minus_arrow_down.png", self.ARROW_SIZE, self.ARROW_SIZE)
blank_image = rl.gen_image_color(self.ARROW_SIZE, self.ARROW_SIZE, rl.Color(0, 0, 0, 0))
self.arrow_blank = rl.load_texture_from_image(blank_image)
rl.unload_image(blank_image)
self._pre_active_fade = AlertFadeAnimator(gui_app.target_fps, duration_on=0.75, rc=0.05)
def update(self):
assist_state = ui_state.sm['longitudinalPlanSP'].speedLimit.assist.state
self._pre_active_fade.update(assist_state == AssistState.preActive)
def speed_limit_pre_active_icon_helper(self):
icon_alpha = max(0.0, min(self._pre_active_fade.alpha * 255.0, 255.0))
txt_icon = self.arrow_blank
icon_margin_x = 10
icon_margin_y = 18
if icon_alpha > 0:
speed_conv = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
speed_limit_final_last = ui_state.sm['longitudinalPlanSP'].speedLimit.resolver.speedLimitFinalLast
v_cruise_cluster = ui_state.sm['carState'].vCruiseCluster
set_speed = ui_state.sm['controlsState'].vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
if not ui_state.is_metric:
set_speed *= KM_TO_MILE
set_speed_round = round(set_speed)
speed_limit_round = round(speed_limit_final_last * speed_conv)
if set_speed_round < speed_limit_round:
txt_icon = self.arrow_up
elif set_speed_round > speed_limit_round:
txt_icon = self.arrow_down
return IconSide.right, txt_icon, icon_alpha, icon_margin_x, icon_margin_y
class SpeedLimitRenderer(Widget, SpeedLimitAlertRenderer):
def __init__(self):
Widget.__init__(self)
SpeedLimitAlertRenderer.__init__(self)
self.speed_limit = 0.0
self.speed_limit_last = 0.0
self.speed_limit_offset = 0.0
self.speed_limit_valid = False
self.speed_limit_last_valid = False
self.speed_limit_final_last = 0.0
self.speed_limit_source = SpeedLimitSource.none
self.speed_limit_assist_state = AssistState.disabled
self.speed_limit_ahead = 0.0
self.speed_limit_ahead_dist = 0.0
self.speed_limit_ahead_dist_prev = 0.0
self.speed_limit_ahead_valid = False
self.speed_limit_ahead_frame = 0
self.is_cruise_set: bool = False
self.is_cruise_available: bool = True
self.set_speed: float = SET_SPEED_NA
self.speed: float = 0.0
self.v_ego_cluster_seen: bool = False
self.font_bold = gui_app.font(FontWeight.BOLD)
self.font_demi = gui_app.font(FontWeight.SEMI_BOLD)
self.font_norm = gui_app.font(FontWeight.NORMAL)
@property
def speed_conv(self):
return CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
def update(self):
SpeedLimitAlertRenderer.update(self)
sm = ui_state.sm
if sm.recv_frame["carState"] < ui_state.started_frame:
self.set_speed = SET_SPEED_NA
self.speed = 0.0
return
if sm.updated["longitudinalPlanSP"]:
lp_sp = sm["longitudinalPlanSP"]
resolver = lp_sp.speedLimit.resolver
assist = lp_sp.speedLimit.assist
self.speed_limit = resolver.speedLimit * self.speed_conv
self.speed_limit_last = resolver.speedLimitLast * self.speed_conv
self.speed_limit_offset = resolver.speedLimitOffset * self.speed_conv
self.speed_limit_valid = resolver.speedLimitValid
self.speed_limit_last_valid = resolver.speedLimitLastValid
self.speed_limit_final_last = resolver.speedLimitFinalLast * self.speed_conv
self.speed_limit_source = resolver.source
self.speed_limit_assist_state = assist.state
if sm.updated["liveMapDataSP"]:
lmd = sm["liveMapDataSP"]
self.speed_limit_ahead_valid = lmd.speedLimitAheadValid
self.speed_limit_ahead = lmd.speedLimitAhead * self.speed_conv
self.speed_limit_ahead_dist = lmd.speedLimitAheadDistance
if self.speed_limit_ahead_dist < self.speed_limit_ahead_dist_prev and self.speed_limit_ahead_frame < AHEAD_THRESHOLD:
self.speed_limit_ahead_frame += 1
elif self.speed_limit_ahead_dist > self.speed_limit_ahead_dist_prev and self.speed_limit_ahead_frame > 0:
self.speed_limit_ahead_frame -= 1
self.speed_limit_ahead_dist_prev = self.speed_limit_ahead_dist
controls_state = sm['controlsState']
car_state = sm["carState"]
v_cruise_cluster = car_state.vCruiseCluster
self.set_speed = (
controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
)
self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA
self.is_cruise_available = self.set_speed != -1
if self.is_cruise_set and not ui_state.is_metric:
self.set_speed *= KM_TO_MILE
self.v_ego_cluster_seen = self.v_ego_cluster_seen or car_state.vEgoCluster != 0.0
v_ego = car_state.vEgoCluster if self.v_ego_cluster_seen else car_state.vEgo
self.speed = max(0.0, v_ego * self.speed_conv)
@staticmethod
def _draw_text_centered(font, text, size, pos_center, color):
sz = measure_text_cached(font, text, size)
rl.draw_text_ex(font, text, rl.Vector2(pos_center.x - sz.x / 2, pos_center.y - sz.y / 2), size, 0, color)
def _render(self, rect: rl.Rectangle):
width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial
x = rect.x + 60 + width + 30 - 6
y = rect.y + 45 - 6
sign_rect = rl.Rectangle(x, y, width, UI_CONFIG.set_speed_height + 6 * 2)
alpha = self._pre_active_fade.alpha
if ui_state.speed_limit_mode != SpeedLimitMode.off:
self._draw_sign_main(sign_rect, alpha)
if self.speed_limit_assist_state == AssistState.preActive:
self._draw_pre_active_arrow(sign_rect)
else:
self._draw_ahead_info(sign_rect)
def _draw_sign_main(self, rect, alpha=1.0):
speed_limit_warning_enabled = ui_state.speed_limit_mode >= SpeedLimitMode.warning
has_limit = self.speed_limit_valid or self.speed_limit_last_valid
is_overspeed = has_limit and round(self.speed_limit_final_last) < round(self.speed)
limit_str = str(round(self.speed_limit_last)) if has_limit else "---"
sub_text = ""
if self.speed_limit_offset != 0:
sign = "" if self.speed_limit_offset > 0 else "-"
sub_text = f"{sign}{round(abs(self.speed_limit_offset))}"
txt_color = Colors.BLACK
if speed_limit_warning_enabled and is_overspeed:
txt_color = Colors.RED
elif not self.speed_limit_valid:
txt_color = Colors.GREY
if ui_state.is_metric:
self._render_vienna(rect, limit_str, sub_text, txt_color, has_limit, alpha)
else:
self._render_mutcd(rect, limit_str, sub_text, txt_color, has_limit, alpha)
def _draw_pre_active_arrow(self, sign_rect):
_, txt_icon, icon_alpha, _, _ = SpeedLimitAlertRenderer.speed_limit_pre_active_icon_helper(self)
if icon_alpha > 0 and txt_icon != self.arrow_blank:
sign_margin = 12
arrow_spacing = int(sign_margin * 1.4)
arrow_x = sign_rect.x + sign_rect.width + arrow_spacing
arrow_y = sign_rect.y + (sign_rect.height - txt_icon.height) / 2
color = rl.Color(255, 255, 255, int(icon_alpha))
rl.draw_texture_ex(txt_icon, rl.Vector2(arrow_x, arrow_y), 0.0, 1.0, color)
def _render_vienna(self, rect, val, sub, color, has_limit, alpha=1.0):
center = rl.Vector2(rect.x + rect.width / 2, rect.y + rect.height / 2)
radius = (rect.width + 18) / 2
white = rl.color_alpha(Colors.WHITE, alpha)
red = rl.color_alpha(Colors.RED, alpha)
black = rl.color_alpha(Colors.BLACK, alpha)
dark_grey = rl.color_alpha(Colors.DARK_GREY, alpha)
text_color = rl.color_alpha(color, alpha)
rl.draw_circle_v(center, radius, white)
rl.draw_ring(center, radius * 0.75, radius, 0, 360, 36, red)
font_size = 70 if len(val) >= 3 else 85
self._draw_text_centered(self.font_bold, val, font_size, center, text_color)
if sub and has_limit:
s_radius = radius * 0.4
s_center = rl.Vector2(rect.x + rect.width - s_radius / 2, rect.y + s_radius / 2)
rl.draw_circle_v(s_center, s_radius, black)
rl.draw_ring(s_center, s_radius - 3, s_radius, 0, 360, 36, dark_grey)
font_scale = 0.5 if len(sub) < 3 else 0.45
self._draw_text_centered(self.font_bold, sub, int(s_radius * 2 * font_scale), s_center, white)
def _render_mutcd(self, rect, val, sub, color, has_limit, alpha=1.0):
white = rl.color_alpha(Colors.WHITE, alpha)
black = rl.color_alpha(Colors.BLACK, alpha)
dark_grey = rl.color_alpha(Colors.DARK_GREY, alpha)
text_color = rl.color_alpha(color, alpha)
rl.draw_rectangle_rounded(rect, 0.35, 10, white)
inner = rl.Rectangle(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20)
outer_radius = 0.35 * rect.width / 2.0
inner_radius = outer_radius - 10.0
inner_roundness = inner_radius / (inner.width / 2.0)
rl.draw_rectangle_rounded_lines_ex(inner, inner_roundness, 10, 4, black)
self._draw_text_centered(self.font_demi, "SPEED", 40, rl.Vector2(rect.x + rect.width / 2, rect.y + 40), black)
self._draw_text_centered(self.font_demi, "LIMIT", 40, rl.Vector2(rect.x + rect.width / 2, rect.y + 80), black)
self._draw_text_centered(self.font_bold, val, 90, rl.Vector2(rect.x + rect.width / 2, rect.y + 150), text_color)
if sub and has_limit:
box_sz = rect.width * 0.3
overlap = box_sz * 0.2
s_rect = rl.Rectangle(rect.x + rect.width - box_sz / 1.5 + overlap, rect.y - box_sz / 1.25 + overlap, box_sz, box_sz)
rl.draw_rectangle_rounded(s_rect, 0.35, 10, black)
rl.draw_rectangle_rounded_lines_ex(s_rect, 0.35, 10, 6, dark_grey)
f_scale = 0.6 if len(sub) < 3 else 0.475
self._draw_text_centered(self.font_bold, sub, int(box_sz * f_scale), rl.Vector2(s_rect.x + box_sz / 2, s_rect.y + box_sz / 2), white)
def _draw_ahead_info(self, sign_rect):
source_is_map = self.speed_limit_source == SpeedLimitSource.map
valid = self.speed_limit_ahead_valid and self.speed_limit_ahead > 0 and self.speed_limit_ahead != self.speed_limit
if not (valid and source_is_map):
return
rect = rl.Rectangle(sign_rect.x + (sign_rect.width - 170) / 2, sign_rect.y + sign_rect.height + 10, 170, 160)
rl.draw_rectangle_rounded(rect, 0.35, 10, Colors.SUB_BG)
rl.draw_rectangle_rounded_lines_ex(rect, 0.35, 10, 3, Colors.MUTCD_LINES)
mid_x = rect.x + rect.width / 2
self._draw_text_centered(self.font_demi, "AHEAD", 40, rl.Vector2(mid_x, rect.y + 28), Colors.GREY)
self._draw_text_centered(self.font_bold, str(round(self.speed_limit_ahead)), 70, rl.Vector2(mid_x, rect.y + 82), Colors.WHITE)
self._draw_text_centered(self.font_norm, self._format_dist(self.speed_limit_ahead_dist), 36, rl.Vector2(mid_x, rect.y + 134), Colors.GREY)
@staticmethod
def _format_dist(d):
# metric
if ui_state.is_metric:
if d < 50:
return tr("Near")
if d >= 1000:
return f"{d / 1000:.1f} km"
d_rounded = round(d, -1) if d < 200 else round(d, -2)
return f"{int(d_rounded)} m"
# imperial
d_ft = d * METER_TO_FOOT
if d_ft < 100:
return tr("Near")
if d_ft >= 900:
return f"{d * METER_TO_MILE:.1f} mi"
if d_ft < 500:
return f"{int(round(d_ft / 50) * 50)} ft"
return f"{int(round(d_ft / 100) * 100)} ft"