diff --git a/scripts/launch_onroad_desktop.sh b/scripts/launch_onroad_desktop.sh index 61cd3b305..d72086e9b 100755 --- a/scripts/launch_onroad_desktop.sh +++ b/scripts/launch_onroad_desktop.sh @@ -27,7 +27,7 @@ env_var_truthy() { usage() { cat <<'EOF' Usage: - ./onroad [jobs] (--c3 | --c4 | --raybig | --all | --replay-only) [-nav] [--cem] [--prefix name] + ./onroad [jobs] (--c3 | --c4 | --raybig | --all | --replay-only) [-nav] [-alert] [--cem] [--prefix name] Examples: ./onroad --c3 @@ -35,6 +35,7 @@ Examples: ./onroad --c4 -nav ./onroad --c4 --cem --demo ./onroad --raybig --cem --demo + ./onroad --raybig --cem -alert --demo --no-loop ./onroad --all ./onroad --replay-only --demo --no-vipc --no-loop @@ -44,6 +45,7 @@ Notes: - Use multiple UI flags together if you want more than one desktop UI at once. - -nav injects a fake navigation demo stream and blocks replay from publishing navInstruction/navRoute. - --cem publishes fake CEM statuses for desktop visual review in the raylib UIs. + - -alert blocks replay from publishing selfdriveState and fires a fake critical full-screen red alert (alertSize=full, alertStatus=critical) 20 seconds after the demo publisher starts (10s for replay route + UI to come up, plus 10s for the user to open Settings). Default alert text mimics a real controlsMismatch event; run tools/replay/fake_alert_demo.py directly to override --text1/--text2/--delay. EOF } @@ -60,9 +62,11 @@ LEGACY_UI_SELECTION="" REPLAY_ONLY=0 NAV_DEMO=0 CEM_DEMO=0 +ALERT_DEMO=0 REPLAY_PID="" NAV_PID="" CEM_PID="" +ALERT_PID="" UI_PIDS=() parse_args() { @@ -100,6 +104,10 @@ parse_args() { CEM_DEMO=1 shift ;; + -alert|--alert|--alert-demo) + ALERT_DEMO=1 + shift + ;; --ui) if [[ $# -lt 2 ]]; then echo "Missing value for --ui" >&2 @@ -217,6 +225,9 @@ cleanup() { if [[ -n "${CEM_PID}" ]]; then kill "${CEM_PID}" >/dev/null 2>&1 || true fi + if [[ -n "${ALERT_PID}" ]]; then + kill "${ALERT_PID}" >/dev/null 2>&1 || true + fi for pid in "${UI_PIDS[@]-}"; do if [[ -n "${pid}" ]]; then @@ -232,6 +243,9 @@ cleanup() { if [[ -n "${CEM_PID}" ]]; then wait "${CEM_PID}" >/dev/null 2>&1 || true fi + if [[ -n "${ALERT_PID}" ]]; then + wait "${ALERT_PID}" >/dev/null 2>&1 || true + fi if [[ -n "${OPENPILOT_PREFIX:-}" && "${OPENPILOT_PREFIX}" == desktop-onroad-* ]]; then echo "Cleaning up temporary prefix environment (${OPENPILOT_PREFIX})..." @@ -305,6 +319,30 @@ ensure_nav_demo_replay_blocklist() { REPLAY_ARGS=(-b "${nav_services}" "${REPLAY_ARGS[@]}") } +ensure_alert_demo_replay_blocklist() { + local alert_services="selfdriveState" + local idx=0 + + for ((idx=0; idx<${#REPLAY_ARGS[@]}; idx++)); do + case "${REPLAY_ARGS[$idx]}" in + -b|--block) + if (( idx + 1 >= ${#REPLAY_ARGS[@]} )); then + echo "Missing value for ${REPLAY_ARGS[$idx]}" >&2 + exit 1 + fi + REPLAY_ARGS[$((idx + 1))]="$(append_blocked_service_names "${REPLAY_ARGS[$((idx + 1))]}" "${alert_services}")" + return + ;; + --block=*) + REPLAY_ARGS[$idx]="--block=$(append_blocked_service_names "${REPLAY_ARGS[$idx]#*=}" "${alert_services}")" + return + ;; + esac + done + + REPLAY_ARGS=(-b "${alert_services}" "${REPLAY_ARGS[@]}") +} + prepare_env() { source .venv/bin/activate @@ -331,6 +369,7 @@ prepare_env() { export SP_ALLOW_DESKTOP_FAKE_WIFI=0 export SP_ONROAD_NAV_DEMO="${NAV_DEMO}" export SP_CEM_DEMO="${CEM_DEMO}" + export SP_ONROAD_ALERT_DEMO="${ALERT_DEMO}" if [[ "$(uname -s)" == "Darwin" ]] || env_var_truthy "${ZMQ:-0}"; then export OPENPILOT_ZMQ_NAMESPACE="${PREFIX_ARG:-${OPENPILOT_ZMQ_NAMESPACE:-desktop-onroad-$$}}" @@ -423,6 +462,18 @@ launch_cem_demo() { fi } +launch_alert_demo() { + echo "Starting fake critical alert demo publisher (fires after 20s on-road; blocks replay's selfdriveState)..." + "${ROOT_DIR}/.venv/bin/python3" "${ROOT_DIR}/tools/replay/fake_alert_demo.py" & + ALERT_PID=$! + + sleep 0.5 + if ! kill -0 "${ALERT_PID}" >/dev/null 2>&1; then + wait "${ALERT_PID}" + return 1 + fi +} + launch_c3_ui() { local os_ext="linux" if [[ "$(uname -s)" == "Darwin" ]]; then @@ -473,6 +524,10 @@ if [[ "${NAV_DEMO}" == "1" ]]; then ensure_nav_demo_replay_blocklist fi +if [[ "${ALERT_DEMO}" == "1" ]]; then + ensure_alert_demo_replay_blocklist +fi + if [[ ${#REPLAY_ARGS[@]} -eq 0 ]]; then usage >&2 exit 1 @@ -520,6 +575,10 @@ if [[ "${CEM_DEMO}" == "1" ]]; then launch_cem_demo fi +if [[ "${ALERT_DEMO}" == "1" ]]; then + launch_alert_demo +fi + if [[ ${#UI_TARGETS[@]} -eq 0 ]]; then echo "Replay is running without UI windows. Press Ctrl-C to stop." wait "${REPLAY_PID}" diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index d02390950..9aa8e363c 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -1,5 +1,6 @@ import pyray as rl from enum import IntEnum +from cereal import log import cereal.messaging as messaging from openpilot.system.hardware import PC from openpilot.system.ui.lib.application import gui_app @@ -46,7 +47,18 @@ class MainLayout(Widget): if not self._onboarding_window.completed: gui_app.push_widget(self._onboarding_window) + @staticmethod + def _critical_full_alert_active() -> bool: + if not ui_state.sm.recv_frame['selfdriveState']: + return False + ss = ui_state.sm['selfdriveState'] + return ss.alertSize == log.SelfdriveState.AlertSize.full and ss.alertStatus == log.SelfdriveState.AlertStatus.critical + def _render(self, _): + if self._current_mode == MainState.SETTINGS and self._critical_full_alert_active(): + self._set_current_layout(MainState.ONROAD) + return + self._handle_onroad_transition() self._render_main_content() diff --git a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py index f1d2bdccb..09f8b7f27 100644 --- a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py +++ b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py @@ -1177,11 +1177,11 @@ class BreadcrumbController: target = 1.0 if self._expanded else 0.0 self._expand_alpha += (target - self._expand_alpha) * ANIM_LERP - ACTIVE_SIZE = 58 - PAST_SIZE = 50 - CHEVRON_SIZE = 38 - CHEVRON_W = 32 - GAP = 23 + ACTIVE_SIZE = 48 + PAST_SIZE = 40 + CHEVRON_SIZE = 30 + CHEVRON_W = 26 + GAP = 20 center_y = rect.y + rect.height / 2 @@ -1198,8 +1198,8 @@ class BreadcrumbController: mouse_pos = gui_app.last_mouse_event.pos - MIN_ACTIVE_SIZE = 34 - CAPSULE_W = 102 + MIN_ACTIVE_SIZE = 30 + CAPSULE_W = 92 capsule_need = CAPSULE_W + GAP + CHEVRON_W + GAP active_size = ACTIVE_SIZE @@ -1264,7 +1264,7 @@ class BreadcrumbController: current_x += GAP continue - capsule_w, capsule_h = CAPSULE_W, 58 + capsule_w, capsule_h = CAPSULE_W, 48 cap_rect = rl.Rectangle(current_x, center_y - capsule_h / 2, capsule_w, capsule_h) hovered = point_hits(mouse_pos, cap_rect, None, pad_x=4, pad_y=6) @@ -1297,10 +1297,10 @@ class BreadcrumbController: rl.draw_rectangle_rounded_lines_ex(cap_rect, 1.0, 16, 1.0, outline) font_dots = gui_app.font(FontWeight.BOLD) - dots_ts = measure_text_cached(font_dots, "...", 41) + dots_ts = measure_text_cached(font_dots, "...", 34) rl.draw_text_ex(font_dots, "...", rl.Vector2(cap_rect.x + (cap_rect.width - dots_ts.x) / 2, center_y - dots_ts.y / 2), - 41, 0, dots_c) + 34, 0, dots_c) current_x += capsule_w + GAP else: @@ -1326,7 +1326,7 @@ class BreadcrumbController: c_pressed = rl.Color(past_pressed.r, past_pressed.g, past_pressed.b, item_alpha) ts = measure_text_cached(font, text, font_size) - hit_rect = rl.Rectangle(current_x - 9, center_y - 44, ts.x + 17, 87) + hit_rect = rl.Rectangle(current_x - 8, center_y - 38, ts.x + 16, 76) hovered = point_hits(mouse_pos, hit_rect, None, pad_x=0, pad_y=0) # Only add to interactive rects if it's visible within the bounds @@ -3382,7 +3382,7 @@ class AetherSettingsView(PanelManagerView): def draw_back_button(pill_rect: rl.Rectangle, center_y: float, pressed: bool, hovered: bool) -> rl.Rectangle: - back_size = 55 + back_size = 48 back_x = pill_rect.x + 12 btn = rl.Rectangle(back_x, center_y - back_size / 2, back_size, back_size) if pressed or hovered: @@ -3458,16 +3458,16 @@ class AetherCategoryDrawer(AetherSettingsView): rl.draw_rectangle_rec(rl.Rectangle(drawer_rect.x + drawer_rect.width - 4, drawer_rect.y, 4, drawer_rect.height), self._color) # Draw header with navigation breadcrumbs - header_rect = rl.Rectangle(drawer_rect.x + 36, drawer_rect.y + 24, drawer_rect.width - 72, 80) + header_rect = rl.Rectangle(drawer_rect.x + 36, drawer_rect.y + 24, drawer_rect.width - 72, 60) if self._has_header: self._draw_header(header_rect) # Set up scroll area (vertical scroll list) self._scroll_rect = rl.Rectangle( drawer_rect.x + 36, - drawer_rect.y + 120, + drawer_rect.y + 100, drawer_rect.width - 72, - drawer_rect.height - 144 + drawer_rect.height - 124 ) content_width = self._scroll_rect.width - AETHER_LIST_METRICS.content_right_gutter @@ -3518,7 +3518,7 @@ class AetherCategoryDrawer(AetherSettingsView): super()._activate_target(target_id) def _draw_header(self, rect: rl.Rectangle): - pill_h = 52 + pill_h = 48 pill_rect = rl.Rectangle(rect.x, rect.y + (rect.height - pill_h) / 2, rect.width, pill_h) center_y = pill_rect.y + pill_h / 2 diff --git a/selfdrive/ui/layouts/settings/starpilot/main_panel.py b/selfdrive/ui/layouts/settings/starpilot/main_panel.py index 136cf1b27..eafffb557 100644 --- a/selfdrive/ui/layouts/settings/starpilot/main_panel.py +++ b/selfdrive/ui/layouts/settings/starpilot/main_panel.py @@ -273,7 +273,7 @@ class StarPilotLayout(Widget): self._commit_navigation() def _render(self, rect: rl.Rectangle): - TOP_BAR_HEIGHT = 160 + TOP_BAR_HEIGHT = 72 content_rect = rl.Rectangle(rect.x, rect.y + TOP_BAR_HEIGHT, rect.width, rect.height - TOP_BAR_HEIGHT) # Standardize width to perfectly match subpanel shells @@ -281,20 +281,20 @@ class StarPilotLayout(Widget): shell_x = rect.x + (rect.width - shell_w) / 2 # 0. Draw top bar with HubTile-style purple glow - glass_rect = rl.Rectangle(shell_x, rect.y + 20, shell_w, TOP_BAR_HEIGHT - 35) + glass_rect = rl.Rectangle(shell_x, rect.y + 2, shell_w, TOP_BAR_HEIGHT - 4) # 0a. Purple glow rings — 4 concentric, fading outward (HubTile parity) for i in range(4, 0, -1): off = i * 2.5 gr = rl.Rectangle(glass_rect.x - off, glass_rect.y - off, glass_rect.width + off * 2, glass_rect.height + off * 2) a = int(25 * (1.0 - i / 5)) - draw_rounded_fill(gr, rl.Color(139, 92, 246, max(0, min(255, a))), radius_px=145) + draw_rounded_fill(gr, rl.Color(139, 92, 246, max(0, min(255, a))), radius_px=34) # 0b. Dark fill — strict parity with HubTile _HUD_BG_ON - draw_rounded_fill(glass_rect, rl.Color(12, 10, 18, 255), radius_px=145) + draw_rounded_fill(glass_rect, rl.Color(12, 10, 18, 255), radius_px=34) # 0c. Full bright purple border — strict parity - draw_rounded_stroke(glass_rect, rl.Color(139, 92, 246, 255), radius_px=145) + draw_rounded_stroke(glass_rect, rl.Color(139, 92, 246, 255), radius_px=34) # 1. Draw breadcrumbs in top bar crumb_rect = rl.Rectangle(glass_rect.x, glass_rect.y, glass_rect.width, glass_rect.height) diff --git a/tools/replay/fake_alert_demo.py b/tools/replay/fake_alert_demo.py new file mode 100644 index 000000000..1de18675a --- /dev/null +++ b/tools/replay/fake_alert_demo.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 + +import argparse +import signal +import time + +from cereal import car, log, messaging + +AlertSize = log.SelfdriveState.AlertSize +AlertStatus = log.SelfdriveState.AlertStatus +AudibleAlert = log.SelfdriveState.AudibleAlert +VisualAlert = car.CarControl.HUDControl.VisualAlert + + +running = True + + +def _handle_signal(_signum, _frame) -> None: + global running + running = False + + +def build_msg(text1: str, text2: str, size: int, status: int, + audible: int = AudibleAlert.none, + visual: int = VisualAlert.none, + alert_type: str = ""): + m = messaging.new_message("selfdriveState") + m.valid = True + ss = m.selfdriveState + ss.alertSize = size + ss.alertStatus = status + ss.alertText1 = text1 + ss.alertText2 = text2 + ss.alertType = alert_type + ss.alertSound = audible + ss.alertHudVisual = visual + return m + + +def main() -> None: + parser = argparse.ArgumentParser(description="Publish a fake critical full-screen selfdriveState alert for desktop onroad replay.") + parser.add_argument("--delay", type=float, default=20.0, help="Seconds to wait after start before firing the alert (allows replay route + UI to come up, then ~10s for the user to open Settings).") + parser.add_argument("--hold-interval", type=float, default=0.25, help="Seconds between selfdriveState publishes.") + parser.add_argument("--hold", type=float, default=5.0, help="Seconds to keep the critical alert active before clearing it.") + parser.add_argument("--text1", type=str, default="TAKE CONTROL IMMEDIATELY", help="Primary alert text.") + parser.add_argument("--text2", type=str, default="Controls Mismatch", help="Secondary alert text.") + parser.add_argument("--audible", action="store_true", default=True, help="Include warningImmediate audible alert.") + parser.add_argument("--silent", dest="audible", action="store_false", help="Omit audible alert (override --audible).") + args = parser.parse_args() + + signal.signal(signal.SIGINT, _handle_signal) + signal.signal(signal.SIGTERM, _handle_signal) + + pm = messaging.PubMaster(["selfdriveState"]) + + audible = AudibleAlert.warningImmediate if args.audible else AudibleAlert.none + visual = VisualAlert.steerRequired + + none_msg = build_msg("", "", AlertSize.none, AlertStatus.normal) + fire_msg = build_msg(args.text1, args.text2, AlertSize.full, AlertStatus.critical, + audible=audible, visual=visual, + alert_type="controlsMismatch/immediateDisable") + + print(f"fake_alert_demo: waiting {args.delay:.1f}s before firing critical alert...", flush=True) + + end = time.monotonic() + args.delay + while running and time.monotonic() < end: + pm.send("selfdriveState", none_msg) + none_msg.clear_write_flag() + time.sleep(args.hold_interval) + + if not running: + return + + print(f"fake_alert_demo: firing critical red alert: '{args.text1}' / '{args.text2}' for {args.hold:.1f}s", flush=True) + + held = time.monotonic() + args.hold + try: + while running and time.monotonic() < held: + pm.send("selfdriveState", fire_msg) + fire_msg.clear_write_flag() + time.sleep(args.hold_interval) + finally: + print("fake_alert_demo: clearing alert (sending AlertSize.none) so UI fades out...", flush=True) + clear = build_msg("", "", AlertSize.none, AlertStatus.normal) + for _ in range(3): + pm.send("selfdriveState", clear) + clear.clear_write_flag() + time.sleep(args.hold_interval) + + +if __name__ == "__main__": + main()