diff --git a/CLAUDE.md b/CLAUDE.md index 168af72..fe1255a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ chown -R comma:comma /data/openpilot ### Testing Changes -Always use `build_only.sh` to compile, then start the manager separately. Never compile individual targets with scons directly — always use the full build script. Always use full paths with `su - comma` — the login shell drops into `/home/comma` (volatile tmpfs), not `/data/openpilot`. +Always use `build_only.sh` to compile, then start the manager separately. Never compile individual targets with scons directly — always use the full build script. Always use full paths with `su - comma` — the login shell drops into `/home/comma` (volatile tmpfs), not `/data/openpilot`. **Always start the manager after a successful build** — don't wait for the user to ask. ```bash # 1. Fix ownership (we edit as root, openpilot runs as comma) @@ -93,6 +93,65 @@ The params system uses a C++ whitelist. Adding a new param name in `manager.py` - OMX encoder object (`omx_encoder.o`) is compiled by the UI build — reference the pre-built `.o` file rather than recompiling (avoids "two environments" scons error) - `prebuilt` is recreated after every successful build — always remove it before rebuilding +## Bench Mode (Onroad UI Testing) + +Bench mode allows testing the onroad UI without a car connected. It runs a fake vehicle simulator (`bench_onroad.py`) as a managed process and disables real car processes (pandad, thermald, controlsd, etc.). + +### Usage + +**IMPORTANT**: Do NOT use `echo` to write bench params — `echo` appends a newline which causes param parsing to fail silently (e.g. gear stays in park). Always use the `bench_cmd.py` tool. + +```bash +# 1. Start in bench mode +su - comma -c "bash /data/openpilot/launch_openpilot.sh --bench" + +# 2. Wait for UI to be ready (polls RPC, up to 10s) +su - comma -c "PYTHONPATH=/data/openpilot python3 -m selfdrive.clearpilot.bench_cmd wait_ready" + +# 3. Control vehicle state +su - comma -c "PYTHONPATH=/data/openpilot python3 -m selfdrive.clearpilot.bench_cmd gear D" +su - comma -c "PYTHONPATH=/data/openpilot python3 -m selfdrive.clearpilot.bench_cmd speed 20" +su - comma -c "PYTHONPATH=/data/openpilot python3 -m selfdrive.clearpilot.bench_cmd speedlimit 45" +su - comma -c "PYTHONPATH=/data/openpilot python3 -m selfdrive.clearpilot.bench_cmd cruise 55" +su - comma -c "PYTHONPATH=/data/openpilot python3 -m selfdrive.clearpilot.bench_cmd engaged 1" + +# 4. Inspect UI widget tree (RPC call, instant response) +su - comma -c "PYTHONPATH=/data/openpilot python3 -m selfdrive.clearpilot.bench_cmd dump" +``` + +### UI Introspection RPC + +The UI process runs a ZMQ REP server at `ipc:///tmp/clearpilot_ui_rpc`. Send `"dump"` to get a recursive widget tree showing class name, visibility, geometry, and stacked layout current indices. This is the primary debugging tool for understanding what the UI is displaying. + +- `bench_cmd dump` — queries the RPC and prints the widget tree +- `bench_cmd wait_ready` — polls the RPC every second until `ReadyWindow` is visible (up to 10s) +- `ui_dump.py` — standalone dump tool (same as `bench_cmd dump`) + +### Architecture + +- `launch_openpilot.sh --bench` sets `BENCH_MODE=1` env var +- `manager.py` reads `BENCH_MODE`, blocks real car processes, enables `bench_onroad` process +- `bench_onroad.py` publishes fake `deviceState`, `pandaStates`, `carState`, `controlsState` at correct frequencies +- The UI receives these messages identically to real car data +- Blocked processes in bench mode: pandad, thermald, controlsd, radard, plannerd, calibrationd, torqued, paramsd, locationd, sensord, ubloxd, pigeond, dmonitoringmodeld, dmonitoringd, modeld, soundd, camerad, loggerd, micd, dashcamd + +### Key Files + +| File | Role | +|------|------| +| `selfdrive/clearpilot/bench_onroad.py` | Fake vehicle state publisher | +| `selfdrive/clearpilot/bench_cmd.py` | Command tool for setting bench params and querying UI | +| `selfdrive/clearpilot/ui_dump.py` | Standalone UI widget tree dump | +| `selfdrive/manager/process_config.py` | Registers bench_onroad as managed process (enabled=BENCH_MODE) | +| `selfdrive/manager/manager.py` | Blocks conflicting processes in bench mode | +| `launch_openpilot.sh` | Accepts `--bench` flag, exports BENCH_MODE env var | +| `selfdrive/ui/qt/window.cc` | UI RPC server (`ipc:///tmp/clearpilot_ui_rpc`), widget tree dump | + +### Known Issues + +- The UI segfaults (exit code -11) when switching to the onroad camera view without a camera feed. The `CameraWidget` / `AnnotatedCameraWidget` crash on empty VisionIPC buffers. camerad is blocked in bench mode, but the `OnroadWindow` still contains a `CameraWidget` that crashes when made visible. Need to either provide fake VisionIPC frames or make the camera widget gracefully handle missing data. +- The `showDriverView` function in `home.cc` was previously overriding park/drive transitions every frame. Fixed to only act when not in started+parked state, but transitions to onroad (drive) view still trigger the camera crash. + ## Session Logging Per-process stderr and an aggregate event log are captured in `/data/log2/{session}/`. diff --git a/common/params.cc b/common/params.cc index da74e4d..166179b 100755 --- a/common/params.cc +++ b/common/params.cc @@ -193,6 +193,7 @@ std::unordered_map keys = { {"SnoozeUpdate", CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION}, {"SshEnabled", PERSISTENT}, {"TermsVersion", PERSISTENT}, + {"TelemetryEnabled", PERSISTENT}, {"Timezone", PERSISTENT}, {"TrainingVersion", PERSISTENT}, {"UbloxAvailable", PERSISTENT}, @@ -211,6 +212,12 @@ std::unordered_map keys = { // FrogPilot parameters {"AccelerationPath", PERSISTENT}, + {"BenchCruiseSpeed", CLEAR_ON_MANAGER_START}, + {"ClpUiState", CLEAR_ON_MANAGER_START}, + {"BenchEngaged", CLEAR_ON_MANAGER_START}, + {"BenchGear", CLEAR_ON_MANAGER_START}, + {"BenchSpeed", CLEAR_ON_MANAGER_START}, + {"BenchSpeedLimit", CLEAR_ON_MANAGER_START}, {"AccelerationProfile", PERSISTENT}, {"AdjacentPath", PERSISTENT}, {"AdjacentPathMetrics", PERSISTENT}, diff --git a/launch_openpilot.sh b/launch_openpilot.sh index 4e634fb..0c400fb 100755 --- a/launch_openpilot.sh +++ b/launch_openpilot.sh @@ -12,5 +12,10 @@ sleep 1 bash /data/openpilot/system/clearpilot/on_start.sh +# CLEARPILOT: pass --bench flag through to manager via env var +if [ "$1" = "--bench" ]; then + export BENCH_MODE=1 +fi + cd /data/openpilot exec ./launch_chffrplus.sh diff --git a/selfdrive/clearpilot/bench_cmd.py b/selfdrive/clearpilot/bench_cmd.py new file mode 100644 index 0000000..8d6c599 --- /dev/null +++ b/selfdrive/clearpilot/bench_cmd.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +ClearPilot bench command tool. Sets bench params and queries UI state. + +Usage: + python3 -m selfdrive.clearpilot.bench_cmd gear D + python3 -m selfdrive.clearpilot.bench_cmd speed 20 + python3 -m selfdrive.clearpilot.bench_cmd speedlimit 45 + python3 -m selfdrive.clearpilot.bench_cmd cruise 55 + python3 -m selfdrive.clearpilot.bench_cmd engaged 1 + python3 -m selfdrive.clearpilot.bench_cmd dump + python3 -m selfdrive.clearpilot.bench_cmd wait_ready +""" +import os +import subprocess +import sys +import time +import zmq +from openpilot.common.params import Params + + +def check_ui_process(): + """Check if UI process is running and healthy. Returns error string or None if OK.""" + try: + result = subprocess.run(["pgrep", "-a", "-f", "./ui"], capture_output=True, text=True) + if result.returncode != 0: + return "ERROR: UI process not running" + # Get the PID and check its uptime + for line in result.stdout.strip().split("\n"): + parts = line.split(None, 1) + if len(parts) >= 2 and "./ui" in parts[1]: + pid = parts[0] + try: + stat = os.stat(f"/proc/{pid}") + uptime = time.time() - stat.st_mtime + if uptime < 5: + return f"ERROR: UI process (pid {pid}) uptime {uptime:.1f}s — likely crash looping. Check: tail /data/log2/$(ls -t /data/log2/ | head -1)/session.log" + except FileNotFoundError: + return "ERROR: UI process disappeared" + except Exception: + pass + return None + + +def ui_dump(): + ctx = zmq.Context() + sock = ctx.socket(zmq.REQ) + sock.setsockopt(zmq.RCVTIMEO, 2000) + sock.connect("ipc:///tmp/clearpilot_ui_rpc") + sock.send_string("dump") + try: + return sock.recv_string() + except zmq.Again: + return None + finally: + sock.close() + ctx.term() + + +def wait_ready(timeout=10): + """Wait until the UI shows ReadyWindow, up to timeout seconds.""" + start = time.time() + while time.time() - start < timeout: + dump = ui_dump() + if dump and "ReadyWindow" in dump: + # Check it's actually visible + for line in dump.split("\n"): + if "ReadyWindow" in line and "vis=Y" in line: + print("UI ready (ReadyWindow visible)") + return True + time.sleep(1) + print(f"ERROR: UI not ready after {timeout}s") + if dump: + print(dump) + return False + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + return + + cmd = sys.argv[1].lower() + params = Params("/dev/shm/params") + + param_map = { + "gear": "BenchGear", + "speed": "BenchSpeed", + "speedlimit": "BenchSpeedLimit", + "cruise": "BenchCruiseSpeed", + "engaged": "BenchEngaged", + } + + if cmd == "dump": + ui_status = check_ui_process() + if ui_status: + print(ui_status) + else: + result = ui_dump() + if result: + print(result) + else: + print("ERROR: UI not responding") + + elif cmd == "wait_ready": + wait_ready() + + elif cmd in param_map: + if len(sys.argv) < 3: + print(f"Usage: bench_cmd {cmd} ") + return + value = sys.argv[2] + params.put(param_map[cmd], value) + print(f"{param_map[cmd]} = {value}") + + else: + print(f"Unknown command: {cmd}") + print(__doc__) + + +if __name__ == "__main__": + main() diff --git a/selfdrive/clearpilot/bench_onroad.py b/selfdrive/clearpilot/bench_onroad.py new file mode 100644 index 0000000..0c4ee2b --- /dev/null +++ b/selfdrive/clearpilot/bench_onroad.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +""" +ClearPilot bench onroad simulator. + +Publishes fake messages to make the UI go onroad and display +configurable vehicle state. Control values via params in /dev/shm/params: + + BenchSpeed - vehicle speed in mph (default: 0) + BenchSpeedLimit - speed limit in mph (default: 0, 0=hidden) + BenchCruiseSpeed - cruise set speed in mph (default: 0, 0=not set) + BenchGear - P, D, R, N (default: P) + BenchEngaged - 0 or 1, cruise engaged (default: 0) + +Usage: + su - comma -c "PYTHONPATH=/data/openpilot python3 -m selfdrive.clearpilot.bench_onroad" + +To stop: Ctrl+C or kill the process. UI returns to offroad. +""" +import time + +import cereal.messaging as messaging +from cereal import log, car +from openpilot.common.params import Params + +MS_PER_MPH = 0.44704 + + +def main(): + params = Params() + params_mem = Params("/dev/shm/params") + + # Set defaults + params_mem.put("BenchSpeed", "0") + params_mem.put("BenchSpeedLimit", "0") + params_mem.put("BenchCruiseSpeed", "0") + params_mem.put("BenchGear", "P") + params_mem.put("BenchEngaged", "0") + + pm = messaging.PubMaster([ + "deviceState", "pandaStates", "carState", "controlsState", + "driverMonitoringState", "liveCalibration", + ]) + + print("Bench onroad simulator started") + print("Set values in /dev/shm/params/d/Bench*") + print(" BenchSpeed=0 BenchSpeedLimit=0 BenchCruiseSpeed=0 BenchGear=P BenchEngaged=0") + + gear_map = { + "P": car.CarState.GearShifter.park, + "D": car.CarState.GearShifter.drive, + "R": car.CarState.GearShifter.reverse, + "N": car.CarState.GearShifter.neutral, + } + + frame = 0 + try: + while True: + # Read bench params + speed_mph = float((params_mem.get("BenchSpeed", encoding="utf-8") or "0").strip()) + speed_limit_mph = float((params_mem.get("BenchSpeedLimit", encoding="utf-8") or "0").strip()) + cruise_mph = float((params_mem.get("BenchCruiseSpeed", encoding="utf-8") or "0").strip()) + gear_str = (params_mem.get("BenchGear", encoding="utf-8") or "P").strip().upper() + engaged = (params_mem.get("BenchEngaged", encoding="utf-8") or "0").strip() == "1" + + speed_ms = speed_mph * MS_PER_MPH + gear = gear_map.get(gear_str, car.CarState.GearShifter.park) + + # deviceState — 2 Hz + if frame % 5 == 0: + dat = messaging.new_message("deviceState") + dat.deviceState.started = True + dat.deviceState.freeSpacePercent = 80 + dat.deviceState.memoryUsagePercent = 30 + dat.deviceState.cpuTempC = [40.0] * 3 + dat.deviceState.gpuTempC = [40.0] * 3 + dat.deviceState.cpuUsagePercent = [20] * 8 + dat.deviceState.networkType = log.DeviceState.NetworkType.cell4G + pm.send("deviceState", dat) + + # pandaStates — 10 Hz + if frame % 1 == 0: + dat = messaging.new_message("pandaStates", 1) + dat.pandaStates[0].ignitionLine = True + dat.pandaStates[0].pandaType = log.PandaState.PandaType.tres + pm.send("pandaStates", dat) + + # carState — 10 Hz + dat = messaging.new_message("carState") + cs = dat.carState + cs.vEgo = speed_ms + cs.vEgoCluster = speed_ms + cs.gearShifter = gear + cs.standstill = speed_ms < 0.1 + cs.cruiseState.available = True + cs.cruiseState.enabled = engaged + cs.cruiseState.speed = cruise_mph * MS_PER_MPH if cruise_mph > 0 else 0 + pm.send("carState", dat) + + # controlsState — 10 Hz + dat = messaging.new_message("controlsState") + ctl = dat.controlsState + ctl.vCruise = cruise_mph * 1.60934 if cruise_mph > 0 else 255 # km/h or 255=not set + ctl.vCruiseCluster = ctl.vCruise + ctl.enabled = engaged + ctl.active = engaged + pm.send("controlsState", dat) + + # driverMonitoringState — low freq + if frame % 10 == 0: + dat = messaging.new_message("driverMonitoringState") + dat.driverMonitoringState.isActiveMode = True + pm.send("driverMonitoringState", dat) + + # liveCalibration — low freq + if frame % 50 == 0: + dat = messaging.new_message("liveCalibration") + dat.liveCalibration.calStatus = log.LiveCalibrationData.Status.calibrated + dat.liveCalibration.rpyCalib = [0.0, 0.0, 0.0] + pm.send("liveCalibration", dat) + + frame += 1 + time.sleep(0.1) # 10 Hz base loop + + except KeyboardInterrupt: + print("\nBench simulator stopped") + + +if __name__ == "__main__": + main() diff --git a/selfdrive/clearpilot/telemetryd.py b/selfdrive/clearpilot/telemetryd.py index 3a4537d..bda081a 100644 --- a/selfdrive/clearpilot/telemetryd.py +++ b/selfdrive/clearpilot/telemetryd.py @@ -5,18 +5,28 @@ ClearPilot telemetry collector. Receives telemetry packets from any process via ZMQ, diffs against previous state per group, and writes only changed values to CSV. +Controlled by TelemetryEnabled param — when toggled on, the first packet +from each group writes all values (full dump). When toggled off, stops writing. + CSV format: timestamp,group,key,value """ import csv import json import os +import shutil +import time import zmq +from openpilot.common.params import Params from openpilot.selfdrive.clearpilot.telemetry import TELEMETRY_SOCK -from openpilot.selfdrive.manager.process import _log_dir +from openpilot.selfdrive.manager.process import _log_dir, session_log + +MIN_DISK_FREE_GB = 5 +DISK_CHECK_INTERVAL = 10 # seconds def main(): + params = Params() ctx = zmq.Context.instance() sock = ctx.socket(zmq.PULL) sock.setsockopt(zmq.RCVHWM, 1000) @@ -24,43 +34,80 @@ def main(): csv_path = os.path.join(_log_dir, "telemetry.csv") state: dict[str, dict] = {} # group -> {key: last_value} + was_enabled = False + writer = None + f = None + last_disk_check = 0 - with open(csv_path, "w", newline="") as f: - writer = csv.writer(f) - writer.writerow(["timestamp", "group", "key", "value"]) - f.flush() + while True: + # Check enable state every iteration (cheap param read) + enabled = params.get("TelemetryEnabled") == b"1" - while True: - try: - raw = sock.recv_string() - except zmq.ZMQError: - continue + # Check disk space every 10 seconds while enabled + if enabled and (time.monotonic() - last_disk_check) > DISK_CHECK_INTERVAL: + last_disk_check = time.monotonic() + disk = shutil.disk_usage("/data") + free_gb = disk.free / (1024 ** 3) + if free_gb < MIN_DISK_FREE_GB: + session_log.warning("telemetry disabled: disk free %.1f GB < %d GB", free_gb, MIN_DISK_FREE_GB) + params.put("TelemetryEnabled", "0") + enabled = False - try: - pkt = json.loads(raw) - except json.JSONDecodeError: - continue + # Toggled on: open CSV, clear state so first packet is a full dump + if enabled and not was_enabled: + f = open(csv_path, "a", newline="") + writer = csv.writer(f) + if os.path.getsize(csv_path) == 0: + writer.writerow(["timestamp", "group", "key", "value"]) + state.clear() # force full dump on first packet per group + f.flush() - ts = pkt.get("ts", 0) - group = pkt.get("group", "") - data = pkt.get("data", {}) + # Toggled off: close file + if not enabled and was_enabled: + if f: + f.close() + f = None + writer = None - if group not in state: - state[group] = {} + was_enabled = enabled - prev = state[group] - changed = False + # Always drain the socket (even when disabled) to avoid backpressure + try: + raw = sock.recv_string(zmq.NOBLOCK) + except zmq.Again: + time.sleep(0.01) + continue + except zmq.ZMQError: + time.sleep(0.01) + continue - for key, value in data.items(): - # Convert to string for comparison so floats/ints/bools all diff correctly - str_val = str(value) - if key not in prev or prev[key] != str_val: - writer.writerow([f"{ts:.6f}", group, key, str_val]) - prev[key] = str_val - changed = True + if not enabled or writer is None: + continue - if changed: - f.flush() + try: + pkt = json.loads(raw) + except json.JSONDecodeError: + continue + + ts = pkt.get("ts", 0) + group = pkt.get("group", "") + data = pkt.get("data", {}) + + if group not in state: + state[group] = {} + + prev = state[group] + changed = False + + for key, value in data.items(): + str_val = str(value) + if key not in prev or prev[key] != str_val: + writer.writerow([f"{ts:.6f}", group, key, str_val]) + prev[key] = str_val + changed = True + + if changed: + f.flush() if __name__ == "__main__": diff --git a/selfdrive/clearpilot/ui_dump.py b/selfdrive/clearpilot/ui_dump.py new file mode 100644 index 0000000..ab67736 --- /dev/null +++ b/selfdrive/clearpilot/ui_dump.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""Query the UI process for its current widget tree. Usage: python3 -m selfdrive.clearpilot.ui_dump""" +import zmq +ctx = zmq.Context() +sock = ctx.socket(zmq.REQ) +sock.setsockopt(zmq.RCVTIMEO, 2000) +sock.connect("ipc:///tmp/clearpilot_ui_rpc") +sock.send_string("dump") +try: + print(sock.recv_string()) +except zmq.Again: + print("ERROR: UI not responding (timeout)") diff --git a/selfdrive/manager/manager.py b/selfdrive/manager/manager.py index 90bffb8..5c8ddce 100755 --- a/selfdrive/manager/manager.py +++ b/selfdrive/manager/manager.py @@ -79,6 +79,9 @@ def manager_init(frogpilot_functions) -> None: params = Params() params_storage = Params("/persist/params") params.clear_all(ParamKeyType.CLEAR_ON_MANAGER_START) + + # CLEARPILOT: always start with telemetry disabled + params.put("TelemetryEnabled", "0") params.clear_all(ParamKeyType.CLEAR_ON_ONROAD_TRANSITION) params.clear_all(ParamKeyType.CLEAR_ON_OFFROAD_TRANSITION) if is_release_branch(): @@ -155,6 +158,7 @@ def manager_init(frogpilot_functions) -> None: ("DisableVTSCSmoothing", "0"), ("DisengageVolume", "100"), ("DashcamDebug", "1"), + ("TelemetryEnabled", "0"), ("DragonPilotTune", "0"), ("DriverCamera", "0"), ("DynamicPathWidth", "0"), @@ -396,6 +400,14 @@ def manager_thread(frogpilot_functions) -> None: ignore += ["manage_athenad", "uploader"] if os.getenv("NOBOARD") is not None: ignore.append("pandad") + # CLEARPILOT: bench mode — disable real car processes, enable bench simulator + if os.getenv("BENCH_MODE") is not None: + ignore += ["pandad", "thermald", "controlsd", "radard", "plannerd", + "calibrationd", "torqued", "paramsd", "locationd", "sensord", + "ubloxd", "pigeond", "dmonitoringmodeld", "dmonitoringd", + "modeld", "soundd", "camerad", "loggerd", "micd", + "dashcamd"] + session_log.info("bench mode enabled") ignore += [x for x in os.getenv("BLOCK", "").split(",") if len(x) > 0] sm = messaging.SubMaster(['deviceState', 'carParams'], poll='deviceState') diff --git a/selfdrive/manager/process_config.py b/selfdrive/manager/process_config.py index 9a0b218..02e88f7 100755 --- a/selfdrive/manager/process_config.py +++ b/selfdrive/manager/process_config.py @@ -7,6 +7,7 @@ from openpilot.selfdrive.manager.process import PythonProcess, NativeProcess, Da WEBCAM = os.getenv("USE_WEBCAM") is not None +BENCH_MODE = os.getenv("BENCH_MODE") is not None def driverview(started: bool, params: Params, CP: car.CarParams) -> bool: return started or params.get_bool("IsDriverViewEnabled") @@ -110,6 +111,7 @@ procs = [ # ClearPilot processes NativeProcess("dashcamd", "selfdrive/clearpilot", ["./dashcamd"], dashcam_should_run), PythonProcess("telemetryd", "selfdrive.clearpilot.telemetryd", always_run), + PythonProcess("bench_onroad", "selfdrive.clearpilot.bench_onroad", always_run, enabled=BENCH_MODE), ] managed_processes = {p.name: p for p in procs} \ No newline at end of file diff --git a/selfdrive/ui/qt/home.cc b/selfdrive/ui/qt/home.cc index d1ecc2d..b3f957f 100755 --- a/selfdrive/ui/qt/home.cc +++ b/selfdrive/ui/qt/home.cc @@ -5,7 +5,9 @@ #include #include +#include "common/swaglog.h" #include "selfdrive/ui/qt/util.h" +#include "selfdrive/ui/qt/widgets/scrollview.h" // HomeWindow: the container for the offroad and onroad UIs @@ -25,8 +27,17 @@ HomeWindow::HomeWindow(QWidget* parent) : QWidget(parent) { slayout = new QStackedLayout(); main_layout->addLayout(slayout); - home = new OffroadHome(this); - QObject::connect(home, &OffroadHome::openSettings, this, &HomeWindow::openSettings); + home = new ClearPilotPanel(this); + QObject::connect(home, &ClearPilotPanel::openSettings, this, &HomeWindow::openSettings); + QObject::connect(home, &ClearPilotPanel::openStatus, this, &HomeWindow::openStatus); + QObject::connect(home, &ClearPilotPanel::closePanel, [=]() { + // Return to splash or onroad depending on state + if (uiState()->scene.started) { + slayout->setCurrentWidget(onroad); + } else { + slayout->setCurrentWidget(ready); + } + }); slayout->addWidget(home); onroad = new OnroadWindow(this); @@ -67,10 +78,10 @@ void HomeWindow::updateState(const UIState &s) { // CLEARPILOT: show splash screen when onroad but in park bool parked = s.scene.parked; if (parked && !was_parked_onroad) { - // just shifted into park — show splash + LOGW("CLP UI: park transition -> showing splash"); slayout->setCurrentWidget(ready); } else if (!parked && was_parked_onroad) { - // just shifted out of park — show onroad camera + LOGW("CLP UI: drive transition -> showing onroad"); slayout->setCurrentWidget(onroad); } was_parked_onroad = parked; @@ -90,11 +101,13 @@ void HomeWindow::updateState(const UIState &s) { void HomeWindow::offroadTransition(bool offroad) { sidebar->setVisible(false); if (offroad) { + LOGW("CLP UI: offroad transition -> showing splash"); was_parked_onroad = false; slayout->setCurrentWidget(ready); } else { // CLEARPILOT: start onroad in splash — updateState will switch to // camera view once the car shifts out of park + LOGW("CLP UI: onroad transition -> showing splash (parked)"); was_parked_onroad = true; slayout->setCurrentWidget(ready); } @@ -102,34 +115,26 @@ void HomeWindow::offroadTransition(bool offroad) { void HomeWindow::showDriverView(bool show, bool started) { if (show) { + LOGW("CLP UI: showDriverView(true) -> driver_view"); emit closeSettings(); slayout->setCurrentWidget(driver_view); - sidebar->setVisible(show == false); - } else { - if (started) { - slayout->setCurrentWidget(onroad); - sidebar->setVisible(params.getBool("Sidebar")); - } else { - slayout->setCurrentWidget(home); - sidebar->setVisible(show == false); - } + sidebar->setVisible(false); + } else if (!started) { + // Offroad, not started — show home menu + slayout->setCurrentWidget(home); + sidebar->setVisible(false); } + // CLEARPILOT: when started, don't touch slayout here — + // updateState handles park->splash and drive->onroad transitions } void HomeWindow::mousePressEvent(QMouseEvent* e) { - // CLEARPILOT todo - tap on main goes straight to settings - // Unless we click a debug widget. - - // CLEARPILOT - click ready shows home (no sidebar) - if (!onroad->isVisible() && ready->isVisible()) { + // CLEARPILOT: tap from any view goes to ClearPilotPanel + if (ready->isVisible() || onroad->isVisible()) { + LOGW("CLP UI: tap -> showing ClearPilotPanel"); sidebar->setVisible(false); slayout->setCurrentWidget(home); } - - // Todo: widgets - if (onroad->isVisible()) { - emit openSettings(); - } } void HomeWindow::mouseDoubleClickEvent(QMouseEvent* e) { @@ -137,59 +142,149 @@ void HomeWindow::mouseDoubleClickEvent(QMouseEvent* e) { // const SubMaster &sm = *(uiState()->sm); } -// CLEARPILOT: OffroadHome — clean grid launcher +// CLEARPILOT: ClearPilotPanel — settings-style sidebar menu -OffroadHome::OffroadHome(QWidget* parent) : QFrame(parent) { - QVBoxLayout* main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(80, 80, 80, 80); - main_layout->setSpacing(0); +static const char *clpSidebarBtnStyle = R"( + QPushButton { + color: grey; + border: none; + background: none; + font-size: 65px; + font-weight: 500; + } + QPushButton:checked { + color: white; + } + QPushButton:pressed { + color: #ADADAD; + } +)"; - // grid of launcher buttons - QGridLayout *grid = new QGridLayout(); - grid->setSpacing(40); +ClearPilotPanel::ClearPilotPanel(QWidget* parent) : QFrame(parent) { + // Sidebar + QWidget *sidebar_widget = new QWidget; + QVBoxLayout *sidebar_layout = new QVBoxLayout(sidebar_widget); + sidebar_layout->setContentsMargins(50, 50, 100, 50); - // Dashcam viewer button - QPushButton *dashcam_btn = new QPushButton("Dashcam"); - dashcam_btn->setFixedSize(400, 300); - dashcam_btn->setStyleSheet(R"( + // Close button + QPushButton *close_btn = new QPushButton("← Back"); + close_btn->setStyleSheet(R"( QPushButton { - background-color: #333333; color: white; - border-radius: 20px; - font-size: 48px; - font-weight: 600; + border-radius: 25px; + background: #292929; + font-size: 50px; + font-weight: 500; } QPushButton:pressed { - background-color: #555555; + color: #ADADAD; } )"); - grid->addWidget(dashcam_btn, 0, 0); + close_btn->setFixedSize(300, 125); + sidebar_layout->addSpacing(10); + sidebar_layout->addWidget(close_btn, 0, Qt::AlignRight); + QObject::connect(close_btn, &QPushButton::clicked, [=]() { emit closePanel(); }); - // Settings button - QPushButton *settings_btn = new QPushButton("Settings"); - settings_btn->setFixedSize(400, 300); - settings_btn->setStyleSheet(R"( + // Panel content area + panel_widget = new QStackedWidget(); + + // Define panels: sidebar label -> content widget + // Home panel: buttons for Status and System Settings + QWidget *home_panel = new QWidget(this); + QVBoxLayout *home_layout = new QVBoxLayout(home_panel); + home_layout->setContentsMargins(50, 25, 50, 25); + home_layout->setSpacing(20); + + QPushButton *status_btn = new QPushButton("Status"); + status_btn->setFixedHeight(120); + status_btn->setStyleSheet(R"( QPushButton { - background-color: #333333; + background-color: #393939; color: white; - border-radius: 20px; - font-size: 48px; - font-weight: 600; - } - QPushButton:pressed { - background-color: #555555; + border-radius: 15px; + font-size: 50px; + font-weight: 500; + text-align: left; + padding-left: 30px; } + QPushButton:pressed { background-color: #4a4a4a; } )"); - QObject::connect(settings_btn, &QPushButton::clicked, [=]() { emit openSettings(); }); - grid->addWidget(settings_btn, 0, 1); + QObject::connect(status_btn, &QPushButton::clicked, [=]() { emit openStatus(); }); + home_layout->addWidget(status_btn); - main_layout->addStretch(); - main_layout->addLayout(grid); - main_layout->addStretch(); + QPushButton *sysset_btn = new QPushButton("System Settings"); + sysset_btn->setFixedHeight(120); + sysset_btn->setStyleSheet(status_btn->styleSheet()); + QObject::connect(sysset_btn, &QPushButton::clicked, [=]() { emit openSettings(); }); + home_layout->addWidget(sysset_btn); + + home_layout->addStretch(); + + // Dashcam panel: placeholder + QWidget *dashcam_panel = new QWidget(this); + QVBoxLayout *dash_layout = new QVBoxLayout(dashcam_panel); + dash_layout->setContentsMargins(50, 25, 50, 25); + QLabel *dash_label = new QLabel("Dashcam viewer coming soon"); + dash_label->setStyleSheet("color: grey; font-size: 40px;"); + dash_label->setAlignment(Qt::AlignCenter); + dash_layout->addWidget(dash_label); + dash_layout->addStretch(); + + // Debug panel + ListWidget *debug_panel = new ListWidget(this); + debug_panel->setContentsMargins(50, 25, 50, 25); + + auto *telemetry_toggle = new ParamControl("TelemetryEnabled", "Telemetry Logging", + "Record telemetry data to CSV in the session log directory. " + "Captures only changed values for efficiency.", "", this); + debug_panel->addItem(telemetry_toggle); + + // Register panels with sidebar buttons + QList> panels = { + {"Home", home_panel}, + {"Dashcam", dashcam_panel}, + {"Debug", debug_panel}, + }; + + for (auto &[name, panel] : panels) { + QPushButton *btn = new QPushButton(name); + btn->setCheckable(true); + btn->setStyleSheet(clpSidebarBtnStyle); + btn->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + sidebar_layout->addWidget(btn, 0, Qt::AlignRight); + + ScrollView *panel_frame = new ScrollView(panel, this); + panel_widget->addWidget(panel_frame); + + QObject::connect(btn, &QPushButton::clicked, [=, w = panel_frame]() { + btn->setChecked(true); + panel_widget->setCurrentWidget(w); + }); + } + + // Select Home by default + if (auto *first_btn = sidebar_widget->findChild(QString(), Qt::FindDirectChildrenOnly)) { + // Skip close_btn, find first sidebar btn + } + panel_widget->setCurrentIndex(0); + + // Main layout: sidebar + panels + QHBoxLayout *main_layout = new QHBoxLayout(this); + sidebar_widget->setFixedWidth(500); + main_layout->addWidget(sidebar_widget); + main_layout->addWidget(panel_widget); setStyleSheet(R"( - OffroadHome { + * { + color: white; + font-size: 50px; + } + ClearPilotPanel { background-color: black; } + QStackedWidget, ScrollView { + background-color: #292929; + border-radius: 30px; + } )"); } diff --git a/selfdrive/ui/qt/home.h b/selfdrive/ui/qt/home.h index 673530f..2fb4e26 100755 --- a/selfdrive/ui/qt/home.h +++ b/selfdrive/ui/qt/home.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -16,14 +17,19 @@ #include "selfdrive/ui/qt/widgets/offroad_alerts.h" #include "selfdrive/ui/ui.h" -class OffroadHome : public QFrame { +class ClearPilotPanel : public QFrame { Q_OBJECT public: - explicit OffroadHome(QWidget* parent = 0); + explicit ClearPilotPanel(QWidget* parent = 0); signals: void openSettings(int index = 0, const QString ¶m = ""); + void openStatus(); + void closePanel(); + +private: + QStackedWidget *panel_widget; }; class HomeWindow : public QWidget { @@ -35,6 +41,7 @@ public: signals: void openSettings(int index = 0, const QString ¶m = ""); + void openStatus(); void closeSettings(); public slots: @@ -49,7 +56,7 @@ protected: private: Sidebar *sidebar; - OffroadHome *home; + ClearPilotPanel *home; OnroadWindow *onroad; DriverViewWindow *driver_view; QStackedLayout *slayout; diff --git a/selfdrive/ui/qt/onroad.cc b/selfdrive/ui/qt/onroad.cc index a9903bc..b2ece96 100755 --- a/selfdrive/ui/qt/onroad.cc +++ b/selfdrive/ui/qt/onroad.cc @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -402,6 +403,17 @@ void AnnotatedCameraWidget::drawHud(QPainter &p) { Hardware::set_display_power(true); } + // CLEARPILOT: blinking red circle when telemetry is recording + if (Params().get("TelemetryEnabled") == "1") { + // Blink: visible for 500ms, hidden for 500ms + int phase = (QDateTime::currentMSecsSinceEpoch() / 500) % 2; + if (phase == 0) { + p.setPen(Qt::NoPen); + p.setBrush(QColor(220, 30, 30)); + p.drawEllipse(width() - 150, 50, 100, 100); + } + } + // Header gradient QLinearGradient bg(0, UI_HEADER_HEIGHT - (UI_HEADER_HEIGHT / 2.5), 0, UI_HEADER_HEIGHT); bg.setColorAt(0, QColor::fromRgbF(0, 0, 0, 0.45)); diff --git a/selfdrive/ui/qt/spinner b/selfdrive/ui/qt/spinner index b635b3c..04e4b9f 100755 Binary files a/selfdrive/ui/qt/spinner and b/selfdrive/ui/qt/spinner differ diff --git a/selfdrive/ui/qt/window.cc b/selfdrive/ui/qt/window.cc index 6df6f92..d111502 100755 --- a/selfdrive/ui/qt/window.cc +++ b/selfdrive/ui/qt/window.cc @@ -1,6 +1,9 @@ #include "selfdrive/ui/qt/window.h" #include +#include + +#include #include "system/hardware/hw.h" @@ -11,6 +14,7 @@ MainWindow::MainWindow(QWidget *parent) : QWidget(parent) { homeWindow = new HomeWindow(this); main_layout->addWidget(homeWindow); QObject::connect(homeWindow, &HomeWindow::openSettings, this, &MainWindow::openSettings); + QObject::connect(homeWindow, &HomeWindow::openStatus, this, &MainWindow::openStatus); QObject::connect(homeWindow, &HomeWindow::closeSettings, this, &MainWindow::closeSettings); settingsWindow = new SettingsWindow(this); @@ -24,6 +28,11 @@ MainWindow::MainWindow(QWidget *parent) : QWidget(parent) { homeWindow->showDriverView(true); }); + // CLEARPILOT: Status window + statusWindow = new StatusWindow(this); + main_layout->addWidget(statusWindow); + QObject::connect(statusWindow, &StatusWindow::closeStatus, this, &MainWindow::closeSettings); + onboardingWindow = new OnboardingWindow(this); main_layout->addWidget(onboardingWindow); QObject::connect(onboardingWindow, &OnboardingWindow::onboardingDone, [=]() { @@ -35,11 +44,14 @@ MainWindow::MainWindow(QWidget *parent) : QWidget(parent) { QObject::connect(uiState(), &UIState::offroadTransition, [=](bool offroad) { if (!offroad) { - closeSettings(); + // CLEARPILOT: just switch to homeWindow, don't show sidebar + // HomeWindow::offroadTransition handles the internal view + main_layout->setCurrentWidget(homeWindow); } }); QObject::connect(device(), &Device::interactiveTimeout, [=]() { - if (main_layout->currentWidget() == settingsWindow) { + if (main_layout->currentWidget() == settingsWindow || + main_layout->currentWidget() == statusWindow) { closeSettings(); } }); @@ -63,6 +75,74 @@ MainWindow::MainWindow(QWidget *parent) : QWidget(parent) { } )"); setAttribute(Qt::WA_NoSystemBackground); + + // CLEARPILOT: UI introspection RPC server + zmq_ctx = zmq_ctx_new(); + zmq_sock = zmq_socket(zmq_ctx, ZMQ_REP); + zmq_bind(zmq_sock, "ipc:///tmp/clearpilot_ui_rpc"); + int fd; + size_t fd_sz = sizeof(fd); + zmq_getsockopt(zmq_sock, ZMQ_FD, &fd, &fd_sz); + rpc_notifier = new QSocketNotifier(fd, QSocketNotifier::Read, this); + connect(rpc_notifier, &QSocketNotifier::activated, this, &MainWindow::handleRpcRequest); +} + +void MainWindow::handleRpcRequest() { + int events = 0; + size_t events_sz = sizeof(events); + zmq_getsockopt(zmq_sock, ZMQ_EVENTS, &events, &events_sz); + if (!(events & ZMQ_POLLIN)) return; + + char buf[256]; + int rc = zmq_recv(zmq_sock, buf, sizeof(buf) - 1, ZMQ_DONTWAIT); + if (rc < 0) return; + buf[rc] = 0; + + QString response; + if (strcmp(buf, "dump") == 0) { + response = dumpWidgetTree(this); + } else { + response = "unknown command"; + } + + QByteArray resp = response.toUtf8(); + zmq_send(zmq_sock, resp.data(), resp.size(), 0); +} + +QString MainWindow::dumpWidgetTree(QWidget *w, int depth) { + QString result; + QString indent(depth * 2, ' '); + QString className = w->metaObject()->className(); + QString name = w->objectName().isEmpty() ? "(no name)" : w->objectName(); + bool visible = w->isVisible(); + QRect geo = w->geometry(); + + result += QString("%1%2 [%3] vis=%4 geo=%5,%6 %7x%8") + .arg(indent, className, name) + .arg(visible ? "Y" : "N") + .arg(geo.x()).arg(geo.y()).arg(geo.width()).arg(geo.height()); + + // Show stacked layout/widget current index + if (auto *sl = w->findChild(QString(), Qt::FindDirectChildrenOnly)) { + QWidget *cur = sl->currentWidget(); + QString curClass = cur ? cur->metaObject()->className() : "null"; + result += QString(" stack_cur=%1/%2(%3)").arg(sl->currentIndex()).arg(sl->count()).arg(curClass); + } + if (auto *sw = qobject_cast(w)) { + QWidget *cur = sw->currentWidget(); + QString curClass = cur ? cur->metaObject()->className() : "null"; + result += QString(" stack_cur=%1/%2(%3)").arg(sw->currentIndex()).arg(sw->count()).arg(curClass); + } + + result += "\n"; + + for (QObject *child : w->children()) { + QWidget *cw = qobject_cast(child); + if (cw && depth < 4) { + result += dumpWidgetTree(cw, depth + 1); + } + } + return result; } void MainWindow::openSettings(int index, const QString ¶m) { @@ -70,6 +150,10 @@ void MainWindow::openSettings(int index, const QString ¶m) { settingsWindow->setCurrentPanel(index, param); } +void MainWindow::openStatus() { + main_layout->setCurrentWidget(statusWindow); +} + void MainWindow::closeSettings() { main_layout->setCurrentWidget(homeWindow); @@ -96,3 +180,138 @@ bool MainWindow::eventFilter(QObject *obj, QEvent *event) { } return ignore; } + +// CLEARPILOT: Status window — live system stats, refreshed every second + +#include +#include +#include + +static QString readFile(const QString &path) { + QFile f(path); + if (f.open(QIODevice::ReadOnly)) return QString(f.readAll()).trimmed(); + return ""; +} + +static QString shellCmd(const QString &cmd) { + QProcess p; + p.start("bash", QStringList() << "-c" << cmd); + p.waitForFinished(1000); + return QString(p.readAllStandardOutput()).trimmed(); +} + +StatusWindow::StatusWindow(QWidget *parent) : QFrame(parent) { + QVBoxLayout *layout = new QVBoxLayout(this); + layout->setContentsMargins(50, 60, 50, 40); + layout->setSpacing(0); + + // Status rows + auto makeRow = [&](const QString &label) -> QLabel* { + QHBoxLayout *row = new QHBoxLayout(); + row->setContentsMargins(20, 0, 20, 0); + + QLabel *name = new QLabel(label); + name->setStyleSheet("color: grey; font-size: 38px;"); + name->setFixedWidth(350); + row->addWidget(name); + + QLabel *value = new QLabel("—"); + value->setStyleSheet("color: white; font-size: 38px;"); + row->addWidget(value); + row->addStretch(); + + layout->addLayout(row); + layout->addSpacing(12); + return value; + }; + + time_label = makeRow("Time"); + storage_label = makeRow("Storage"); + ram_label = makeRow("Memory"); + load_label = makeRow("Load"); + ip_label = makeRow("IP Address"); + wifi_label = makeRow("WiFi"); + vpn_label = makeRow("VPN"); + gps_label = makeRow("GPS"); + telemetry_label = makeRow("Telemetry"); + + layout->addStretch(); + + setStyleSheet("StatusWindow { background-color: black; }"); + + // Refresh every second + QTimer *timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, &StatusWindow::refresh); + timer->start(1000); + refresh(); +} + +void StatusWindow::refresh() { + // Time + time_label->setText(QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss")); + + // Storage + QString df = shellCmd("df -h /data | tail -1 | awk '{print $3 \" / \" $2 \" (\" $5 \" used)\"}'"); + storage_label->setText(df); + + // RAM + QString meminfo = readFile("/proc/meminfo"); + long total = 0, avail = 0; + for (const QString &line : meminfo.split('\n')) { + if (line.startsWith("MemTotal:")) total = line.split(QRegExp("\\s+"))[1].toLong(); + if (line.startsWith("MemAvailable:")) avail = line.split(QRegExp("\\s+"))[1].toLong(); + } + if (total > 0) { + long used = total - avail; + ram_label->setText(QString("%1 / %2 MB").arg(used / 1024).arg(total / 1024)); + } + + // Load + QString loadavg = readFile("/proc/loadavg"); + QStringList parts = loadavg.split(' '); + if (parts.size() >= 3) { + load_label->setText(QString("%1 %2 %3").arg(parts[0], parts[1], parts[2])); + } + + // IP + WiFi + QString ip = shellCmd("ip route get 1.1.1.1 2>/dev/null | head -1 | awk '{print $7}'"); + ip_label->setText(ip.isEmpty() ? "No connection" : ip); + + QString essid = shellCmd("iwconfig wlan0 2>/dev/null | grep -oP 'ESSID:\"\\K[^\"]*'"); + wifi_label->setText(essid.isEmpty() ? "Not connected" : essid); + + // VPN + QString tun = shellCmd("ip link show tun0 2>/dev/null | head -1"); + if (tun.contains("UP")) { + QString vpn_ip = shellCmd("ip addr show tun0 2>/dev/null | grep 'inet ' | awk '{print $2}'"); + vpn_label->setText("Connected (" + vpn_ip + ")"); + vpn_label->setStyleSheet("color: #17c44d; font-size: 38px;"); + } else { + vpn_label->setText("Not connected"); + vpn_label->setStyleSheet("color: #ff4444; font-size: 38px;"); + } + + // GPS + QString gps_raw = shellCmd("cat /data/params/d/LastGPSPosition 2>/dev/null"); + if (gps_raw.isEmpty()) { + gps_label->setText("No fix"); + gps_label->setStyleSheet("color: #ff4444; font-size: 38px;"); + } else { + gps_label->setText(gps_raw); + gps_label->setStyleSheet("color: white; font-size: 38px;"); + } + + // Telemetry + QString telem = shellCmd("cat /data/params/d/TelemetryEnabled 2>/dev/null"); + if (telem == "1") { + telemetry_label->setText("Enabled"); + telemetry_label->setStyleSheet("color: #17c44d; font-size: 38px;"); + } else { + telemetry_label->setText("Disabled"); + telemetry_label->setStyleSheet("color: grey; font-size: 38px;"); + } +} + +void StatusWindow::mousePressEvent(QMouseEvent *e) { + emit closeStatus(); +} diff --git a/selfdrive/ui/qt/window.h b/selfdrive/ui/qt/window.h index f1389c2..ea0b5e6 100755 --- a/selfdrive/ui/qt/window.h +++ b/selfdrive/ui/qt/window.h @@ -1,12 +1,42 @@ #pragma once #include +#include +#include +#include #include #include "selfdrive/ui/qt/home.h" #include "selfdrive/ui/qt/offroad/onboarding.h" #include "selfdrive/ui/qt/offroad/settings.h" +class StatusWindow : public QFrame { + Q_OBJECT + +public: + explicit StatusWindow(QWidget *parent = 0); + +protected: + void mousePressEvent(QMouseEvent *e) override; + +signals: + void closeStatus(); + +private slots: + void refresh(); + +private: + QLabel *storage_label; + QLabel *ram_label; + QLabel *load_label; + QLabel *ip_label; + QLabel *wifi_label; + QLabel *vpn_label; + QLabel *gps_label; + QLabel *time_label; + QLabel *telemetry_label; +}; + class MainWindow : public QWidget { Q_OBJECT @@ -16,13 +46,24 @@ public: private: bool eventFilter(QObject *obj, QEvent *event) override; void openSettings(int index = 0, const QString ¶m = ""); + void openStatus(); void closeSettings(); + QString dumpWidgetTree(QWidget *w, int depth = 0); QStackedLayout *main_layout; HomeWindow *homeWindow; SettingsWindow *settingsWindow; + StatusWindow *statusWindow; OnboardingWindow *onboardingWindow; + // CLEARPILOT: UI introspection RPC + void *zmq_ctx = nullptr; + void *zmq_sock = nullptr; + QSocketNotifier *rpc_notifier = nullptr; + // FrogPilot variables Params params; + +private slots: + void handleRpcRequest(); };