From 21fe09babcd8d8685673099a62bbbb75a9cc2233 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Thu, 4 Jun 2026 20:39:36 +0900 Subject: [PATCH] vision-work (#392) --- selfdrive/carrot/server/app.py | 4 +- selfdrive/carrot/server/config.py | 6 + selfdrive/carrot/server/features/__init__.py | 4 + selfdrive/carrot/server/features/stream.py | 44 ++ selfdrive/carrot/server/features/terminal.py | 4 +- .../carrot/server/features/vision_diag.py | 36 ++ .../carrot/server/features/vision_test.py | 14 + .../carrot/server/services/vision_diag.py | 548 ++++++++++++++++ .../carrot/server/services/vision_test.py | 412 ++++++++++++ .../carrot/server/terminal_commands/README.md | 58 ++ .../server/terminal_commands/__init__.py | 3 + .../carrot/server/terminal_commands/bridge.py | 17 + .../carrot/server/terminal_commands/cli.py | 46 ++ .../custom_commands/__init__.py | 16 + .../terminal_commands/custom_commands/help.py | 25 + .../custom_commands/vision_test.py | 13 + .../server/terminal_commands/registry.py | 47 ++ .../carrot/web/css/components/nav_hud.css | 68 +- selfdrive/carrot/web/css/pages/drive.css | 236 +++++++ selfdrive/carrot/web/index.html | 42 +- .../carrot/web/js/realtime/app_realtime.js | 242 ++++++- .../carrot/web/js/realtime/carrot_map.js | 2 + .../carrot/web/js/realtime/home_drive.js | 477 ++++++++++---- selfdrive/carrot/web/js/realtime/nav_hud.js | 46 +- .../carrot/web/js/realtime/vision_diag.js | 598 ++++++++++++++++-- .../carrot/web/js/realtime/vision_rtc.js | 245 ++++++- system/loggerd/encoder/v4l_encoder.cc | 38 ++ 27 files changed, 3069 insertions(+), 222 deletions(-) create mode 100644 selfdrive/carrot/server/features/vision_diag.py create mode 100644 selfdrive/carrot/server/features/vision_test.py create mode 100644 selfdrive/carrot/server/services/vision_diag.py create mode 100644 selfdrive/carrot/server/services/vision_test.py create mode 100644 selfdrive/carrot/server/terminal_commands/README.md create mode 100644 selfdrive/carrot/server/terminal_commands/__init__.py create mode 100644 selfdrive/carrot/server/terminal_commands/bridge.py create mode 100644 selfdrive/carrot/server/terminal_commands/cli.py create mode 100644 selfdrive/carrot/server/terminal_commands/custom_commands/__init__.py create mode 100644 selfdrive/carrot/server/terminal_commands/custom_commands/help.py create mode 100644 selfdrive/carrot/server/terminal_commands/custom_commands/vision_test.py create mode 100644 selfdrive/carrot/server/terminal_commands/registry.py diff --git a/selfdrive/carrot/server/app.py b/selfdrive/carrot/server/app.py index 172bf1fab..d1df456d4 100644 --- a/selfdrive/carrot/server/app.py +++ b/selfdrive/carrot/server/app.py @@ -25,6 +25,8 @@ from .services.git_status import git_status_loop from .services.heartbeat import heartbeat_loop from .services.params import HAS_PARAMS +VISION_DIAG_UPLOAD_MAX_BYTES = 16 * 1024 * 1024 + # ===== request log middleware ===== @web.middleware @@ -125,7 +127,7 @@ async def on_cleanup(app: web.Application) -> None: def make_app() -> web.Application: - app = web.Application(middlewares=[log_mw]) + app = web.Application(middlewares=[log_mw], client_max_size=VISION_DIAG_UPLOAD_MAX_BYTES) app.on_startup.append(on_startup) app.on_cleanup.append(on_cleanup) diff --git a/selfdrive/carrot/server/config.py b/selfdrive/carrot/server/config.py index 924056563..66223e899 100644 --- a/selfdrive/carrot/server/config.py +++ b/selfdrive/carrot/server/config.py @@ -47,6 +47,12 @@ DASHCAM_DEFAULT_DISCORD_WEBHOOK = ( "RUEoVxYELSNfWUgCOBUiF0s4HBpsIjcyLw" ) DASHCAM_DEFAULT_DISCORD_KEY = "carrot-log" +VISION_DIAG_DEFAULT_DISCORD_WEBHOOK = ( + "CxUGAhxOAlkNGhoMAV8IQQQMDF0THx0CAQwRAQABRh9AVlZQQ0NXTBREWENRW1ga" + "VF1WVU4gPB8OYgQtQyIEDWc8CBUFNCMGFjl5OT8XJCpeQThCFgY-JRk9GWICNhsr" + "JwcbIgoCEAQtFDgfSyI9Ql89KWRcIxM1BQ" +) +VISION_DIAG_DEFAULT_DISCORD_KEY = "carrot-vision-log" # Internal services WEBRTCD_URL = "http://127.0.0.1:5001/stream" diff --git a/selfdrive/carrot/server/features/__init__.py b/selfdrive/carrot/server/features/__init__.py index db0d26d5e..55e5416e3 100644 --- a/selfdrive/carrot/server/features/__init__.py +++ b/selfdrive/carrot/server/features/__init__.py @@ -14,6 +14,8 @@ from . import ( system, terminal, tools, + vision_diag, + vision_test, web_settings, ws, ) @@ -35,3 +37,5 @@ def register_all(app: web.Application) -> None: dashcam.register(app) screenrecord.register(app) tools.register(app) + vision_test.register(app) + vision_diag.register(app) diff --git a/selfdrive/carrot/server/features/stream.py b/selfdrive/carrot/server/features/stream.py index 015296b85..e9346db81 100644 --- a/selfdrive/carrot/server/features/stream.py +++ b/selfdrive/carrot/server/features/stream.py @@ -1,13 +1,36 @@ import asyncio +import json +import time from aiohttp import ClientSession, ClientTimeout, web from ..config import WEBRTCD_URL +from ..services.vision_diag import record_stream_proxy_event + + +def _request_summary(body: bytes) -> dict: + try: + payload = json.loads(body.decode("utf-8", errors="replace")) + return { + "cameras": payload.get("cameras"), + "bridge_services_in": payload.get("bridge_services_in"), + "bridge_services_out": payload.get("bridge_services_out"), + "sdp_bytes": len(str(payload.get("sdp") or "")), + } + except Exception: + return {} async def proxy_stream(request: web.Request) -> web.StreamResponse: body = await request.read() ct = request.headers.get("Content-Type", "application/json") + started_at = time.monotonic() + base_event = { + "client": request.remote or "", + "content_type": ct, + "request_bytes": len(body), + **_request_summary(body), + } sess: ClientSession = request.app["http"] @@ -19,10 +42,31 @@ async def proxy_stream(request: web.Request) -> web.StreamResponse: rct = resp.headers.get("Content-Type") if rct: out.headers["Content-Type"] = rct + record_stream_proxy_event({ + **base_event, + "ok": 200 <= resp.status < 300, + "status": resp.status, + "response_bytes": len(resp_body), + "elapsed_ms": round((time.monotonic() - started_at) * 1000, 1), + }) return out except asyncio.TimeoutError: + record_stream_proxy_event({ + **base_event, + "ok": False, + "status": 504, + "error": "webrtcd timeout", + "elapsed_ms": round((time.monotonic() - started_at) * 1000, 1), + }) return web.json_response({"ok": False, "error": "webrtcd timeout"}, status=504) except Exception as e: + record_stream_proxy_event({ + **base_event, + "ok": False, + "status": 502, + "error": str(e), + "elapsed_ms": round((time.monotonic() - started_at) * 1000, 1), + }) return web.json_response({"ok": False, "error": str(e)}, status=502) diff --git a/selfdrive/carrot/server/features/terminal.py b/selfdrive/carrot/server/features/terminal.py index 1edc64aba..400f99596 100644 --- a/selfdrive/carrot/server/features/terminal.py +++ b/selfdrive/carrot/server/features/terminal.py @@ -6,6 +6,7 @@ from aiohttp import web, WSMsgType from ..config import TMUX_WEB_SESSION from ..services import tmux +from ..terminal_commands import translate_meta_command async def ws_terminal(request: web.Request) -> web.WebSocketResponse: @@ -74,7 +75,8 @@ async def ws_terminal(request: web.Request) -> web.WebSocketResponse: typ = data.get("type") try: if typ == "input": - await asyncio.to_thread(tmux.send_line, session, str(data.get("data") or "")) + line = str(data.get("data") or "") + await asyncio.to_thread(tmux.send_line, session, translate_meta_command(line) or line) await push_screen(force=True, delay=0.03) elif typ == "control": action = (data.get("action") or "").strip() diff --git a/selfdrive/carrot/server/features/vision_diag.py b/selfdrive/carrot/server/features/vision_diag.py new file mode 100644 index 000000000..c80004b91 --- /dev/null +++ b/selfdrive/carrot/server/features/vision_diag.py @@ -0,0 +1,36 @@ +import asyncio + +from aiohttp import web + +from ..services.vision_diag import get_server_diagnostic_snapshot, upload_diagnostic_bundle_to_discord + + +async def api_vision_diag_server_snapshot(_request: web.Request) -> web.Response: + snapshot = await asyncio.to_thread(get_server_diagnostic_snapshot) + return web.json_response({"ok": True, "snapshot": snapshot}) + + +async def api_vision_diag_upload_discord(request: web.Request) -> web.Response: + try: + body = await request.json() + except web.HTTPRequestEntityTooLarge: + return web.json_response({"ok": False, "error": "diagnostic upload too large"}, status=413) + except Exception as exc: + return web.json_response({"ok": False, "error": f"invalid diagnostic upload json: {exc}"}, status=400) + bundle_text = str(body.get("bundle") or body.get("text") or "") + if not bundle_text.strip(): + return web.json_response({"ok": False, "error": "missing diagnostic bundle"}, status=400) + result = await upload_diagnostic_bundle_to_discord( + bundle_text=bundle_text, + filename=body.get("filename"), + console_text=str(body.get("console") or ""), + console_filename=body.get("consoleFilename") or body.get("console_filename"), + source=str(body.get("source") or "web"), + ) + status = 200 if result.get("ok") or result.get("skipped") else 502 + return web.json_response({"ok": bool(result.get("ok")), "discord": result}, status=status) + + +def register(app: web.Application) -> None: + app.router.add_get("/api/vision_diag/server_snapshot", api_vision_diag_server_snapshot) + app.router.add_post("/api/vision_diag/upload_discord", api_vision_diag_upload_discord) diff --git a/selfdrive/carrot/server/features/vision_test.py b/selfdrive/carrot/server/features/vision_test.py new file mode 100644 index 000000000..105726981 --- /dev/null +++ b/selfdrive/carrot/server/features/vision_test.py @@ -0,0 +1,14 @@ +import asyncio + +from aiohttp import web + +from ..services.vision_test import get_status + + +async def api_vision_test_status(_request: web.Request) -> web.Response: + status = await asyncio.to_thread(get_status) + return web.json_response({"ok": True, **status}) + + +def register(app: web.Application) -> None: + app.router.add_get("/api/vision_test/status", api_vision_test_status) diff --git a/selfdrive/carrot/server/services/vision_diag.py b/selfdrive/carrot/server/services/vision_diag.py new file mode 100644 index 000000000..877ff9575 --- /dev/null +++ b/selfdrive/carrot/server/services/vision_diag.py @@ -0,0 +1,548 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +import os +import platform +import socket +import subprocess +import threading +import time +from collections import deque +from pathlib import Path +from typing import Any + +from aiohttp import ClientSession, ClientTimeout, FormData + +from ..config import VISION_DIAG_DEFAULT_DISCORD_KEY, VISION_DIAG_DEFAULT_DISCORD_WEBHOOK +from .params import HAS_PARAMS, Params +from .vision_test import LOG_PATH as VISION_TEST_LOG_PATH +from .vision_test import get_status as get_vision_test_status + +try: + from openpilot.system.hardware import HARDWARE +except Exception: + HARDWARE = None + + +STREAM_PROXY_HISTORY_LIMIT = 120 +TEXT_LINE_LIMIT = 800 +JOURNAL_LINE_LIMIT = 240 +PROC_NET_LINE_LIMIT = 320 +VISION_TEST_LOG_LINE_LIMIT = 240 +DISCORD_FILE_MAX_BYTES = 8 * 1024 * 1024 + +_STREAM_PROXY_HISTORY: deque[dict[str, Any]] = deque(maxlen=STREAM_PROXY_HISTORY_LIMIT) +_STREAM_PROXY_HISTORY_LOCK = threading.Lock() +_PROCESS_MATCHES = { + "camerad": "system/camerad/camerad", + "stream_encoderd": "system/loggerd/encoderd\x00--stream", + "encoderd": "system/loggerd/encoderd", + "webrtcd": "system.webrtc.webrtcd", + "carrot_web": "selfdrive.carrot.server", +} +_JOURNAL_TERMS = ( + "camerad", + "camera", + "encoderd", + "loggerd", + "webrtcd", + "visionipc", + "v4l_encoder", + "spectra.cc", + "h264", + "multi-slice", + "checked_ioctl", + "carrot", +) + + +def _trim_text(value: Any, limit: int = TEXT_LINE_LIMIT) -> str: + text = str(value or "").replace("\x00", " ") + return text if len(text) <= limit else f"{text[:limit]}..." + + +def _decode_obfuscated(value: str, key: str) -> str: + try: + token = str(value or "").strip() + key_bytes = str(key or "").encode("utf-8") + if not token or not key_bytes: + return "" + raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)) + decoded = bytes(raw[i] ^ key_bytes[i % len(key_bytes)] for i in range(len(raw))) + return decoded.decode("utf-8", errors="ignore").strip() + except Exception: + return "" + + +def _param_text(params: Any, key: str, default: str = "") -> str: + try: + if not params: + return default + value = params.get(key) + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + value = str(value or "").strip() + return value or default + except Exception: + return default + + +def _repo_dir() -> str: + return os.environ.get("CARROT_REPO_DIR", "/data/openpilot") + + +def _git_text(args: list[str], default: str = "unknown") -> str: + try: + result = subprocess.run( + ["git", *args], + cwd=_repo_dir(), + capture_output=True, + text=True, + timeout=4, + ) + if result.returncode == 0: + value = (result.stdout or "").strip() + return value or default + except Exception: + pass + return default + + +def _device_serial(params: Any) -> str: + for key in ("HardwareSerial", "DeviceSerial", "Serial", "CarrotSerial"): + value = _param_text(params, key, "") + if value: + return value + for key in ("CARROT_DEVICE_SERIAL", "DEVICE_SERIAL", "SERIAL"): + value = os.environ.get(key, "").strip() + if value: + return value + try: + getter = getattr(HARDWARE, "get_serial", None) if HARDWARE is not None else None + if callable(getter): + value = str(getter() or "").strip() + if value: + return value + except Exception: + pass + return "unknown" + + +def _diagnostic_metadata(params: Any | None = None) -> dict[str, str]: + return { + "carName": _param_text(params, "CarName", "none"), + "dongleId": _param_text(params, "DongleId", "unknown"), + "serial": _device_serial(params), + "branch": _git_text(["branch", "--show-current"]), + "commit": _git_text(["rev-parse", "--short", "HEAD"]), + "commitDate": _git_text(["show", "-s", "--date=format:%Y-%m-%d %H:%M:%S", "--format=%cd", "HEAD"]), + } + + +def vision_diag_discord_webhook_url(params: Any | None = None) -> str: + for key in ("CARROT_VISION_DIAG_DISCORD_WEBHOOK_URL", "CARROT_DISCORD_WEBHOOK_URL", "DISCORD_WEBHOOK_URL"): + value = os.environ.get(key, "").strip() + if value: + return value + for key in ( + "CarrotVisionDiagDiscordWebhookUrl", + "CarrotVisionDiagDiscordWebhookURL", + "CarrotDiscordWebhookUrl", + "CarrotDiscordWebhookURL", + "DiscordWebhookUrl", + "DiscordWebhookURL", + ): + value = _param_text(params, key, "") + if value: + return value + if os.environ.get("CARROT_VISION_DIAG_DISCORD_WEBHOOK_DISABLE", "").strip().lower() in {"1", "true", "yes", "on"}: + return "" + return _decode_obfuscated(VISION_DIAG_DEFAULT_DISCORD_WEBHOOK, VISION_DIAG_DEFAULT_DISCORD_KEY) + + +def record_stream_proxy_event(event: dict[str, Any]) -> None: + entry = { + "ts": time.time(), + **event, + } + with _STREAM_PROXY_HISTORY_LOCK: + _STREAM_PROXY_HISTORY.append(entry) + + +def get_stream_proxy_history() -> list[dict[str, Any]]: + with _STREAM_PROXY_HISTORY_LOCK: + return list(_STREAM_PROXY_HISTORY) + + +def _read_text(path: Path, limit: int = 256_000) -> str: + try: + return path.read_text(encoding="utf-8", errors="replace")[:limit] + except Exception: + return "" + + +def _tail_file(path: Path, lines: int) -> list[str]: + text = _read_text(path) + return [_trim_text(line) for line in text.splitlines()[-lines:]] + + +def _run(args: list[str], timeout: float = 3.0) -> dict[str, Any]: + try: + proc = subprocess.run( + args, + check=False, + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + return { + "argv": args, + "returncode": proc.returncode, + "stdout": _trim_text(proc.stdout, 96_000), + "stderr": _trim_text(proc.stderr, 24_000), + } + except Exception as exc: + return { + "argv": args, + "error": _trim_text(exc), + } + + +def _pid_cmdline(pid: int) -> str: + try: + return Path(f"/proc/{pid}/cmdline").read_bytes().decode(errors="replace") + except Exception: + return "" + + +def _find_processes() -> dict[str, list[dict[str, Any]]]: + result: dict[str, list[dict[str, Any]]] = {name: [] for name in _PROCESS_MATCHES} + proc_root = Path("/proc") + if not proc_root.exists(): + return result + + for proc_path in proc_root.glob("[0-9]*"): + try: + pid = int(proc_path.name) + except ValueError: + continue + raw_cmdline = _pid_cmdline(pid) + if not raw_cmdline: + continue + for name, match in _PROCESS_MATCHES.items(): + if match in raw_cmdline: + result[name].append(_process_snapshot(pid, raw_cmdline)) + return result + + +def _process_snapshot(pid: int, raw_cmdline: str) -> dict[str, Any]: + status: dict[str, str] = {} + for line in _read_text(Path(f"/proc/{pid}/status"), limit=32_000).splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + if key in {"Name", "State", "Pid", "PPid", "Tgid", "Threads", "VmRSS", "VmSize"}: + status[key] = value.strip() + + stat = _read_text(Path(f"/proc/{pid}/stat"), limit=8_000).split() + fd_count = None + try: + fd_count = sum(1 for _ in Path(f"/proc/{pid}/fd").iterdir()) + except Exception: + pass + return { + "pid": pid, + "cmdline": _trim_text(raw_cmdline.replace("\x00", " ").strip()), + "status": status, + "cpu_user_ticks": stat[13] if len(stat) > 14 else None, + "cpu_system_ticks": stat[14] if len(stat) > 15 else None, + "fd_count": fd_count, + } + + +def _params_snapshot() -> dict[str, Any]: + if not HAS_PARAMS: + return {"available": False} + params = Params() + result: dict[str, Any] = {"available": True} + for name in ("IsOffroad", "IsTakingSnapshot", "CarParamsPersistent"): + try: + if name == "CarParamsPersistent": + raw = params.get(name) + result[name] = {"present": bool(raw), "bytes": len(raw or b"")} + else: + result[name] = params.get_bool(name) + except Exception as exc: + result[name] = {"error": _trim_text(exc)} + return result + + +def _vipc_snapshot() -> dict[str, Any]: + try: + from msgq.visionipc import VisionIpcClient + streams = sorted(int(stream) for stream in VisionIpcClient.available_streams("camerad", block=False)) + return {"available": True, "camerad_streams": streams} + except Exception as exc: + return {"available": False, "error": _trim_text(exc)} + + +def _port_open(port: int) -> bool: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.25): + return True + except OSError: + return False + + +def _proc_net_snapshot() -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + for name in ("tcp", "tcp6", "udp", "udp6"): + lines = _read_text(Path(f"/proc/net/{name}"), limit=256_000).splitlines() + result[name] = [_trim_text(line) for line in lines[:PROC_NET_LINE_LIMIT]] + return result + + +def _journal_snapshot() -> dict[str, Any]: + raw = _run(["journalctl", "-b", "--no-pager", "-n", "900", "-o", "short-iso"], timeout=4.0) + lines = str(raw.get("stdout") or "").splitlines() + filtered = [ + _trim_text(line) + for line in lines + if any(term in line.lower() for term in _JOURNAL_TERMS) + ] + return { + "command": raw.get("argv"), + "returncode": raw.get("returncode"), + "error": raw.get("error", ""), + "lines": filtered[-JOURNAL_LINE_LIMIT:], + } + + +def _upload_filename(filename: str | None = None) -> str: + name = str(filename or "").strip().replace("\\", "_").replace("/", "_") + if name and name.endswith(".txt"): + return name[:180] + stamp = time.strftime("%Y-%m-%dT%H-%M-%SZ", time.gmtime()) + return f"carrot_vision_diag_{stamp}.txt" + + +def _limit_upload_text(text: str) -> str: + raw = text.encode("utf-8", errors="replace") + if len(raw) <= DISCORD_FILE_MAX_BYTES: + return text + keep_each = max(256_000, (DISCORD_FILE_MAX_BYTES // 2) - 128_000) + head = raw[:keep_each].decode("utf-8", errors="replace") + tail = raw[-keep_each:].decode("utf-8", errors="replace") + return "\n".join([ + head, + "", + f"# ===== DISCORD UPLOAD TRUNCATED original_bytes={len(raw)} limit={DISCORD_FILE_MAX_BYTES} =====", + "", + tail, + ]) + + +def _console_upload_filename(filename: str | None, diag_filename: str) -> str: + name = str(filename or "").strip().replace("\\", "_").replace("/", "_") + if name and name.endswith(".txt"): + return name[:180] + if diag_filename.endswith(".txt"): + return f"{diag_filename[:-4]}_console.txt" + return f"{diag_filename}_console.txt" + + +def _discord_upload_content( + snapshot: dict[str, Any], + meta: dict[str, str], + filename: str, + text_bytes: int, + console_filename: str = "", + console_bytes: int = 0, +) -> str: + status = snapshot.get("vision_test", {}).get("status", {}) if isinstance(snapshot, dict) else {} + ports = snapshot.get("ports", {}) if isinstance(snapshot, dict) else {} + vipc = snapshot.get("vipc", {}) if isinstance(snapshot, dict) else {} + stream_history = snapshot.get("stream_proxy_history", []) if isinstance(snapshot, dict) else [] + commit = str(meta.get("commit") or "").strip() + commit_date = meta.get("commitDate") or "unknown" + commit_text = ( + f"[{commit}](https://github.com/ajouatom/openpilot/commit/{commit})" + if commit and commit != "unknown" + else "unknown" + ) + uploaded_at = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + car_name = meta.get("carName") or "none" + dongle_id = meta.get("dongleId") or "unknown" + upload_path = f"vision_diag/{car_name} {dongle_id}/".strip() + lines = [ + "# Carrot Vision Diagnostic", + "### Upload", + f"- Time: {uploaded_at}", + f"- Path: {upload_path}", + "### Device", + f"- Car name: {car_name}", + f"- DongleId: {dongle_id}", + f"- Serial: {meta.get('serial') or 'unknown'}", + f"- Branch: {meta.get('branch') or 'unknown'}", + f"- Commit: {commit_text} ({commit_date})", + "### Result", + f"- Vision test: {status.get('status') or 'unknown'}", + f"- VIPC streams: {','.join(map(str, vipc.get('camerad_streams') or [])) or 'none'}", + f"- WebRTC port 5001: {'open' if ports.get('webrtcd_5001_open') else 'closed'}", + f"- Stream requests: {len(stream_history) if isinstance(stream_history, list) else 0}", + f"- File: {filename} ({text_bytes} bytes)", + ] + if console_filename: + lines.append(f"- Console: {console_filename} ({console_bytes} bytes)") + return "\n".join(lines)[:1900] + + +async def upload_diagnostic_bundle_to_discord( + *, + bundle_text: str, + filename: str | None = None, + console_text: str = "", + console_filename: str | None = None, + source: str = "web", +) -> dict[str, Any]: + params = Params() if HAS_PARAMS else None + url = vision_diag_discord_webhook_url(params) + if not url: + return {"configured": False, "ok": False, "skipped": True} + if not url.startswith(("http://", "https://")): + return {"configured": True, "ok": False, "error": "invalid webhook url"} + + snapshot = await asyncio.to_thread(get_server_diagnostic_snapshot) + meta = _diagnostic_metadata(params) + upload_snapshot_title = "COMMA SERVER SNAPSHOT AT DISCORD UPLOAD" + upload_text = _limit_upload_text("\n".join([ + str(bundle_text or ""), + "", + f"# ===== {upload_snapshot_title} =====", + json.dumps(snapshot, ensure_ascii=False, indent=2), + f"# ===== END {upload_snapshot_title} =====", + ])) + upload_name = _upload_filename(filename) + upload_bytes = upload_text.encode("utf-8", errors="replace") + console_upload_name = "" + console_upload_bytes = b"" + if str(console_text or "").strip(): + console_upload_name = _console_upload_filename(console_filename, upload_name) + console_upload_text = _limit_upload_text(str(console_text or "")) + console_upload_bytes = console_upload_text.encode("utf-8", errors="replace") + payload = { + "username": "Carrot Vision", + "content": _discord_upload_content( + snapshot, + meta, + upload_name, + len(upload_bytes), + console_upload_name, + len(console_upload_bytes), + ), + "allowed_mentions": {"parse": []}, + "flags": 4, + } + + form = FormData() + form.add_field("payload_json", json.dumps(payload, ensure_ascii=False), content_type="application/json") + form.add_field("files[0]", upload_bytes, filename=upload_name, content_type="text/plain; charset=utf-8") + if console_upload_bytes: + form.add_field("files[1]", console_upload_bytes, filename=console_upload_name, content_type="text/plain; charset=utf-8") + + try: + timeout = ClientTimeout(total=20) + async with ClientSession(timeout=timeout) as session: + async with session.post(url, data=form) as resp: + text = await resp.text() + if 200 <= resp.status < 300: + return { + "configured": True, + "ok": True, + "status": resp.status, + "filename": upload_name, + "bytes": len(upload_bytes), + "console_filename": console_upload_name or None, + "console_bytes": len(console_upload_bytes) if console_upload_bytes else 0, + "source": source, + } + return { + "configured": True, + "ok": False, + "status": resp.status, + "filename": upload_name, + "bytes": len(upload_bytes), + "console_filename": console_upload_name or None, + "console_bytes": len(console_upload_bytes) if console_upload_bytes else 0, + "error": text[:1000], + } + except Exception as exc: + return { + "configured": True, + "ok": False, + "filename": upload_name, + "bytes": len(upload_bytes), + "console_filename": console_upload_name or None, + "console_bytes": len(console_upload_bytes) if console_upload_bytes else 0, + "error": str(exc), + } + + +def _socket_snapshot(processes: dict[str, list[dict[str, Any]]]) -> dict[str, Any]: + relevant_pids = { + str(proc["pid"]) + for entries in processes.values() + for proc in entries + if proc.get("pid") + } + raw = _run(["ss", "-tunap"], timeout=3.0) + lines = str(raw.get("stdout") or "").splitlines() + filtered = [ + _trim_text(line) + for line in lines + if ":5001" in line or any(f"pid={pid}," in line for pid in relevant_pids) + ] + return { + "command": raw.get("argv"), + "returncode": raw.get("returncode"), + "error": raw.get("error", ""), + "lines": filtered[-PROC_NET_LINE_LIMIT:], + } + + +def get_server_diagnostic_snapshot() -> dict[str, Any]: + processes = _find_processes() + try: + vision_test_status: dict[str, Any] = get_vision_test_status() + except Exception as exc: + vision_test_status = {"error": _trim_text(exc)} + + return { + "captured_at": time.time(), + "captured_at_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "system": { + "hostname": platform.node(), + "platform": platform.platform(), + "python": platform.python_version(), + "uptime_seconds": _trim_text(_read_text(Path("/proc/uptime"), limit=128)), + "loadavg": _trim_text(_read_text(Path("/proc/loadavg"), limit=128)), + }, + "params": _params_snapshot(), + "vipc": _vipc_snapshot(), + "ports": { + "webrtcd_5001_open": _port_open(5001), + }, + "processes": processes, + "stream_proxy_history": get_stream_proxy_history(), + "vision_test": { + "status": vision_test_status, + "log_path": str(VISION_TEST_LOG_PATH), + "log_tail": _tail_file(VISION_TEST_LOG_PATH, VISION_TEST_LOG_LINE_LIMIT), + }, + "sockets": _socket_snapshot(processes), + "proc_net": _proc_net_snapshot(), + "journal_tail": _journal_snapshot(), + } diff --git a/selfdrive/carrot/server/services/vision_test.py b/selfdrive/carrot/server/services/vision_test.py new file mode 100644 index 000000000..85a6d8f27 --- /dev/null +++ b/selfdrive/carrot/server/services/vision_test.py @@ -0,0 +1,412 @@ +from __future__ import annotations + +import argparse +import json +import os +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path("/data/openpilot") +STATE_PATH = Path("/tmp/carrot-vision-test-state.json") +LOG_PATH = Path("/tmp/carrot-vision-test.log") +TIMEOUT_SECONDS = 10 * 60 +RUNNER_MODULE = "selfdrive.carrot.server.services.vision_test" + +_PROCESS_SPECS = { + "camerad": { + "cmd": [str(REPO_ROOT / "system/camerad/camerad")], + "cwd": str(REPO_ROOT), + "match": "system/camerad/camerad", + }, + "stream_encoderd": { + "cmd": [str(REPO_ROOT / "system/loggerd/encoderd"), "--stream"], + "cwd": str(REPO_ROOT), + "match": "system/loggerd/encoderd\x00--stream", + }, + "webrtcd": { + "cmd": [sys.executable, "-m", "system.webrtc.webrtcd"], + "cwd": str(REPO_ROOT), + "match": "system.webrtc.webrtcd", + }, +} + + +def _params(): + from openpilot.common.params import Params + return Params() + + +def _set_snapshot_active(active: bool) -> None: + params = _params() + params.put_bool("IsTakingSnapshot", active) + try: + from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert + set_offroad_alert("Offroad_IsTakingSnapshot", active) + except Exception: + pass + + +def _write_state(state: dict[str, Any]) -> None: + state["updated_at"] = time.time() + temp_path = STATE_PATH.with_suffix(".tmp") + temp_path.write_text(json.dumps(state, sort_keys=True), encoding="utf-8") + temp_path.replace(STATE_PATH) + + +def _read_state() -> dict[str, Any]: + try: + raw = json.loads(STATE_PATH.read_text(encoding="utf-8")) + return raw if isinstance(raw, dict) else {} + except Exception: + return {} + + +def _pid_cmdline(pid: int) -> str: + try: + return Path(f"/proc/{int(pid)}/cmdline").read_bytes().decode(errors="replace") + except Exception: + return "" + + +def _pid_alive(pid: int, match: str = "") -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return not match or match in _pid_cmdline(pid) + + +def _find_matching_pids(match: str) -> list[int]: + matches = [] + for proc_path in Path("/proc").glob("[0-9]*"): + try: + pid = int(proc_path.name) + except ValueError: + continue + if match in _pid_cmdline(pid): + matches.append(pid) + return sorted(matches) + + +def _tail_log(lines: int) -> list[str]: + try: + return LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] + except Exception: + return [] + + +def _vipc_streams() -> list[int]: + try: + from msgq.visionipc import VisionIpcClient + return sorted(int(stream) for stream in VisionIpcClient.available_streams("camerad", block=False)) + except Exception: + return [] + + +def _port_open(port: int) -> bool: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + return False + + +def _children_status(state: dict[str, Any]) -> dict[str, dict[str, Any]]: + children = state.get("children") if isinstance(state.get("children"), dict) else {} + result = {} + for name, spec in _PROCESS_SPECS.items(): + pid = int(children.get(name) or 0) + result[name] = {"pid": pid, "alive": _pid_alive(pid, str(spec["match"]))} + return result + + +def get_status() -> dict[str, Any]: + state = _read_state() + runner_pid = int(state.get("runner_pid") or 0) + status = str(state.get("status") or "stopped") + runner_alive = _pid_alive(runner_pid, RUNNER_MODULE) + return { + **state, + "status": status if runner_alive or status == "error" else "stopped", + "runner_pid": runner_pid, + "runner_alive": runner_alive, + "children": _children_status(state), + "vipc_streams": _vipc_streams(), + "webrtcd_port_open": _port_open(5001), + "log_path": str(LOG_PATH), + } + + +def _format_duration(seconds: float) -> str: + seconds = max(0, int(seconds)) + return f"{seconds // 3600:02d}:{(seconds % 3600) // 60:02d}:{seconds % 60:02d}" + + +def print_status() -> int: + status = get_status() + started_at = float(status.get("started_at") or 0) + elapsed = _format_duration(time.time() - started_at) if started_at else "00:00:00" + print(f"[vision_test] {status['status']} elapsed={elapsed}") + print(f" runner pid={status['runner_pid'] or '-'} alive={str(status['runner_alive']).lower()}") + for name, child in status["children"].items(): + print(f" {name:<16} pid={child['pid'] or '-'} alive={str(child['alive']).lower()}") + streams = ", ".join(str(stream) for stream in status["vipc_streams"]) or "-" + print(f" VIPC streams {streams}") + print(f" webrtcd port 5001 open={str(status['webrtcd_port_open']).lower()}") + print(f" log {status['log_path']}") + error = str(status.get("error") or "").strip() + if error: + print(f" error {error}") + return 0 if status["runner_alive"] else 1 + + +def _terminate_pid(pid: int, match: str, timeout: float = 3.0) -> None: + if not _pid_alive(pid, match): + return + try: + os.kill(pid, signal.SIGTERM) + except OSError: + return + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not _pid_alive(pid, match): + return + time.sleep(0.1) + + if _pid_alive(pid, match): + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + +def _cleanup_owned_processes(state: dict[str, Any]) -> None: + children = state.get("children") if isinstance(state.get("children"), dict) else {} + for name in reversed(tuple(_PROCESS_SPECS)): + spec = _PROCESS_SPECS[name] + _terminate_pid(int(children.get(name) or 0), str(spec["match"])) + + +def start_test() -> int: + status = get_status() + if status["runner_alive"]: + print("[vision_test] already running") + return print_status() + + if not _params().get_bool("IsOffroad"): + print("[vision_test] refused: device is not offroad", file=sys.stderr) + return 1 + + for name, spec in _PROCESS_SPECS.items(): + pids = _find_matching_pids(str(spec["match"])) + if pids: + print(f"[vision_test] refused: {name} already running pid={','.join(map(str, pids))}", file=sys.stderr) + return 1 + + LOG_PATH.write_text("", encoding="utf-8") + with LOG_PATH.open("a", encoding="utf-8") as log: + proc = subprocess.Popen( + [sys.executable, "-m", RUNNER_MODULE, "_run"], + cwd=str(REPO_ROOT), + stdin=subprocess.DEVNULL, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + + print(f"[vision_test] starting runner pid={proc.pid}") + deadline = time.monotonic() + 12.0 + while time.monotonic() < deadline: + time.sleep(0.25) + status = get_status() + if status.get("status") == "running": + print("[vision_test] ready") + return print_status() + if status.get("status") == "error": + print(f"[vision_test] failed: {status.get('error') or 'unknown error'}", file=sys.stderr) + return 1 + if proc.poll() is not None: + print("[vision_test] runner exited during startup", file=sys.stderr) + return 1 + + print("[vision_test] startup is still in progress; run :vision_test status", file=sys.stderr) + return 1 + + +def stop_test() -> int: + state = _read_state() + runner_pid = int(state.get("runner_pid") or 0) + if _pid_alive(runner_pid, RUNNER_MODULE): + print(f"[vision_test] stopping runner pid={runner_pid}") + _terminate_pid(runner_pid, RUNNER_MODULE, timeout=15.0) + elif state: + print("[vision_test] runner is not active; cleaning stale state") + _cleanup_owned_processes(state) + _set_snapshot_active(False) + else: + print("[vision_test] runner is not active") + + state = _read_state() + if state: + state.update({"status": "stopped", "children": {}, "error": ""}) + _write_state(state) + print("[vision_test] stopped") + return 0 + + +def print_logs(lines: int) -> int: + print(f"[vision_test] log={LOG_PATH}") + for line in _tail_log(lines): + print(line) + return 0 + + +def _wait_for_vipc(timeout: float) -> list[int]: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + streams = _vipc_streams() + if streams: + return streams + time.sleep(0.25) + return [] + + +def _wait_for_port(port: int, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if _port_open(port): + return True + time.sleep(0.25) + return False + + +def _run_test() -> int: + stopped = False + children: dict[str, subprocess.Popen] = {} + state: dict[str, Any] = { + "status": "starting", + "runner_pid": os.getpid(), + "started_at": time.time(), + "children": {}, + "error": "", + } + + def request_stop(_signum=None, _frame=None) -> None: + nonlocal stopped + stopped = True + + signal.signal(signal.SIGTERM, request_stop) + signal.signal(signal.SIGINT, request_stop) + + def log(message: str) -> None: + print(f"[vision_test] {message}", flush=True) + + def start_child(name: str) -> None: + spec = _PROCESS_SPECS[name] + proc = subprocess.Popen( + list(spec["cmd"]), + cwd=str(spec["cwd"]), + stdin=subprocess.DEVNULL, + stdout=sys.stdout, + stderr=subprocess.STDOUT, + ) + children[name] = proc + state["children"][name] = proc.pid + _write_state(state) + log(f"{name} started pid={proc.pid}") + + _write_state(state) + try: + if not _params().get_bool("IsOffroad"): + raise RuntimeError("device is not offroad") + + _set_snapshot_active(True) + log("IsTakingSnapshot enabled") + time.sleep(2.0) + + start_child("camerad") + streams = _wait_for_vipc(8.0) + if not streams: + raise RuntimeError("camerad did not publish VisionIPC streams") + log(f"VIPC streams ready: {','.join(map(str, streams))}") + + start_child("stream_encoderd") + start_child("webrtcd") + if not _wait_for_port(5001, 8.0): + raise RuntimeError("webrtcd did not open port 5001") + log("webrtcd port ready: 5001") + + state["status"] = "running" + _write_state(state) + log(f"running timeout={TIMEOUT_SECONDS}s") + + deadline = time.monotonic() + TIMEOUT_SECONDS + while not stopped and time.monotonic() < deadline: + if not _params().get_bool("IsOffroad"): + log("offroad ended; stopping") + break + for name, proc in children.items(): + if proc.poll() is not None: + raise RuntimeError(f"{name} exited code={proc.returncode}") + time.sleep(0.5) + if time.monotonic() >= deadline: + log("timeout reached; stopping") + except Exception as exc: + state["status"] = "error" + state["error"] = str(exc) + _write_state(state) + log(f"error: {exc}") + return 1 + finally: + for name in reversed(tuple(children)): + spec = _PROCESS_SPECS[name] + _terminate_pid(children[name].pid, str(spec["match"])) + log(f"{name} stopped") + _set_snapshot_active(False) + state["children"] = {} + if state["status"] != "error": + state["status"] = "stopped" + _write_state(state) + log("IsTakingSnapshot cleared") + + return 0 + + +def run_command(args: list[str]) -> int: + parser = argparse.ArgumentParser(prog=":vision_test", add_help=False) + parser.add_argument("action", nargs="?", default="start", choices=("start", "status", "logs", "stop")) + parser.add_argument("--lines", type=int, default=80) + try: + options = parser.parse_args(args) + except SystemExit: + return 2 + + if options.action == "start": + return start_test() + if options.action == "status": + return print_status() + if options.action == "logs": + return print_logs(max(1, min(500, options.lines))) + return stop_test() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=("_run",)) + options = parser.parse_args(argv) + if options.action == "_run": + return _run_test() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/selfdrive/carrot/server/terminal_commands/README.md b/selfdrive/carrot/server/terminal_commands/README.md new file mode 100644 index 000000000..c7805a254 --- /dev/null +++ b/selfdrive/carrot/server/terminal_commands/README.md @@ -0,0 +1,58 @@ +# Web Terminal Meta Commands + +The Carrot web terminal sends normal input to its tmux shell unchanged. Input +starting with `:` is reserved for small web-terminal-only helpers. + +```text +:help +:help vision_test +:vision_test status +``` + +## Adding A Command + +1. Add `custom_commands/example.py`. +2. Register one handler with `register_command`. +3. Import the new module in `custom_commands/__init__.py`. +4. Print normal progress and result text from the handler. Output appears in + the existing web terminal because the command runs through the tmux shell. + +```python +from ..registry import register_command + + +@register_command( + name="example", + summary="Describe the helper in one line.", + usage=":example [status]", +) +def run(args: list[str]) -> int: + print("[example] ready") + return 0 +``` + +Use Python argument lists for subprocess calls. Do not pass user input to a +shell. Keep reusable process or state management in `server/services/`; the +command handler should remain a small text interface. + +## Layout + +```text +terminal_commands/ + bridge.py # Converts : input into a fixed CLI call. + cli.py # Parses one meta-command line. + registry.py # Stores command metadata and handlers. + custom_commands/ # Actual user-editable custom command files. + help.py + vision_test.py +``` + +## Bridge Flow + +1. `features/terminal.py` receives terminal input over the existing websocket. +2. `bridge.py` converts `:` input into a fixed Python CLI invocation. +3. `cli.py` parses the command and invokes a registered handler. +4. stdout and stderr are rendered by the existing tmux screen capture loop. + +This keeps ordinary shell behavior intact and makes each helper independently +editable. diff --git a/selfdrive/carrot/server/terminal_commands/__init__.py b/selfdrive/carrot/server/terminal_commands/__init__.py new file mode 100644 index 000000000..b7fe17abd --- /dev/null +++ b/selfdrive/carrot/server/terminal_commands/__init__.py @@ -0,0 +1,3 @@ +from .bridge import META_COMMAND_PREFIX, translate_meta_command + +__all__ = ["META_COMMAND_PREFIX", "translate_meta_command"] diff --git a/selfdrive/carrot/server/terminal_commands/bridge.py b/selfdrive/carrot/server/terminal_commands/bridge.py new file mode 100644 index 000000000..41009df7b --- /dev/null +++ b/selfdrive/carrot/server/terminal_commands/bridge.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import shlex + + +META_COMMAND_PREFIX = ":" +_CLI_MODULE = "selfdrive.carrot.server.terminal_commands.cli" + + +def translate_meta_command(line: str) -> str | None: + """Translate a web-terminal-only meta command into the fixed CLI bridge.""" + stripped = str(line or "").strip() + if not stripped.startswith(META_COMMAND_PREFIX): + return None + + command_line = stripped[len(META_COMMAND_PREFIX):].strip() or "help" + return shlex.join(["python3", "-m", _CLI_MODULE, "--line", command_line]) diff --git a/selfdrive/carrot/server/terminal_commands/cli.py b/selfdrive/carrot/server/terminal_commands/cli.py new file mode 100644 index 000000000..6df734efe --- /dev/null +++ b/selfdrive/carrot/server/terminal_commands/cli.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import argparse +import shlex +import sys + +from .custom_commands import load_commands +from .registry import get_command + + +def _parse_command_line(command_line: str) -> list[str] | None: + try: + return shlex.split(command_line) + except ValueError as exc: + print(f"[terminal] parse error: {exc}", file=sys.stderr) + return None + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--line", default="help") + options = parser.parse_args(argv) + + load_commands() + parts = _parse_command_line(options.line) + if parts is None: + return 2 + if not parts: + parts = ["help"] + + command = get_command(parts[0]) + if command is None: + print(f"[terminal] unknown command: {parts[0]}", file=sys.stderr) + print("[terminal] run :help to list available commands", file=sys.stderr) + return 2 + + try: + result = command.handler(parts[1:]) + except Exception as exc: + print(f"[terminal] {command.name} failed: {exc}", file=sys.stderr) + return 1 + return int(result or 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/selfdrive/carrot/server/terminal_commands/custom_commands/__init__.py b/selfdrive/carrot/server/terminal_commands/custom_commands/__init__.py new file mode 100644 index 000000000..5499ad303 --- /dev/null +++ b/selfdrive/carrot/server/terminal_commands/custom_commands/__init__.py @@ -0,0 +1,16 @@ +from __future__ import annotations + + +_loaded = False + + +def load_commands() -> None: + global _loaded + if _loaded: + return + + from . import help as _help + from . import vision_test as _vision_test + + del _help, _vision_test + _loaded = True diff --git a/selfdrive/carrot/server/terminal_commands/custom_commands/help.py b/selfdrive/carrot/server/terminal_commands/custom_commands/help.py new file mode 100644 index 000000000..38ab89de3 --- /dev/null +++ b/selfdrive/carrot/server/terminal_commands/custom_commands/help.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from ..bridge import META_COMMAND_PREFIX +from ..registry import get_command, iter_commands, register_command + + +@register_command( + name="help", + summary="List web terminal meta commands or show command usage.", + usage=":help [command]", +) +def run(args: list[str]) -> int: + if args: + command = get_command(args[0]) + if command is None: + print(f"[terminal] unknown command: {args[0]}") + return 2 + print(f"{command.usage}\n {command.summary}") + return 0 + + print("Web terminal meta commands") + for command in iter_commands(): + print(f" {META_COMMAND_PREFIX}{command.name:<18} {command.summary}") + print("\nRun :help for usage. Other input is sent to the shell unchanged.") + return 0 diff --git a/selfdrive/carrot/server/terminal_commands/custom_commands/vision_test.py b/selfdrive/carrot/server/terminal_commands/custom_commands/vision_test.py new file mode 100644 index 000000000..ad09ad6fc --- /dev/null +++ b/selfdrive/carrot/server/terminal_commands/custom_commands/vision_test.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from ...services import vision_test +from ..registry import register_command + + +@register_command( + name="vision_test", + summary="Run an offroad Carrot Vision camera and WebRTC test.", + usage=":vision_test [start|status|logs|stop] [--lines N]", +) +def run(args: list[str]) -> int: + return vision_test.run_command(args) diff --git a/selfdrive/carrot/server/terminal_commands/registry.py b/selfdrive/carrot/server/terminal_commands/registry.py new file mode 100644 index 000000000..11f072239 --- /dev/null +++ b/selfdrive/carrot/server/terminal_commands/registry.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Callable, Iterable + + +CommandHandler = Callable[[list[str]], int | None] +_COMMAND_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") + + +@dataclass(frozen=True) +class TerminalCommand: + name: str + summary: str + usage: str + handler: CommandHandler + + +_commands: dict[str, TerminalCommand] = {} + + +def register_command(*, name: str, summary: str, usage: str) -> Callable[[CommandHandler], CommandHandler]: + normalized = str(name or "").strip().lower() + if not _COMMAND_NAME_RE.fullmatch(normalized): + raise ValueError(f"invalid terminal command name: {name!r}") + + def decorator(handler: CommandHandler) -> CommandHandler: + if normalized in _commands: + raise ValueError(f"duplicate terminal command: {normalized}") + _commands[normalized] = TerminalCommand( + name=normalized, + summary=str(summary or "").strip(), + usage=str(usage or "").strip(), + handler=handler, + ) + return handler + + return decorator + + +def get_command(name: str) -> TerminalCommand | None: + return _commands.get(str(name or "").strip().lower()) + + +def iter_commands() -> Iterable[TerminalCommand]: + return sorted(_commands.values(), key=lambda command: command.name) diff --git a/selfdrive/carrot/web/css/components/nav_hud.css b/selfdrive/carrot/web/css/components/nav_hud.css index 7def4f5b2..b86b415de 100644 --- a/selfdrive/carrot/web/css/components/nav_hud.css +++ b/selfdrive/carrot/web/css/components/nav_hud.css @@ -11,6 +11,21 @@ --nav-side-width: 188px; --nav-gap: 12px; --nav-card-height: 88px; + --nav-scale: 1; + --nav-padding-y: 11px; + --nav-main-padding-x: 18px; + --nav-side-padding-x: 14px; + --nav-side-gap: 14px; + --nav-body-gap: 4px; + --nav-side-sign-size: 56px; + --nav-side-sign-font: 25px; + --nav-side-dist-font: 23px; + --nav-side-label-font: 18px; + --nav-icon-size: 62px; + --nav-icon-font: 38px; + --nav-dist-font: 33px; + --nav-road-font: 21px; + --nav-meta-font: 17px; --nav-top: 56px; --nav-main-left: 50%; --nav-side-left: 14px; @@ -72,7 +87,7 @@ min-height: var(--nav-card-height); margin: 0; margin-left: var(--nav-main-left); - padding: 11px 18px 11px 14px; + padding: var(--nav-padding-y) var(--nav-main-padding-x) var(--nav-padding-y) calc(var(--nav-main-padding-x) * 0.78); border-radius: var(--nav-radius); } @@ -89,10 +104,11 @@ display: flex; align-items: center; gap: clamp(11px, 1.0vw, 15px); + gap: var(--nav-side-gap); width: var(--nav-side-width); height: var(--nav-card-height); min-height: var(--nav-card-height); - padding: 11px 14px; + padding: var(--nav-padding-y) var(--nav-side-padding-x); border-radius: var(--nav-radius); transform: translateY(-50%); } @@ -112,14 +128,14 @@ .carrot-nav-hud__side-sign { flex: 0 0 auto; - width: clamp(50px, 4.4vw, 60px); - height: clamp(50px, 4.4vw, 60px); + width: var(--nav-side-sign-size); + height: var(--nav-side-sign-size); display: grid; place-items: center; border-radius: 50%; background: #fff; color: #c00024; - font-size: clamp(22px, 1.72vw, 27px); + font-size: var(--nav-side-sign-font); font-family: "Arial Black", Impact, var(--font-sans, sans-serif); font-weight: 900; line-height: 1; @@ -139,12 +155,12 @@ min-width: 0; display: flex; flex-direction: column; - gap: 4px; + gap: var(--nav-body-gap); } .carrot-nav-hud__side-dist { color: #fff; - font-size: clamp(20px, 1.55vw, 25px); + font-size: var(--nav-side-dist-font); font-weight: 1000; line-height: 1.05; white-space: nowrap; @@ -159,35 +175,35 @@ .carrot-nav-hud__side-label { color: rgba(255, 255, 255, 0.98); - font-size: clamp(16px, 1.18vw, 20px); + font-size: var(--nav-side-label-font); font-weight: 920; - line-height: 1.08; + line-height: 1.12; word-break: keep-all; overflow-wrap: normal; overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; + display: block; + white-space: nowrap; + text-overflow: ellipsis; text-shadow: 0 2px 3px rgba(0, 0, 0, 0.82); } .carrot-nav-hud[data-layout="compact"] .carrot-nav-hud__side { - gap: 9px; - padding-inline: 11px; + gap: calc(var(--nav-side-gap) * 0.82); + padding-inline: calc(var(--nav-side-padding-x) * 0.82); } .carrot-nav-hud[data-layout="compact"] .carrot-nav-hud__side-sign { - width: 48px; - height: 48px; - font-size: 21px; + width: calc(var(--nav-side-sign-size) * 0.88); + height: calc(var(--nav-side-sign-size) * 0.88); + font-size: calc(var(--nav-side-sign-font) * 0.88); } .carrot-nav-hud[data-layout="compact"] .carrot-nav-hud__side-dist { - font-size: 19px; + font-size: calc(var(--nav-side-dist-font) * 0.88); } .carrot-nav-hud[data-layout="compact"] .carrot-nav-hud__side-label { - font-size: 16px; + font-size: calc(var(--nav-side-label-font) * 0.90); } .carrot-nav-hud__side-countdown { @@ -232,8 +248,8 @@ .carrot-nav-hud__icon { flex: 0 0 auto; - width: clamp(54px, 4.7vw, 66px); - height: clamp(54px, 4.7vw, 66px); + width: var(--nav-icon-size); + height: var(--nav-icon-size); display: grid; place-items: center; border-radius: clamp(13px, 1.2vw, 17px); @@ -241,7 +257,7 @@ linear-gradient(160deg, rgba(255,255,255,.22), rgba(255,255,255,0) 38%), linear-gradient(180deg, #ffb55e 0%, #f0791f 100%); color: #fff; - font-size: clamp(32px, 2.7vw, 41px); + font-size: var(--nav-icon-font); font-weight: 950; line-height: 1; box-shadow: @@ -281,7 +297,7 @@ min-width: 0; display: flex; flex-direction: column; - gap: 4px; + gap: var(--nav-body-gap); } .carrot-nav-hud__dist-row { @@ -293,7 +309,7 @@ .carrot-nav-hud__dist { color: var(--nav-amber); - font-size: clamp(27px, 2.35vw, 36px); + font-size: var(--nav-dist-font); font-weight: 950; line-height: 1.02; letter-spacing: -0.02em; @@ -350,7 +366,7 @@ flex: 1 1 auto; min-width: 0; color: rgba(255, 255, 255, 0.95); - font-size: clamp(18px, 1.45vw, 23px); + font-size: var(--nav-road-font); font-weight: 900; line-height: 1.18; overflow: hidden; @@ -383,7 +399,7 @@ .carrot-nav-hud__meta { color: rgba(255, 255, 255, 0.90); - font-size: clamp(15px, 1.14vw, 18px); + font-size: var(--nav-meta-font); font-weight: 900; line-height: 1.15; overflow: hidden; diff --git a/selfdrive/carrot/web/css/pages/drive.css b/selfdrive/carrot/web/css/pages/drive.css index 82f5faf56..88948e51c 100644 --- a/selfdrive/carrot/web/css/pages/drive.css +++ b/selfdrive/carrot/web/css/pages/drive.css @@ -36,6 +36,238 @@ body[data-page="carrot"] #driveHudCard { display: block; } +.carrot-rtc-perf { + position: absolute; + left: 0; + bottom: calc(100% + 6px); + z-index: 8; + width: 100%; + pointer-events: none; +} + +.carrot-rtc-perf[hidden], +.carrot-rtc-perf-summary[hidden] { + display: none !important; +} + +.carrot-rtc-perf-glance { + display: flex; + align-items: center; + gap: 7px; + width: 100%; + min-width: 0; + height: 30px; + padding: 0 10px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 8px; + background: rgba(6, 19, 14, 0.88); + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.30); + color: rgba(242, 255, 247, 0.96); + font: 800 11px/1.1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: -0.18px; +} + +.carrot-rtc-perf-glance__text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.carrot-rtc-perf__dot { + flex: 0 0 auto; + width: 8px; + height: 8px; + border-radius: 999px; + background: #48d786; + box-shadow: 0 0 9px rgba(72, 215, 134, 0.66); +} + +.carrot-rtc-perf-glance[data-tone="degraded"], +.carrot-rtc-perf-summary[data-tone="degraded"] { + border-color: rgba(255, 207, 80, 0.48); +} + +.carrot-rtc-perf-glance[data-tone="degraded"] .carrot-rtc-perf__dot, +.carrot-rtc-perf-summary[data-tone="degraded"] .carrot-rtc-perf__dot { + background: #ffcf50; + box-shadow: 0 0 9px rgba(255, 207, 80, 0.66); +} + +.carrot-rtc-perf-glance[data-tone="reconnecting"], +.carrot-rtc-perf-summary[data-tone="reconnecting"] { + border-color: rgba(255, 157, 66, 0.56); +} + +.carrot-rtc-perf-glance[data-tone="reconnecting"] .carrot-rtc-perf__dot, +.carrot-rtc-perf-summary[data-tone="reconnecting"] .carrot-rtc-perf__dot { + background: #ff9d42; + box-shadow: 0 0 9px rgba(255, 157, 66, 0.70); +} + +.carrot-rtc-perf-glance[data-tone="stall"], +.carrot-rtc-perf-summary[data-tone="stall"] { + border-color: rgba(255, 92, 92, 0.62); + background: rgba(47, 10, 14, 0.92); +} + +.carrot-rtc-perf-glance[data-tone="stall"] .carrot-rtc-perf__dot, +.carrot-rtc-perf-summary[data-tone="stall"] .carrot-rtc-perf__dot { + background: #ff5c5c; + box-shadow: 0 0 10px rgba(255, 92, 92, 0.78); +} + +.carrot-rtc-perf-glance[data-tone="offline"], +.carrot-rtc-perf-summary[data-tone="offline"] { + border-color: rgba(191, 202, 197, 0.26); + background: rgba(19, 24, 23, 0.90); +} + +.carrot-rtc-perf-glance[data-tone="offline"] .carrot-rtc-perf__dot, +.carrot-rtc-perf-summary[data-tone="offline"] .carrot-rtc-perf__dot { + background: #aeb9b4; + box-shadow: none; +} + +.carrot-rtc-perf-summary { + position: absolute; + left: 0; + bottom: 36px; + width: min(520px, calc(100vw - 24px)); + max-width: calc(100vw - 24px); + padding: 11px 12px 12px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 11px; + background: rgba(6, 28, 20, 0.94); + box-shadow: 0 14px 28px rgba(0, 0, 0, 0.42); + color: rgba(245, 255, 249, 0.96); + pointer-events: auto; + backdrop-filter: blur(9px); + -webkit-backdrop-filter: blur(9px); +} + +.carrot-rtc-perf-summary__head { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.carrot-rtc-perf-summary__title { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + color: rgba(250, 255, 252, 0.98); + font: 900 13px/1.1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + text-overflow: ellipsis; + white-space: nowrap; +} + +.carrot-rtc-perf-summary__action, +.carrot-rtc-perf-summary__close { + flex: 0 0 auto; + min-width: 32px; + height: 24px; + padding: 0 7px; + border: 1px solid rgba(255, 255, 255, 0.20); + border-radius: 7px; + background: rgba(255, 255, 255, 0.04); + color: rgba(242, 255, 247, 0.92); + font: 900 10px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + cursor: pointer; +} + +.carrot-rtc-perf-summary__close { + min-width: 24px; + padding: 0; + font-size: 12px; +} + +.carrot-rtc-perf-summary__sub { + margin-top: 4px; + padding-bottom: 7px; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + color: rgba(193, 225, 207, 0.72); + font: 800 9px/1.1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.28px; +} + +.carrot-rtc-perf-summary__grid { + display: grid; + grid-template-columns: auto minmax(92px, 1fr) auto minmax(92px, 1fr); + gap: 7px 10px; + align-items: center; + margin-top: 8px; + font: 700 11px/1.15 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.carrot-rtc-perf-summary__grid span { + color: rgba(193, 225, 207, 0.70); +} + +.carrot-rtc-perf-summary__grid strong { + min-width: 0; + overflow: hidden; + color: rgba(247, 255, 250, 0.96); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.carrot-rtc-perf-summary__grid .carrot-rtc-perf-summary__path-label { + align-self: start; + padding-top: 1px; +} + +.carrot-rtc-perf-summary__grid .carrot-rtc-perf-summary__path-value { + grid-column: 2 / -1; + text-align: left; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.24; +} + +.carrot-rtc-perf-hold-target { + position: absolute; + inset: 0; + z-index: 7; + width: 100%; + height: 100%; + padding: 0; + border: 0; + border-radius: var(--hud-radius, 18px); + background: transparent; + cursor: pointer; + pointer-events: auto; + touch-action: manipulation; +} + +.carrot-rtc-perf-hold-target.is-holding { + background: rgba(255, 255, 255, 0.035); +} + +.carrot-rtc-perf-hold-target::after { + content: ""; + position: absolute; + inset: 0; + border: 2px solid #48d786; + border-radius: 18px; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} + +.carrot-rtc-perf-hold-target.is-holding::after { + opacity: 0.82; + animation: carrot-rtc-perf-hold-progress 0.6s linear; +} + +@keyframes carrot-rtc-perf-hold-progress { + from { clip-path: inset(0 100% 0 0); } + to { clip-path: inset(0 0 0 0); } +} + .carrot-hud-dock { display: none; } @@ -621,6 +853,10 @@ body[data-page="carrot"] #driveHudCard.driveHudCard--loading { } @media (orientation: portrait) { + .carrot-rtc-perf-summary { + display: none !important; + } + .page--drive { justify-content: stretch; } diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 7c8e1357a..30b5b9b69 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -83,8 +83,8 @@ - - + + @@ -484,6 +484,32 @@
+ +