diff --git a/RELEASES.md b/RELEASES.md index b0c10178..9fdfa0b5 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,3 +1,9 @@ +Carrot2-v9 (2026-02-xx) +======================== +* CD210 model +* web carrot_man (http://ip:7000) +* fix speed based TF + Carrot2-v9 (2026-01-xx) ======================== * WMI model diff --git a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py index eacb2b7a..bb18cc0e 100644 --- a/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py +++ b/opendbc_repo/opendbc/car/hyundai/hyundaicanfd.py @@ -307,7 +307,7 @@ def create_acc_control_scc2(packer, CAN, enabled, accel_last, accel, stopping, g a_val, a_raw = 0, 0 else: a_raw = accel - a_val = np.clip(accel, accel_last - jn, accel_last + jn) + a_val = accel #np.clip(accel, accel_last - jn, accel_last + jn) values = copy.copy(CS.cruise_info) values.pop("COUNTER", None) @@ -440,6 +440,8 @@ def create_tcs_messages(packer, CAN, CS): if CS.tcs_info_373 is not None: values = copy.copy(CS.tcs_info_373) values["DriverBraking"] = 0 + values["NEW_SIGNAL_20"] = 0 + values["NEW_SIGNAL_11"] = 0 values["DriverBrakingLowSens"] = 0 #values["NEW_SIGNAL_1"] = 0 # accel과 관련.. 옆두부 꺼지는것과 관련? 확인필요 #values["ACC_REQ"] = 1 # 옆두부 꺼지는것과 관련? 확인필요.. 항상 켜지게함.. @@ -991,7 +993,7 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, values["ALERTS_3"] = 0 values["SOUNDS_3"] = 0 - if values["ALERTS_5"] in [1, 2, 4, 5]: + if values["ALERTS_5"] in [1, 2, 3, 4, 5]: values["ALERTS_5"] = 0 if values["ALERTS_5"] in [11] and CS.softHoldActive == 0: @@ -1002,13 +1004,13 @@ def create_ccnc_messages(CP, packer, CAN, frame, CC, CS, hud_control, values["LANELINE_CURVATURE"] = (min(abs(curvature), 15) + (-1 if curvature < 0 else 0)) if lat_active else 0 values["LANELINE_CURVATURE_DIRECTION"] = 1 if curvature < 0 and lat_active else 0 - lane_color = 2 if CS.out.leftLaneLine < 20 else 4 + lane_color = 4 if CS.out.leftLaneLine >= 20 or CS.out.leftBlindspot else 2 if hud_control.leftLaneDepart: values["LANELINE_LEFT"] = 4 if (frame // 50) % 2 == 0 else 1 else: values["LANELINE_LEFT"] = lane_color if hud_control.leftLaneVisible else 0 - lane_color = 2 if CS.out.rightLaneLine < 20 else 4 + lane_color = 4 if CS.out.rightLaneLine >= 20 or CS.out.rightBlindspot else 2 if hud_control.rightLaneDepart: values["LANELINE_RIGHT"] = 4 if (frame // 50) % 2 == 0 else 1 else: diff --git a/selfdrive/carrot/carrot_functions.py b/selfdrive/carrot/carrot_functions.py index f136744e..aac3bcb5 100644 --- a/selfdrive/carrot/carrot_functions.py +++ b/selfdrive/carrot/carrot_functions.py @@ -205,11 +205,20 @@ class CarrotPlanner: [self.tFollowGap1, self.tFollowGap2, self.tFollowGap3, self.tFollowGap4])) self.jerk_factor = float(np.interp(v_kph, bp, [1.0, 0.7, 0.5, 0.5])) + """ personality = int(np.clip(np.digitize(v_kph, bp[1:], right=False), 0, 3)) - if self.params_count % 100 == 0: self.params.put_int_nonblocking("LongitudinalPersonality", personality) self.personality = personality + """ + if personality == log.LongitudinalPersonality.moreRelaxed: + tf_target *= 2.0 + elif personality == log.LongitudinalPersonality.relaxed: + tf_target *= 1.6 + elif personality == log.LongitudinalPersonality.standard: + tf_target *= 1.3 + elif personality == log.LongitudinalPersonality.aggressive: + tf_target *= 1.0 else: tf_target = 1.0 @@ -233,6 +242,8 @@ class CarrotPlanner: s = float(np.clip(v_ego * CV.MS_TO_KPH / 100.0, 0.0, 1.0)) scale = (1.0 - reduce) + reduce * s tf_target *= scale + else: + return tf_target # ------------------------------------------------------------ # 2) Decel-hold only (no smoothing constants) diff --git a/selfdrive/carrot/carrot_man.py b/selfdrive/carrot/carrot_man.py index 28f80312..87869dc0 100644 --- a/selfdrive/carrot/carrot_man.py +++ b/selfdrive/carrot/carrot_man.py @@ -13,6 +13,10 @@ from datetime import datetime from ftplib import FTP from cereal import log +import urllib.request +import urllib.error +import ssl + import cereal.messaging as messaging from openpilot.common.realtime import Ratekeeper from openpilot.common.params import Params @@ -256,6 +260,55 @@ class CarrotMan: except Exception as e: return f"Error: {e}" + def register_my_ip(self): + try: + token = "12345678" + local_ip = self.get_local_ip() + version = self.params.get("Version") + github_id = self.params.get("GithubUsername") + port = 7000 + is_onroad = self.params.get_bool("IsOnroad") + ts = int(time.time()) + url = "https://shind0.synology.me/carrot/api_heartbeat.php" + timeout_s = 3.5 + payload = { + "github_id": github_id, + "token": token, + "local_ip": local_ip, + "port": int(port), + "version": version, + "is_onroad": bool(is_onroad), + "ts": int(time.time()), + } + #if extra: + # payload.update(extra) + + data = json.dumps(payload).encode("utf-8") + print(data) + req = urllib.request.Request( + url=url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + try: + ctx = ssl._create_unverified_context() + with urllib.request.urlopen(req, timeout=timeout_s, context=ctx) as resp: + body = resp.read().decode("utf-8", errors="replace") + # 서버가 {"ok":true} 같은 JSON을 주는 경우가 많음 + return (200 <= resp.status < 300), body + except urllib.error.HTTPError as e: + try: + body = e.read().decode("utf-8", errors="replace") + except Exception: + body = "" + return False, f"HTTPError {e.code}: {body}" + except Exception as e: + return False, f"Exception: {e}" + except Exception as e: + print(f"register_my_ip error: {e}") + # 브로드캐스트 메시지 전송 def broadcast_version_info(self): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -302,6 +355,9 @@ class CarrotMan: if carrot_speed_active_count > 0: self.carrot_speed_serv(carrot_speed, frame) + if frame % (20 * 30) == 0: + ok, msg = self.register_my_ip() + print(f"[heartbeat] ok: {ok}, msg: {msg}") if frame % 20 == 0 or remote_addr is not None: try: self.broadcast_ip = self.get_broadcast_address() if remote_addr is None else remote_addr[0] @@ -325,7 +381,7 @@ class CarrotMan: # sock.sendto(dat, address) if remote_addr is None: - print(f"Broadcasting: {self.broadcast_ip}:{msg}") + print(f"Broadcasting: {self.broadcast_ip}") #:{msg}") if not self.navd_active: #print("clear path_points: navd_active: ", self.navd_active) self.navi_points = [] diff --git a/selfdrive/carrot/carrot_server.py b/selfdrive/carrot/carrot_server.py new file mode 100644 index 00000000..d7fe1658 --- /dev/null +++ b/selfdrive/carrot/carrot_server.py @@ -0,0 +1,1025 @@ +#!/usr/bin/env python3 +# /data/openpilot/selfdrive/carrot/carrot_server.py +# +# aiohttp dashboard: +# - Home / Setting +# - loads carrot_settings.json +# - group buttons +# - bulk values load (fast on phone) +# - typed param set (ParamKeyType 기반) with fallback inference +# +# Run: +# python3 /data/openpilot/selfdrive/carrot/carrot_server.py --host 0.0.0.0 --port 7000 +# +# Open: +# http://:7000/ + +import argparse +import json +import os +import math +import time +from datetime import datetime +import asyncio +import glob +import subprocess +import traceback +from typing import Dict, Any, Tuple, Optional, List + +from aiohttp import web, ClientSession +from cereal import messaging +from opendbc.car import structs +import shlex + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +DEFAULT_SETTINGS_PATH = "/data/openpilot/selfdrive/carrot_settings.json" + +WEB_DIR = os.path.join(BASE_DIR, "web") + +UNIT_CYCLE = [1, 2, 5, 10, 50, 100] + +GearShifter = structs.CarState.GearShifter + +# ----------------------- +# Optional openpilot Params +# ----------------------- +HAS_PARAMS = False +Params = None +ParamKeyType = None + +try: + from openpilot.common.params import Params as _Params + Params = _Params + HAS_PARAMS = True +except Exception: + pass + +# ParamKeyType는 fork/버전에 따라 위치가 다를 수 있어서 방어적으로 처리 +if HAS_PARAMS: + try: + # 일부 환경에서는 openpilot.common.params에 ParamKeyType가 있을 수 있음 + from openpilot.common.params import ParamKeyType as _ParamKeyType + ParamKeyType = _ParamKeyType + except Exception: + ParamKeyType = None + + +# ===== request log middleware ===== +@web.middleware +async def log_mw(request, handler): + ua = request.headers.get("User-Agent", "") + ip = request.remote + t0 = time.time() + try: + resp = await handler(request) + return resp + finally: + #dt = (time.time() - t0) * 1000 + #print(f"[REQ] {ip} {request.method} {request.path_qs} {dt:.1f}ms UA={ua[:80]}") + pass + + +WEBRTCD_URL = "http://127.0.0.1:5001/stream" + + + +async def proxy_stream(request: web.Request) -> web.StreamResponse: + body = await request.read() + ct = request.headers.get("Content-Type", "application/json") + + sess: ClientSession = request.app["http"] + + try: + async with sess.post(WEBRTCD_URL, data=body, headers={"Content-Type": ct}) as resp: + resp_body = await resp.read() + # 그대로 전달 + out = web.Response(body=resp_body, status=resp.status) + rct = resp.headers.get("Content-Type") + if rct: + out.headers["Content-Type"] = rct + return out + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=502) + +async def on_startup(app: web.Application): + app["http"] = ClientSession() + +async def on_cleanup(app: web.Application): + sess = app.get("http") + if sess: + await sess.close() + +# ----------------------- +# Settings cache (mtime based) +# ----------------------- +_settings_cache = { + "path": DEFAULT_SETTINGS_PATH, + "mtime": 0, + "data": None, # full json + "groups": None, # {group: [param,...]} + "by_name": None, # {name: param} + "groups_list": None, # [{group, egroup, count}, ...] +} + +def _read_settings_file(path: str) -> Dict[str, Any]: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + +def _group_index(settings: Dict[str, Any]) -> Tuple[Dict[str, list], Dict[str, Dict[str, Any]], List[Dict[str, Any]]]: + groups: Dict[str, list] = {} + by_name: Dict[str, Dict[str, Any]] = {} + groups_list: List[Dict[str, Any]] = [] + + params = settings.get("params", []) + for p in params: + g = p.get("group", "UNGROUPED") + groups.setdefault(g, []).append(p) + n = p.get("name") + if n: + by_name[n] = p + + # group list with egroup guess + for g, items in groups.items(): + egroup = None + for it in items: + if it.get("egroup"): + egroup = it.get("egroup") + break + groups_list.append({"group": g, "egroup": egroup, "count": len(items)}) + + return groups, by_name, groups_list + +def _get_settings_cached() -> Tuple[Dict[str, Any], Dict[str, list], Dict[str, Dict[str, Any]], List[Dict[str, Any]]]: + path = _settings_cache["path"] + st = os.stat(path) + mtime = int(st.st_mtime) + if _settings_cache["data"] is None or _settings_cache["mtime"] != mtime: + data = _read_settings_file(path) + groups, by_name, groups_list = _group_index(data) + _settings_cache.update({ + "mtime": mtime, + "data": data, + "groups": groups, + "by_name": by_name, + "groups_list": groups_list, + }) + return _settings_cache["data"], _settings_cache["groups"], _settings_cache["by_name"], _settings_cache["groups_list"] + + +# ----------------------- +# Param helpers +# ----------------------- +_mem_store: Dict[str, str] = {} # if Params not available + +def _infer_type_from_setting(p: Optional[Dict[str, Any]]) -> str: + """ + Fallback when get_type/ParamKeyType unavailable. + returns one of: "bool","int","float","string","json","time" + """ + if not p: + return "string" + mn, mx, d = p.get("min"), p.get("max"), p.get("default") + + # bool heuristic: min=0 max=1 and default is 0/1 + if mn in (0, 0.0) and mx in (1, 1.0) and d in (0, 1, 0.0, 1.0): + return "bool" + + # int vs float + if isinstance(mn, int) and isinstance(mx, int) and isinstance(d, int): + return "int" + + if isinstance(mn, (int, float)) and isinstance(mx, (int, float)) and isinstance(d, (int, float)): + # if any float exists + if any(isinstance(x, float) for x in (mn, mx, d)): + return "float" + return "int" + + return "string" + +def _clamp_numeric(value: float, p: Optional[Dict[str, Any]]) -> float: + if not p: + return value + mn = p.get("min") + mx = p.get("max") + try: + if mn is not None: + value = max(value, float(mn)) + if mx is not None: + value = min(value, float(mx)) + except Exception: + pass + return value + +def _get_param_value(name: str, default: Any) -> Any: + if not HAS_PARAMS: + # mem store (string) fallback + s = _mem_store.get(name, None) + return default if s is None else s + + params = Params() + try: + t = params.get_type(name) + + if t == ParamKeyType.BOOL: + return bool(params.get_bool(name)) + + if t == ParamKeyType.INT: + return int(params.get_int(name)) + + if t == ParamKeyType.FLOAT: + return float(params.get_float(name)) + + # STRING / TIME / 기타는 raw string + v = params.get(name) + if v is None: + return default if default is not None else "" + if isinstance(v, (bytes, bytearray, memoryview)): + return v.decode("utf-8", errors="replace") + return str(v) + + except Exception: + pass + + # fallback: raw get + minimal decode + try: + v = params.get(name) + if v is None: + return default if default is not None else "" + return v.decode("utf-8", errors="replace") + except Exception: + return default if default is not None else "" + +def _put_typed(params: "Params", key: str, value: Any) -> None: + try: + t = params.get_type(key) + + # BOOL + if t == ParamKeyType.BOOL: + v = value in ("1", "true", "True", "on", "yes") if isinstance(value, str) else bool(value) + params.put_bool(key, v) + return + + # INT + if t == ParamKeyType.INT: + params.put_int(key, int(float(value))) + return + + # FLOAT + if t == ParamKeyType.FLOAT: + params.put_float(key, float(value)) + return + + # TIME (string ISO) + if t == ParamKeyType.TIME: + params.put(key, str(value)) + return + + # STRING + if t == ParamKeyType.STRING: + params.put(key, str(value)) + return + + # JSON + if t == ParamKeyType.JSON: + obj = json.loads(value) if isinstance(value, str) else value + params.put(key, obj) + + # BYTES 등은 일단 스킵 + raise RuntimeError(f"Unsupported ParamKeyType for {key}: {t}") + + except Exception: + # fall through to inference + pass + + +def _set_param_value(name: str, value: Any) -> None: + if not HAS_PARAMS: + _mem_store[name] = str(value) + return + params = Params() + _put_typed(params, name, value) + + +# ----------------------- +# Web handlers +# ----------------------- +async def handle_index(request: web.Request) -> web.Response: + return web.FileResponse(os.path.join(WEB_DIR, "index.html")) + +async def handle_appjs(request: web.Request) -> web.Response: + return web.FileResponse(os.path.join(WEB_DIR, "app.js")) + +async def handle_hudjs(request: web.Request) -> web.Response: + return web.FileResponse(os.path.join(WEB_DIR, "hud_card.js")) + +async def handle_hudcss(request: web.Request) -> web.Response: + return web.FileResponse(os.path.join(WEB_DIR, "hud_card.css")) + +async def api_settings(request: web.Request) -> web.Response: + path = _settings_cache["path"] + if not os.path.exists(path): + return web.json_response({"ok": False, "error": f"settings file not found: {path}"}, status=404) + + try: + data, groups, by_name, groups_list = _get_settings_cached() + # keep insertion order of groups + items_by_group = {g: items for g, items in groups.items()} + return web.json_response({ + "ok": True, + "path": path, + "apilot": data.get("apilot"), + "groups": groups_list, + "items_by_group": items_by_group, + "unit_cycle": UNIT_CYCLE, + "has_params": HAS_PARAMS, + "has_param_type": bool(ParamKeyType is not None and hasattr(Params(), "get_type")) if HAS_PARAMS else False, + }) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + +async def api_params_bulk(request: web.Request) -> web.Response: + names = request.query.get("names", "") + if not names: + return web.json_response({"ok": False, "error": "missing names"}, status=400) + + req_names = [n for n in names.split(",") if n] + try: + _, _, by_name, _ = _get_settings_cached() + except Exception: + by_name = {} + + values = {} + for n in req_names: + default = by_name.get(n, {}).get("default", 0) + values[n] = _get_param_value(n, default) + + return web.json_response({"ok": True, "values": values}) + +async def api_param_set(request: web.Request) -> web.Response: + try: + body = await request.json() + except Exception: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + name = body.get("name") + value = body.get("value") + + if not name: + return web.json_response({"ok": False, "error": "missing name"}, status=400) + + # clamp using settings if numeric + p = None + try: + _, _, by_name, _ = _get_settings_cached() + p = by_name.get(name) + except Exception: + pass + + # If value numeric -> clamp + try: + if p is not None and isinstance(p.get("min"), (int, float)) and isinstance(p.get("max"), (int, float)): + fv = float(value) + fv = _clamp_numeric(fv, p) + # keep int if setting looks int-ish + if isinstance(p.get("min"), int) and isinstance(p.get("max"), int) and isinstance(p.get("default"), int): + value = int(round(fv)) + else: + value = fv + except Exception: + # ignore clamp errors (string values etc.) + pass + + try: + _set_param_value(name, value) + return web.json_response({"ok": True, "name": name, "value": value, "has_params": HAS_PARAMS}) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + +SUPPORTED_CAR_GLOB = "/data/params/d/SupportedCars*" + +def _load_supported_cars() -> Tuple[List[str], Dict[str, List[str]]]: + files = sorted(glob.glob(SUPPORTED_CAR_GLOB)) + makers: Dict[str, set] = {} + + for fp in files: + try: + with open(fp, "r", encoding="utf-8", errors="ignore") as f: + for line in f: + line = line.strip() + if not line: + continue + parts = line.split(" ", 1) + if len(parts) < 2: + continue + maker, rest = parts[0], parts[1].strip() + full = f"{maker} {rest}" + makers.setdefault(maker, set()).add(full) + except Exception: + continue + + makers_sorted: Dict[str, List[str]] = {} + for mk, s in makers.items(): + makers_sorted[mk] = sorted(s) + + return [os.path.basename(x) for x in files], makers_sorted + + +async def api_cars(request: web.Request) -> web.Response: + try: + sources, makers = _load_supported_cars() + return web.json_response({ + "ok": True, + "sources": sources, + "makers": makers, + }) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + +async def api_reboot(request: web.Request) -> web.Response: + try: + # 보안 최소조치(권장): 로컬/사설 대역만 허용 등 + # ip = request.remote + # if not (ip.startswith("192.168.") or ip.startswith("10.") or ip in ("127.0.0.1", "::1")): + # return web.json_response({"ok": False, "error": "forbidden"}, status=403) + + # 즉시 반환하고 리붓은 백그라운드로 + subprocess.Popen(["sudo", "reboot"]) + return web.json_response({"ok": True}) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + +async def api_tools(request: web.Request) -> web.Response: + try: + body = await request.json() + except Exception: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + action = body.get("action") + if not action: + return web.json_response({"ok": False, "error": "missing action"}, status=400) + + # 최소 보안: 사설대역만 허용 (권장) + ip = request.remote or "" + if not (ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.16.") or ip.startswith("172.17.") or ip in ("127.0.0.1", "::1")): + return web.json_response({"ok": False, "error": "forbidden"}, status=403) + + def run(cmd: List[str], cwd: Optional[str] = None) -> Tuple[int, str]: + p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + out = (p.stdout or "") + (("\n" + p.stderr) if p.stderr else "") + return p.returncode, out.strip() + + try: + # repo 위치는 당신 환경에 맞게 조정 + REPO_DIR = "/data/openpilot" + + if action == "git_pull": + rc, out = run(["git", "pull"], cwd=REPO_DIR) + return web.json_response({"ok": rc == 0, "rc": rc, "out": out}) + + if action == "git_sync": + # 목적: 현재 체크아웃된 브랜치만 남기고 로컬 브랜치 모두 삭제 후 fetch/prune + rc1, out1 = run(["bash", "-lc", "git branch | grep -v '^\\*' | xargs -r git branch -D"], cwd=REPO_DIR) + if rc1 != 0: + return web.json_response({"ok": False, "rc": rc1, "out": out1}) + + rc2, out2 = run(["git", "fetch", "--all", "--prune"], cwd=REPO_DIR) + out = (out1 + "\n\n" + out2).strip() + return web.json_response({"ok": rc2 == 0, "rc": rc2, "out": out}) + + + if action == "git_reset": + mode = (body.get("mode") or "hard").strip() + target = (body.get("target") or "HEAD").strip() + if mode not in ("hard", "soft", "mixed"): + return web.json_response({"ok": False, "error": "bad mode"}, status=400) + rc, out = run(["git", "reset", f"--{mode}", target], cwd=REPO_DIR) + return web.json_response({"ok": rc == 0, "rc": rc, "out": out}) + + if action == "git_checkout": + branch = (body.get("branch") or "").strip() + if not branch: + return web.json_response({"ok": False, "error": "missing branch"}, status=400) + rc, out = run(["git", "checkout", branch], cwd=REPO_DIR) + return web.json_response({"ok": rc == 0, "rc": rc, "out": out}) + + if action == "git_branch_list": + rc, out = run( + ["git", "branch", "-a", "--format=%(refname:short)"], + cwd=REPO_DIR + ) + if rc != 0: + return web.json_response({"ok": False, "rc": rc, "out": out}) + + branches = [] + for line in out.splitlines(): + line = line.strip() + if not line: + continue + if line.startswith("remotes/"): + line = line.replace("remotes/", "", 1) + branches.append(line) + + # 중복 제거 + 정렬 + branches = sorted(set(branches)) + return web.json_response({"ok": True, "branches": branches}) + + + if action == "delete_all_videos": + # 경로는 환경 맞춰 조정 + # openpilot device: /data/media/0/videos + paths = ["/data/media/0/videos"] + deleted = 0 + for pth in paths: + if not os.path.isdir(pth): + continue + for fn in glob.glob(os.path.join(pth, "*")): + try: + os.remove(fn) + deleted += 1 + except Exception: + pass + return web.json_response({"ok": True, "out": f"deleted files: {deleted}"}) + + if action == "delete_all_logs": + # 경로는 환경 맞춰 조정 + # openpilot device: /data/media/0/realdata + paths = ["/data/media/0/realdata"] + deleted = 0 + for pth in paths: + if not os.path.isdir(pth): + continue + for fn in glob.glob(os.path.join(pth, "*")): + try: + os.remove(fn) + deleted += 1 + except Exception: + pass + return web.json_response({"ok": True, "out": f"deleted files: {deleted}"}) + + + if action == "send_tmux_log": + log_path = "/data/media/tmux.log" + + cmd = ( + "rm -f /data/media/tmux.log && " + "tmux capture-pane -pq -S-1000 > /data/media/tmux.log" + ) + + p = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=False + ) + + if p.returncode != 0: + return web.json_response({ + "ok": False, + "error": "tmux capture failed" + }) + + return web.json_response({ + "ok": True, + "out": "tmux log captured", + "file": "/download/tmux.log" + }) + + if action == "backup_settings": + if not HAS_PARAMS or ParamKeyType is None: + return web.json_response({"ok": False, "error": "Params/ParamKeyType not available"}, status=500) + + # 사설대역 제한 + ip = request.remote or "" + if not (ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.16.") or ip.startswith("172.17.") or ip in ("127.0.0.1", "::1")): + return web.json_response({"ok": False, "error": "forbidden"}, status=403) + + try: + values = _get_all_param_values_for_backup() + + os.makedirs(os.path.dirname(PARAMS_BACKUP_PATH), exist_ok=True) + with open(PARAMS_BACKUP_PATH, "w", encoding="utf-8") as f: + json.dump(values, f, ensure_ascii=False, indent=2) + + return web.json_response({"ok": True, "out": f"backup saved ({len(values)} keys)", "file": "/download/params_backup.json"}) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + + if action == "reboot": + subprocess.Popen(["sudo", "reboot"]) + return web.json_response({"ok": True, "out": "reboot requested"}) + + + if action == "shell_cmd": + cmd_str = (body.get("cmd") or "").strip() + if not cmd_str: + return web.json_response({"ok": False, "error": "missing cmd"}, status=400) + + # 화이트리스트: "첫 토큰" 기준 + git은 서브커맨드 제한 + try: + argv = shlex.split(cmd_str) + except Exception: + return web.json_response({"ok": False, "error": "bad cmd format"}, status=400) + + if not argv: + return web.json_response({"ok": False, "error": "empty cmd"}, status=400) + + allowed_top = {"git", "df", "free", "uptime"} + if argv[0] not in allowed_top: + return web.json_response({"ok": False, "error": f"not allowed: {argv[0]}"}, status=403) + + """ + # git subcommand 제한 + if argv[0] == "git": + if len(argv) < 2: + return web.json_response({"ok": False, "error": "git needs subcommand"}, status=400) + allowed_git = {"pull", "status", "branch", "log", "rev-parse"} + if argv[1] not in allowed_git: + return web.json_response({"ok": False, "error": f"git subcommand not allowed: {argv[1]}"}, status=403) + """ + # 실행 (shell=False 유지) + try: + p = subprocess.run( + argv, + cwd="/data/openpilot", # 필요시 조정 + capture_output=True, + text=True, + timeout=10 + ) + out = "" + if p.stdout: out += p.stdout + if p.stderr: out += ("\n" + p.stderr if out else p.stderr) + out = out.strip() or "(no output)" + return web.json_response({"ok": True, "out": out, "returncode": p.returncode}) + except subprocess.TimeoutExpired: + return web.json_response({"ok": False, "error": "timeout"}, status=504) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + + + + return web.json_response({"ok": False, "error": f"unknown action: {action}"}, status=400) + + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + +async def ws_state(request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse(heartbeat=20) + await ws.prepare(request) + + while True: + payload = { + "ts": time.time(), + "pid": os.getpid(), + "has_params": HAS_PARAMS, + "settings_path": _settings_cache["path"], + "settings_exists": os.path.exists(_settings_cache["path"]), + } + try: + await ws.send_str(json.dumps(payload)) + except Exception: + break + await asyncio.sleep(2.0) # 폰에서 부담 줄이려고 2초 + + try: + await ws.close() + except Exception: + pass + return ws + +async def handle_download_tmux(request: web.Request) -> web.Response: + path = "/data/media/tmux.log" + if not os.path.exists(path): + return web.json_response({"ok": False, "error": "file not found"}, status=404) + + return web.FileResponse( + path, + headers={ + "Content-Disposition": "attachment; filename=tmux.log" + } + ) + +async def ws_carstate(request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse(heartbeat=20) + await ws.prepare(request) + + sm = messaging.SubMaster(['carState', 'carControl', 'deviceState', 'longitudinalPlan', 'carrotMan', 'peripheralState']) + + # for gap/driving mode (same as your drawHud: Params reads) + params = Params() if HAS_PARAMS else None + last_toggle_t = 0.0 + show_volt = False + + try: + while True: + sm.update(0) # non-blocking + now = time.time() + + # toggle DISK/VOLT display every ~3s (like disp_timer) + if now - last_toggle_t > 3.2: + last_toggle_t = now + show_volt = not show_volt + + v_ego = None + v_cruise = None + gear = None + temp = None + gps_ok = None + + cpu_temp_c = None + mem_pct = None + disk_pct = None + volt_v = None + tf_gap = None + drive_mode_obj = None + temp_speed = None + + if sm.alive['carState'] and sm.alive['carControl']: + CS = sm['carState'] + CC = sm['carControl'] + CM = sm['carrotMan'] + lp = sm['longitudinalPlan'] + ps = sm['peripheralState'] + ds = sm['deviceState'] + v_ego = CS.vEgoCluster + v_cruise = CS.vCruiseCluster + gs = CS.gearShifter + step = CS.gearStep + if gs == GearShifter.unknown: + gear = "U" + elif gs == GearShifter.park: + gear = "P" + elif gs == GearShifter.drive: + gear = str(step) if step > 0 else "D" + elif gs == GearShifter.neutral: + gear = "N" + elif gs == GearShifter.reverse: + gear = "R" + elif gs == GearShifter.low: + gear = "L" + elif gs == GearShifter.sport: + gear = "S" + else: + gear = "X" + + apply_speed = CM.desiredSpeed + apply_source = CM.desiredSource + temp_speed = { "speed": apply_speed, "source": apply_source if apply_speed >= v_cruise else "", "is_decel": True if apply_speed < v_cruise else False} + drive_mode = lp.myDrivingMode + if drive_mode == 1: + drive_mode_obj = {"name": "연비", "kind": "eco"} + elif drive_mode == 2: + drive_mode_obj = {"name": "안전", "kind": "safe"} + elif drive_mode == 4: + drive_mode_obj = {"name": "고속", "kind": "sport"} + else: + drive_mode_obj = {"name": "일반", "kind": "normal"} + + + gps_ok = True + + # deviceState + ds = sm['deviceState'] + # cpuTempC can be list; use max + c = ds.cpuTempC + if c is not None: + if isinstance(c, (list, tuple)) and len(c) > 0: + cpu_temp_c = float(max(c)) + + mem_pct = ds.memoryUsagePercent + free_pct = ds.freeSpacePercent + if math.isfinite(free_pct): + disk_pct = 100.0 - free_pct + + volt_v = ps.voltage + + # gap/driving mode from Params (same as your C++) + tf_gap = int(params.get_int("LongitudinalPersonality") or 0) + 1 + + + payload = { + "ts": now, + "vEgo": v_ego, # m/s + "vSetKph": v_cruise, + "gear": gear, + "gpsOk": gps_ok, + + "cpuTempC": cpu_temp_c, + "memPct": mem_pct, + "diskPct": (volt_v if show_volt else disk_pct), + "diskLabel": ("VOLT" if show_volt else "DISK"), + + "tfGap": tf_gap, + "tfBars": tf_gap, + "driveMode": drive_mode_obj, + + # placeholders (fill later from your sources) + "tlight": "off", + "redDot": False, + "temp": temp_speed, + "speedLimitKph": None, + "speedLimitOver": False, + "apm": " ", + } + + await ws.send_str(json.dumps(payload)) + await asyncio.sleep(0.1) # 10Hz + except Exception: + #traceback.print_exc() + pass + + try: + await ws.close() + except Exception: + pass + return ws + + +PARAMS_BACKUP_PATH = "/data/media/params_backup.json" +def _get_all_param_values_for_backup() -> Dict[str, str]: + if not HAS_PARAMS or ParamKeyType is None: + raise RuntimeError("Params/ParamKeyType not available") + + params = Params() + out: Dict[str, str] = {} + + for k in params.all_keys(): + # key normalize + if isinstance(k, (bytes, bytearray, memoryview)): + try: + key = k.decode("utf-8") + except Exception: + continue + else: + key = str(k) + + # type + try: + t = params.get_type(key) + except Exception: + continue + + # skip heavy/unsupported + if t in (ParamKeyType.BYTES, ParamKeyType.JSON): + continue + + # default 없는 키 제외(당신 로직 유지) + try: + dv = params.get_default_value(key) + except Exception: + continue + if dv is None: + continue + + # read current + try: + v = params.get(key, block=False, return_default=False) + except Exception: + v = None + + if v is None: + v = dv + + # stringify for JSON file + if isinstance(v, (dict, list)): + out[key] = json.dumps(v, ensure_ascii=False) + else: + out[key] = str(v) + + return out + +def _restore_param_values_from_backup(values: Dict[str, Any]) -> Dict[str, Any]: + if not HAS_PARAMS or ParamKeyType is None: + raise RuntimeError("Params/ParamKeyType not available") + + params = Params() + ok_cnt = 0 + fail_cnt = 0 + fails = [] + + for key, value in values.items(): + try: + t = params.get_type(key) + + if t == ParamKeyType.BOOL: + v = value in ("1", "true", "True", "on", "yes") if isinstance(value, str) else bool(value) + params.put_bool(key, v) + + elif t == ParamKeyType.INT: + params.put_int(key, int(float(value))) + + elif t == ParamKeyType.FLOAT: + params.put_float(key, float(value)) + + elif t == ParamKeyType.TIME: + params.put(key, str(value)) + + elif t == ParamKeyType.STRING: + params.put(key, str(value)) + + # JSON/BYTES는 백업에서 제외했지만, 혹시 들어오면 skip + else: + continue + + ok_cnt += 1 + + except Exception as e: + fail_cnt += 1 + fails.append({"key": key, "err": str(e)}) + + return {"ok_cnt": ok_cnt, "fail_cnt": fail_cnt, "fails": fails[:30]} + +async def handle_download_params_backup(request: web.Request) -> web.Response: + path = PARAMS_BACKUP_PATH + if not os.path.exists(path): + return web.json_response({"ok": False, "error": "file not found"}, status=404) + + return web.FileResponse( + path, + headers={"Content-Disposition": "attachment; filename=params_backup.json"} + ) + +async def api_params_restore(request: web.Request) -> web.Response: + if not HAS_PARAMS or ParamKeyType is None: + return web.json_response({"ok": False, "error": "Params/ParamKeyType not available"}, status=500) + + # 최소 보안: 사설대역만 허용 (api_tools와 동일하게) + ip = request.remote or "" + if not (ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.16.") or ip.startswith("172.17.") or ip in ("127.0.0.1", "::1")): + return web.json_response({"ok": False, "error": "forbidden"}, status=403) + + try: + reader = await request.multipart() + part = await reader.next() + if part is None or part.name != "file": + return web.json_response({"ok": False, "error": "missing file field"}, status=400) + + data = await part.read(decode=False) + text = data.decode("utf-8", errors="replace") + j = json.loads(text) + + if not isinstance(j, dict): + return web.json_response({"ok": False, "error": "bad json format (must be object)"}, status=400) + + values = j + res = _restore_param_values_from_backup(values) + return web.json_response({"ok": True, "result": res}) + + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + + +def make_app() -> web.Application: + app = web.Application(middlewares=[log_mw]) + app.on_startup.append(on_startup) + app.on_cleanup.append(on_cleanup) + + # static-like routes + app.router.add_get("/", handle_index) + app.router.add_get("/app.js", handle_appjs) + app.router.add_get("/hud_card.js", handle_hudjs) + app.router.add_get("/hud_card.css", handle_hudcss) + + # api + app.router.add_get("/api/settings", api_settings) + app.router.add_get("/api/params_bulk", api_params_bulk) + app.router.add_post("/api/param_set", api_param_set) + app.router.add_get("/api/cars", api_cars) + app.router.add_post("/api/reboot", api_reboot) + app.router.add_post("/api/tools", api_tools) + app.router.add_post("/stream", proxy_stream) + # ws + app.router.add_get("/ws/state", ws_state) + + app.router.add_get("/ws/carstate", ws_carstate) + app.router.add_get("/download/tmux.log", handle_download_tmux) + + app.router.add_get("/download/params_backup.json", handle_download_params_backup) + app.router.add_post("/api/params_restore", api_params_restore) + + app.router.add_static("/", str(WEB_DIR), show_index=True) + return app + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", type=str, default="0.0.0.0") + parser.add_argument("--port", type=int, default=7000) + parser.add_argument("--settings", type=str, default=DEFAULT_SETTINGS_PATH, + help="path to carrot_settings.json") + args = parser.parse_args() + + _settings_cache["path"] = args.settings + + if not os.path.isdir(WEB_DIR): + raise RuntimeError(f"web dir not found: {WEB_DIR}") + if not os.path.exists(_settings_cache["path"]): + print(f"[WARN] settings file not found: {_settings_cache['path']}") + + import logging + logging.getLogger("aiohttp.access").setLevel(logging.WARNING) + web.run_app(make_app(), host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/selfdrive/carrot/web/app.js b/selfdrive/carrot/web/app.js new file mode 100644 index 00000000..3bf57dc0 --- /dev/null +++ b/selfdrive/carrot/web/app.js @@ -0,0 +1,1251 @@ +const DEBUG_UI = false; + +let SETTINGS = null; +let CURRENT_GROUP = null; +let LANG = "ko"; // "ko" | "en" + +let UNIT_CYCLE = [1, 2, 5, 10, 50, 100]; +const UNIT_INDEX = {}; // per name + +// Car select data +let CARS = null; // { makers: {Hyundai:[...], Genesis:[...]} } +let CURRENT_MAKER = null; + +const btnHome = document.getElementById("btnHome"); +const btnSetting = document.getElementById("btnSetting"); +const btnFleet = document.getElementById("btnFleet"); +const btnLang = document.getElementById("btnLang"); +const langLabel = document.getElementById("langLabel"); +const btnTools = document.getElementById("btnTools"); +const btnToolsBack = document.getElementById("btnToolsBack"); + +btnTools.onclick = () => showPage("tools"); +btnToolsBack.onclick = () => showPage("home"); + +const btnChangeCar = document.getElementById("btnChangeCar"); +const curCarLabelCar = document.getElementById("curCarLabelCar"); +const curCarLabelSetting = document.getElementById("curCarLabelSetting"); + +// Setting screens +const settingTitle = document.getElementById("settingTitle"); +const btnBackGroups = document.getElementById("btnBackGroups"); +const screenGroups = document.getElementById("settingScreenGroups"); +const screenItems = document.getElementById("settingScreenItems"); +const itemsTitle = document.getElementById("itemsTitle"); + +// Car screens +const carTitle = document.getElementById("carTitle"); +const btnBackCar = document.getElementById("btnBackCar"); +const carMeta = document.getElementById("carMeta"); +const carScreenMakers = document.getElementById("carScreenMakers"); +const carScreenModels = document.getElementById("carScreenModels"); +const makerList = document.getElementById("makerList"); +const modelList = document.getElementById("modelList"); +const modelTitle = document.getElementById("modelTitle"); +const modelMeta = document.getElementById("modelMeta"); + +btnHome.onclick = () => showPage("home", true); +btnSetting.onclick = () => showPage("setting", true); + +btnFleet.onclick = () => { + const ip = location.hostname; + const url = `http://${ip}:8082/`; + window.open(url, "_blank", "noopener"); +}; + +btnLang.onclick = () => toggleLang(); + +btnChangeCar.onclick = () => showPage("car", true); +btnBackCar.onclick = () => history.back(); +carTitle.onclick = () => history.back(); +modelTitle.onclick = () => showCarScreen("makers"); // ȭ鿡 ŸƲ makers + +// Branch select +let BRANCHES = []; +const branchTitle = document.getElementById("branchTitle"); +const btnBackBranch = document.getElementById("btnBackBranch"); +const branchMeta = document.getElementById("branchMeta"); +const branchList = document.getElementById("branchList"); + +// Quick Link +const quickLink = document.getElementById("quickLink"); + +btnBackBranch.onclick = () => history.back(); +branchTitle.onclick = () => history.back(); + +function showPage(page, pushHistory = false) { + document.getElementById("pageHome").style.display = (page === "home") ? "" : "none"; + document.getElementById("pageSetting").style.display = (page === "setting") ? "" : "none"; + document.getElementById("pageCar").style.display = (page === "car") ? "" : "none"; + document.getElementById("pageTools").style.display = (page === "tools") ? "" : "none"; + document.getElementById("pageBranch").style.display = (page === "branch") ? "" : "none"; + + btnHome.classList.toggle("active", page === "home"); + btnSetting.classList.toggle("active", page === "setting"); + + if (page === "home") { + loadCurrentCar().catch(() => {}); + updateQuickLink().catch(() => {}); + } + + if (page === "setting") { + showSettingScreen("groups", false); + if (!SETTINGS) loadSettings(); + } + + if (page === "car") { + showCarScreen("makers", false); + if (!CARS) loadCars(); + } + if (page === "tools") { + initToolsPage(); + } + + const state = + (page === "home") ? { page: "home" } : + (page === "setting") ? { page: "setting", screen: "groups", group: null } : + (page === "car") ? { page: "car", screen: "makers", maker: null } : + (page === "tools") ? { page: "tools" } : + (page === "branch") ? { page: "branch" } : + { page: "home" }; + + if (pushHistory) history.pushState(state, ""); + else history.replaceState(state, ""); +} + +/* ---------- screen transitions (Setting) ---------- */ +function showSettingScreen(which, pushHistory = false) { + const isGroups = (which === "groups"); + const showEl = isGroups ? screenGroups : screenItems; + const hideEl = isGroups ? screenItems : screenGroups; + + btnBackGroups.style.display = isGroups ? "none" : ""; + settingTitle.textContent = isGroups ? "Setting" : ("Setting - " + (CURRENT_GROUP || "")); + + showEl.style.display = ""; + requestAnimationFrame(() => showEl.classList.remove("hidden")); + + hideEl.classList.add("hidden"); + setTimeout(() => { hideEl.style.display = "none"; }, 170); + + if (pushHistory) { + history.pushState({ page: "setting", screen: which, group: CURRENT_GROUP || null }, ""); + } +} + +btnBackGroups.onclick = () => history.back(); +settingTitle.onclick = () => history.back(); +itemsTitle.onclick = () => history.back(); + +/* ---------- screen transitions (Car) ---------- */ +function showCarScreen(which, pushHistory = false) { + const isMakers = (which === "makers"); + const showEl = isMakers ? carScreenMakers : carScreenModels; + const hideEl = isMakers ? carScreenModels : carScreenMakers; + + showEl.style.display = ""; + requestAnimationFrame(() => showEl.classList.remove("hidden")); + + hideEl.classList.add("hidden"); + setTimeout(() => { hideEl.style.display = "none"; }, 170); + + if (pushHistory) { + history.pushState({ page: "car", screen: which, maker: CURRENT_MAKER || null }, ""); + } +} + +function toggleLang() { + LANG = (LANG === "ko") ? "en" : "ko"; + langLabel.textContent = (LANG === "ko") ? "KO" : "EN"; + if (SETTINGS) { + renderGroups(); + if (CURRENT_GROUP) renderItems(CURRENT_GROUP); + } +} + +function escapeHtml(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatItemText(p, keyKo, keyEn, fallback = "") { + if (LANG === "ko") return (p[keyKo] ?? fallback); + return (p[keyEn] ?? p[keyKo] ?? fallback); +} + +function clamp(v, mn, mx) { + if (Number.isFinite(mn) && v < mn) return mn; + if (Number.isFinite(mx) && v > mx) return mx; + return v; +} + +/* ---------- Params helpers ---------- */ +async function bulkGet(names) { + const q = encodeURIComponent(names.join(",")); + const r = await fetch("/api/params_bulk?names=" + q); + const j = await r.json(); + if (!j.ok) throw new Error(j.error || "bulk failed"); + return j.values || {}; +} + +async function setParam(name, value) { + const r = await fetch("/api/param_set", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, value }) + }); + const j = await r.json(); + if (!j.ok) throw new Error(j.error || "set failed"); + return true; +} + +/* ---------- Home: current car ---------- */ +async function loadCurrentCar() { + try { + const values = await bulkGet(["CarSelected3"]); + const v = values["CarSelected3"]; + curCarLabelCar.textContent = (v && String(v).trim().length) ? String(v) : "-"; + curCarLabelSetting.textContent = (v && String(v).trim().length) ? String(v) : "-"; + } catch (e) { + curCarLabelCar.textContent = "-"; + curCarLabelSetting.textContent = "-"; + } +} + +/* ---------- Cars: load list + maker/model UI ---------- */ +async function loadCars() { + carMeta.textContent = "loading..."; + makerList.innerHTML = ""; + modelList.innerHTML = ""; + CURRENT_MAKER = null; + showCarScreen("makers", false); + + const r = await fetch("/api/cars"); + const j = await r.json(); + if (!j.ok) { + carMeta.textContent = "Failed: " + (j.error || "unknown"); + return; + } + CARS = j; // { ok:true, sources:[...], makers:{Hyundai:[...],Genesis:[...]} ... } + + const sources = (j.sources || []).join(", "); + carMeta.textContent = sources ? ("sources: " + sources) : "ok"; + + renderMakers(); +} + +function renderMakers() { + makerList.innerHTML = ""; + const makers = CARS && CARS.makers ? Object.keys(CARS.makers) : []; + makers.sort((a, b) => a.localeCompare(b)); + + for (const mk of makers) { + const arr = CARS.makers[mk] || []; + const b = document.createElement("button"); + b.className = "btn groupBtn"; + b.textContent = `${mk} (${arr.length})`; + b.onclick = () => { + CURRENT_MAKER = mk; + renderModels(mk); + showCarScreen("models", true); + }; + makerList.appendChild(b); + } +} + +function renderModels(maker) { + modelList.innerHTML = ""; + const arr = (CARS.makers && CARS.makers[maker]) ? CARS.makers[maker] : []; + modelTitle.textContent = maker; + modelMeta.textContent = `${arr.length} models`; + + // ̴ϱ ư / ϰ: groupBtn + for (const fullLine of arr) { + // fullLine : "Hyundai Grandeur 2018-19" + // CarSelected3 maker ־ "Grandeur 2018-19" + const modelOnly = stripMaker(fullLine, maker); + + const b = document.createElement("button"); + b.className = "btn groupBtn"; + b.textContent = modelOnly; + b.onclick = () => onSelectCar(maker, modelOnly, fullLine); + modelList.appendChild(b); + } +} + +function stripMaker(fullLine, maker) { + // maker + 1 + const prefix = maker + " "; + if (fullLine.startsWith(prefix)) return fullLine.slice(prefix.length).trim(); + // Ȥ "Hyundai" ƴ ٸ ǥ fallback: ù ܾ + const sp = fullLine.split(" "); + if (sp.length >= 2) return sp.slice(1).join(" ").trim(); + return fullLine.trim(); +} + +async function onSelectCar(maker, modelOnly, fullLine) { + const ok = confirm(`Select this car?\n\n${maker} ${modelOnly}\n\nThis will set CarSelected3 = "${modelOnly}".`); + if (!ok) return; + + try { + await setParam("CarSelected3", fullLine); + } catch (e) { + alert("Failed to set CarSelected3: " + e.message); + return; + } + + // Home ǥ Ʈ + curCarLabelCar.textContent = modelOnly; + curCarLabelSetting.textContent = modelOnly; + + const rb = confirm("Reboot now?"); + if (!rb) { + alert("Selected. Reboot later to apply."); + return; + } + + try { + const r = await fetch("/api/reboot", { method: "POST" }); + const j = await r.json(); + if (!j.ok) throw new Error(j.error || "reboot failed"); + alert("Rebooting..."); + } catch (e) { + alert("Reboot failed: " + e.message); + } +} + +/* ---------- Settings ---------- */ +async function loadSettings() { + const meta = document.getElementById("settingsMeta"); + meta.textContent = "loading..."; + + const r = await fetch("/api/settings"); + const j = await r.json(); + if (!j.ok) { + meta.textContent = "Failed: " + (j.error || "unknown"); + return; + } + + SETTINGS = j; + UNIT_CYCLE = j.unit_cycle || UNIT_CYCLE; + + meta.textContent = `path: ${j.path} | has_params: ${j.has_params} | type_api: ${j.has_param_type}`; + + if (!DEBUG_UI) { + meta.style.display = "none"; + const gm = document.getElementById("groupMeta"); + if (gm) gm.style.display = "none"; + const cm = document.getElementById("carMeta"); + if (cm) cm.style.display = "none"; + } + + renderGroups(); + CURRENT_GROUP = null; + showSettingScreen("groups", false); +} + +function renderGroups() { + const box = document.getElementById("groupList"); + box.innerHTML = ""; + + (SETTINGS.groups || []).forEach(g => { + const label = (LANG === "ko") ? g.group : (g.egroup || g.group); + const b = document.createElement("button"); + b.className = "btn groupBtn"; + b.textContent = `${label} (${g.count})`; + b.onclick = () => selectGroup(g.group); + box.appendChild(b); + }); +} + +function selectGroup(group) { + CURRENT_GROUP = group; + showSettingScreen("items", true); + renderItems(group); +} + +async function renderItems(group) { + const meta = document.getElementById("groupMeta"); + const itemsBox = document.getElementById("items"); + itemsBox.innerHTML = ""; + + const list = SETTINGS.items_by_group[group] || []; + if (meta) meta.textContent = `${group} / ${list.length}`; + settingTitle.textContent = "Setting - " + group; + + const names = list.map(p => p.name); + let values = {}; + try { + values = await bulkGet(names); + } catch (e) { + values = {}; + } + + for (const p of list) { + const name = p.name; + if (!(name in UNIT_INDEX)) UNIT_INDEX[name] = 0; + + const title = formatItemText(p, "title", "etitle", ""); + const descr = formatItemText(p, "descr", "edescr", ""); + + const el = document.createElement("div"); + el.className = "setting"; + + const top = document.createElement("div"); + top.className = "settingTop"; + + const left = document.createElement("div"); + left.innerHTML = ` +
${escapeHtml(title)}
+
${escapeHtml(name)}
+
+ min=${p.min}, max=${p.max}, default=${p.default} +
+ `; + + const ctrl = document.createElement("div"); + ctrl.className = "ctrl"; + + const btnMinus = document.createElement("button"); + btnMinus.className = "smallBtn"; + btnMinus.textContent = "-"; + + const val = document.createElement("div"); + val.className = "pill val"; + + const btnPlus = document.createElement("button"); + btnPlus.className = "smallBtn"; + btnPlus.textContent = "+"; + + const unitBtn = document.createElement("button"); + unitBtn.className = "smallBtn"; + unitBtn.textContent = "unit: " + UNIT_CYCLE[UNIT_INDEX[name]]; + + unitBtn.onclick = () => { + UNIT_INDEX[name] = (UNIT_INDEX[name] + 1) % UNIT_CYCLE.length; + unitBtn.textContent = "unit: " + UNIT_CYCLE[UNIT_INDEX[name]]; + }; + + ctrl.appendChild(btnMinus); + ctrl.appendChild(val); + ctrl.appendChild(btnPlus); + ctrl.appendChild(unitBtn); + + top.appendChild(left); + top.appendChild(ctrl); + + const d = document.createElement("div"); + d.className = "descr"; + d.textContent = descr; + + el.appendChild(top); + el.appendChild(d); + itemsBox.appendChild(el); + + // initial value + const cur = (name in values) ? values[name] : p.default; + val.textContent = String(cur); + + async function applyDelta(sign) { + const step = UNIT_CYCLE[UNIT_INDEX[name]]; + let curv = Number(val.textContent); + if (Number.isNaN(curv)) curv = Number(p.default); + + let next = curv + sign * step; + next = clamp(next, Number(p.min), Number(p.max)); + + if (Number.isInteger(p.min) && Number.isInteger(p.max) && Number.isInteger(step)) { + next = Math.round(next); + } + + try { + await setParam(name, next); + val.textContent = String(next); + } catch (e) { + alert("set failed: " + e.message); + } + } + + btnMinus.onclick = () => applyDelta(-1); + btnPlus.onclick = () => applyDelta(+1); + } +} + +/* ---------- Home WS state ---------- */ +function wsConnect() { + const wsProto = (location.protocol === "https:") ? "wss" : "ws"; + const ws = new WebSocket(wsProto + "://" + location.host + "/ws/state"); + const box = document.getElementById("stateBox"); + ws.onopen = () => box.textContent = "connected"; + ws.onmessage = (ev) => { + try { + const j = JSON.parse(ev.data); + box.textContent = JSON.stringify(j, null, 2); + } catch (e) { + box.textContent = ev.data; + } + }; + ws.onclose = () => { + box.textContent = "disconnected (reconnecting...)"; + setTimeout(wsConnect, 1000); + }; +} +wsConnect(); + +/* ---------- Back key / history ---------- */ +history.replaceState({ page: "home" }, ""); + +window.addEventListener("popstate", async (ev) => { + const st = ev.state || { page: "home" }; + + if (st.page === "home") { + CURRENT_GROUP = null; + CURRENT_MAKER = null; + showPage("home", false); + return; + } + + if (st.page === "setting") { + showPage("setting", false); + const screen = st.screen || "groups"; + CURRENT_GROUP = st.group || null; + + if (screen === "items" && CURRENT_GROUP) { + showSettingScreen("items", false); + renderItems(CURRENT_GROUP); + } else { + showSettingScreen("groups", false); + } + return; + } + + if (st.page === "car") { + showPage("car", false); + if (!CARS) await loadCars(); + + const screen = st.screen || "makers"; + CURRENT_MAKER = st.maker || null; + + if (screen === "models" && CURRENT_MAKER) { + renderModels(CURRENT_MAKER); + showCarScreen("models", false); + } else { + showCarScreen("makers", false); + } + return; + } + + if (st.page == "tools") { + showPage("tools", false); + return; + } + + if (st.page === "branch") { + showPage("branch", false); + // 귣ġ ٽ ε + if (!BRANCHES || !BRANCHES.length) { + loadBranchesAndShow().catch(() => {}); + } + return; + } + +}); + +function toolsOutSet(s) { + const out = document.getElementById("toolsOut"); + if (out) out.textContent = String(s); +} + +function toolsMetaSet(s) { + const meta = document.getElementById("toolsMeta"); + if (meta) meta.textContent = String(s); +} + +async function postJson(url, bodyObj) { + const r = await fetch(url, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(bodyObj || {}) + }); + const j = await r.json().catch(() => ({})); + if (!r.ok || !j.ok) throw new Error(j.error || ("HTTP " + r.status)); + return j; +} + +async function runTool(action, payload) { + toolsMetaSet("running: " + action); + toolsOutSet("..."); + + // { ok:true, out:"...", rc:0 } ̷ · ָ + const j = await postJson("/api/tools", { action, ...(payload || {}) }); + + toolsMetaSet("done: " + action); + if (j.out != null) { + toolsOutSet(j.out); + } else { + toolsOutSet(JSON.stringify(j, null, 2)); + } + + return j; +} + +function confirmText(msg, placeholder = "") { + const v = prompt(msg, placeholder); + if (v === null) return null; + return String(v).trim(); +} + + +function initToolsPage() { + // ư ε ( ) + const bindOnce = (id, fn) => { + const el = document.getElementById(id); + if (!el || el.dataset.bound === "1") return; + el.dataset.bound = "1"; + el.onclick = fn; + }; + + toolsMetaSet("ready"); + + bindOnce("btnGitPull", async () => { + try { + await runTool("git_pull"); + } catch (e) { + toolsMetaSet("error"); + toolsOutSet("git pull failed: " + e.message); + alert(e.message); + } + }); + + bindOnce("btnGitSync", async () => { + if (!confirm("Run git sync?")) return; + try { + await runTool("git_sync"); + } catch (e) { + toolsMetaSet("error"); + toolsOutSet("git sync failed: " + e.message); + alert(e.message); + } + }); + + bindOnce("btnGitReset", async () => { + if (!confirm("Run git reset? (DANGEROUS)")) return; + + // ɼ ʿϸ prompt ޱ + // : hard / soft, target + const mode = confirmText("reset mode? (hard/soft/mixed)", "hard"); + if (!mode) return; + + const target = confirmText("reset target? (e.g. HEAD~1 or origin/master)", "HEAD"); + if (!target) return; + + try { + await runTool("git_reset", { mode, target }); + } catch (e) { + toolsMetaSet("error"); + toolsOutSet("git reset failed: " + e.message); + alert(e.message); + } + }); + bindOnce("btnGitBranch", async () => { + await loadBranchesAndShow(); + }); + + + bindOnce("btnSendTmuxLog", async () => { + try { + const j = await runTool("send_tmux_log"); + + if (j.file) { + window.location.href = j.file; + } + } catch (e) { + toolsMetaSet("error"); + toolsOutSet("send tmux log failed: " + e.message); + alert(e.message); + } + }); + + bindOnce("btnDeleteVideos", async () => { + if (!confirm("Delete ALL videos? (DANGEROUS)")) return; + try { + await runTool("delete_all_videos"); + } catch (e) { + toolsMetaSet("error"); + toolsOutSet("delete videos failed: " + e.message); + alert(e.message); + } + }); + + bindOnce("btnDeleteLogs", async () => { + if (!confirm("Delete ALL logs? (DANGEROUS)")) return; + try { + await runTool("delete_all_logs"); + } catch (e) { + toolsMetaSet("error"); + toolsOutSet("delete logs failed: " + e.message); + alert(e.message); + } + }); + + bindOnce("btnBackupSettings", async () => { + try { + const j = await runTool("backup_settings"); + if (j.file) window.location.href = j.file; // ٿε + } catch (e) { + toolsMetaSet("error"); + toolsOutSet("backup failed: " + e.message); + alert(e.message); + } + }); + + bindOnce("btnRestoreSettings", async () => { + const inp = document.getElementById("restoreFile"); + if (!inp || !inp.files || !inp.files[0]) { + alert("Select a backup json file first."); + return; + } + + if (!confirm("Restore settings from file?\n\nThis will overwrite many Params values.")) return; + + try { + toolsMetaSet("uploading..."); + toolsOutSet("..."); + + const fd = new FormData(); + fd.append("file", inp.files[0]); + + const r = await fetch("/api/params_restore", { method: "POST", body: fd }); + const j = await r.json().catch(() => ({})); + if (!r.ok || !j.ok) throw new Error(j.error || ("HTTP " + r.status)); + + toolsMetaSet("restore done"); + toolsOutSet(JSON.stringify(j.result, null, 2)); + + if (confirm("Restore done.\nReboot now?")) { + await runTool("reboot"); + toolsMetaSet("rebooting..."); + toolsOutSet("reboot requested"); + } + } catch (e) { + toolsMetaSet("error"); + toolsOutSet("restore failed: " + e.message); + alert(e.message); + } + }); + + bindOnce("btnReboot", async () => { + if (!confirm("Reboot now?")) return; + try { + // װ ̹ /api/reboot Ÿ ̰ɷ ٲ㵵 : + // await postJson("/api/reboot", {}); + await runTool("reboot"); + toolsMetaSet("rebooting..."); + toolsOutSet("reboot requested"); + } catch (e) { + toolsMetaSet("error"); + toolsOutSet("reboot failed: " + e.message); + alert(e.message); + } + }); + + bindOnce("btnSysCmdRun", async () => { + const inp = document.getElementById("sysCmdInput"); + const cmd = (inp?.value || "").trim(); + if (!cmd) return; + + toolsOutSet("running: " + cmd + "\n"); + + try { + const j = await runTool("shell_cmd", { cmd }); + // j.out stdout/stderr ģ + toolsOutSet(j.out || "(no output)"); + } catch (e) { + toolsOutSet("error: " + e.message); + alert(e.message); + } + }); +} + +async function loadBranchesAndShow() { + showPage("branch", true); + if (!branchMeta || !branchList) { + alert("Branch DOM missing (branchMeta / branchList)"); + return; + } + branchMeta.textContent = "loading..."; + branchList.innerHTML = ""; + BRANCHES = []; + + try { + const j = await runTool("git_branch_list"); + BRANCHES = j.branches || []; + branchMeta.textContent = `${BRANCHES.length} branches`; + + renderBranchList(); + } catch (e) { + branchMeta.textContent = "Failed: " + e.message; + } +} + +function renderBranchList() { + branchList.innerHTML = ""; + + for (const br of BRANCHES) { + const b = document.createElement("button"); + b.className = "btn groupBtn"; + b.textContent = br; + b.onclick = () => onSelectBranch(br); + branchList.appendChild(b); + } +} + +async function onSelectBranch(branch) { + if (!confirm(`Checkout branch?\n\n${branch}\n\nContinue?`)) return; + + try { + await runTool("git_checkout", { branch }); + alert("Branch changed."); + } catch (e) { + alert("Checkout failed: " + e.message); + return; + } + + const rb = confirm("Reboot now?"); + if (!rb) return; + + try { + await runTool("reboot"); // Ǵ /api/reboot + alert("Rebooting..."); + } catch (e) { + alert("Reboot failed: " + e.message); + } +} + + + + +// ===== WebRTC (auto) ===== +let RTC_PC = null; +let RTC_RETRY_T = null; + +function rtcStatusSet(s) { + const el = document.getElementById("rtcStatus"); + if (el) el.textContent = String(s); +} + +function rtcCancelRetry() { + if (RTC_RETRY_T) { + clearTimeout(RTC_RETRY_T); + RTC_RETRY_T = null; + } +} + +async function rtcDisconnect() { + rtcCancelRetry(); // ߰ + try { if (RTC_PC) RTC_PC.close(); } catch {} + RTC_PC = null; + const v = document.getElementById("rtcVideo"); + if (v) { v.srcObject = null; v.style.display = "none"; } + const rtcCard = document.getElementById("rtcCard"); + rtcCard.style.display = "none"; + + // HUD auto dock handled by hudAutoDock() + //await carWsDisconnect(); +} + +function rtcScheduleRetry(ms = 2000) { + rtcCancelRetry(); // ׻ ´ + RTC_RETRY_T = setTimeout(async () => { + RTC_RETRY_T = null; + await rtcConnectOnce().catch(() => {}); + }, ms); +} + +async function waitIceComplete(pc, timeoutMs = 8000) { + if (pc.iceGatheringState === "complete") return; + await new Promise((resolve) => { + const t = setTimeout(resolve, timeoutMs); + function onchg() { + if (pc.iceGatheringState === "complete") { + pc.removeEventListener("icegatheringstatechange", onchg); + clearTimeout(t); + resolve(); + } + } + pc.addEventListener("icegatheringstatechange", onchg); + }); +} + +let RTC_WAIT_TRACK_T = null; + +function rtcArmTrackTimeout(ms = 5000) { + if (RTC_WAIT_TRACK_T) clearTimeout(RTC_WAIT_TRACK_T); + RTC_WAIT_TRACK_T = setTimeout(async () => { + RTC_WAIT_TRACK_T = null; + rtcStatusSet("no track, retry..."); + await rtcDisconnect(); + rtcScheduleRetry(1000); + }, ms); +} + +function rtcDisarmTrackTimeout() { + if (RTC_WAIT_TRACK_T) { + clearTimeout(RTC_WAIT_TRACK_T); + RTC_WAIT_TRACK_T = null; + } +} + +async function rtcConnectOnce() { + if (RTC_PC && (RTC_PC.connectionState === "connected" || RTC_PC.connectionState === "connecting")) return; + + try { + await rtcDisconnect(); + rtcStatusSet("connecting..."); + + const pc = new RTCPeerConnection({ + iceServers: [], + sdpSemantics: "unified-plan", + iceCandidatePoolSize: 1 + }); + RTC_PC = pc; + + const v = document.getElementById("rtcVideo"); + if (v) { v.muted = true; v.playsInline = true; } + + const dbg = (...a) => console.log("[RTC]", ...a); + + pc.addTransceiver("video", { direction: "recvonly" }); + + pc.ontrack = async (ev) => { + const rtcCard = document.getElementById("rtcCard"); + const v = document.getElementById("rtcVideo"); + if (!v) return; + + let stream = ev.streams && ev.streams[0]; + if (!stream) { + stream = new MediaStream([ev.track]); + } + + v.srcObject = stream; + v.style.display = "block"; + rtcCard.style.display = "block"; + try { await v.play(); } catch(e) { console.log("[RTC] play() failed", e); } + rtcStatusSet("track: " + ev.track.kind); + rtcDisarmTrackTimeout(); + + hudAutoDock(); + carWsConnect(); + }; + + pc.onconnectionstatechange = () => { + const st = pc.connectionState; + dbg("connectionState:", st); + rtcStatusSet("conn: " + st); + if (st === "failed" || st === "disconnected" || st === "closed") { + rtcDisconnect(); + rtcScheduleRetry(2000); + } + }; + + pc.oniceconnectionstatechange = () => { + const st = pc.iceConnectionState; + dbg("iceConnectionState:", st); + rtcStatusSet("ice: " + st); + if (st === "failed" || st === "disconnected" || st === "closed") { + rtcDisconnect(); + rtcScheduleRetry(2000); + } + }; + + // offer + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + await waitIceComplete(pc, 8000); + + const url = "/stream"; + const body = { + sdp: pc.localDescription.sdp, + cameras: ["road"], + bridge_services_in: [], + bridge_services_out: [], + }; + + const r = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!r.ok) { + const t = await r.text().catch(() => ""); + throw new Error("stream http " + r.status + " " + t); + } + + const ans = await r.json(); + if (!ans || !ans.sdp) throw new Error("bad answer"); + + await pc.setRemoteDescription({ type: ans.type || "answer", sdp: ans.sdp }); + + rtcStatusSet("connected (waiting track...)"); + rtcArmTrackTimeout(6000); + + } catch (e) { + rtcStatusSet("error: " + e.message); + await rtcDisconnect(); // + rtcScheduleRetry(2000); // ⼭ õ + throw e; + } +} + +async function waitServerReady(timeoutMs = 8000) { + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + try { + // ִ Ȯ ( API) + const r = await fetch("/api/settings", { cache: "no-store" }); + if (r.ok) return true; + } catch {} + await new Promise(res => setTimeout(res, 300)); + } + return false; +} + +function rtcInitAuto() { + (async () => { + rtcStatusSet("waiting server..."); + await waitServerReady(8000); // ص + await rtcConnectOnce().catch(() => {}); + })(); + + document.addEventListener("visibilitychange", () => { + if (!document.hidden) rtcConnectOnce().catch(() => {}); + }); +} +const btnRtcFs = document.getElementById("btnRtcFs"); +const rtcVideoEl = document.getElementById("rtcVideo"); +const rtcWrap = document.getElementById("rtcWrap"); + +// ó ȣǵ: ư Ŭ / ̺Ʈ +async function rtcToggleFullscreen() { + const target = rtcWrap || rtcVideoEl; + + // ̹ Ǯũ̸ + const fsEl = document.fullscreenElement || document.webkitFullscreenElement; + if (fsEl) { + if (document.exitFullscreen) await document.exitFullscreen().catch(()=>{}); + else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); + return; + } + + // 1) ǥ Fullscreen API (κ ũ/ȵ/ũž) + if (target.requestFullscreen) { + await target.requestFullscreen().catch(()=>{}); + return; + } + + // 2) Safari (Ϻδ webkitRequestFullscreen) + if (target.webkitRequestFullscreen) { + target.webkitRequestFullscreen(); + return; + } + + // 3) iOS Safari: video üȭ ( ) + // (: iOS inline /å ) + if (target.webkitEnterFullscreen) { + target.webkitEnterFullscreen(); + return; + } + + alert("Fullscreen not supported on this browser."); +} + +// ư +if (btnRtcFs) btnRtcFs.onclick = rtcToggleFullscreen; + +// (ϸ) +if (rtcVideoEl) { + rtcVideoEl.style.cursor = "pointer"; + rtcVideoEl.addEventListener("click", rtcToggleFullscreen); +} + +let CAR_WS = null; +let CAR_WS_RETRY_T = null; + +function carWsScheduleReconnect(ms = 1000) { + if (CAR_WS_RETRY_T) return; + CAR_WS_RETRY_T = setTimeout(() => { + CAR_WS_RETRY_T = null; + carWsConnect(); + }, ms); +} + +// ===== Driving HUD docking (card <-> WebRTC overlay) ===== +function hudDock(mode /* "card"|"top"|"bl" */) { + const hudRoot = document.getElementById("hudRoot"); + const card = document.getElementById("driveHudCard"); + const host = document.getElementById("hudOverlayHost"); + if (!hudRoot || !card || !host) return; + + host.classList.remove("dock_top","dock_bl"); + host.style.display = "none"; + + if (mode === "top" || mode === "bl") { + host.classList.add(mode === "bl" ? "dock_bl" : "dock_top"); + host.style.display = ""; + if (hudRoot.parentElement !== host) host.appendChild(hudRoot); + card.style.display = "none"; + } else { + if (hudRoot.parentElement !== card) card.appendChild(hudRoot); + card.style.display = ""; + } +} + +function hudAutoDock() { + const rtcVideo = document.getElementById("rtcVideo"); + const rtcCard = document.getElementById("rtcCard"); + const host = document.getElementById("hudOverlayHost"); + if (!rtcVideo || !rtcCard || !host) return; + + const videoVisible = rtcCard.style.display !== "none" && rtcVideo.style.display !== "none"; + if (!videoVisible) { hudDock("card"); return; } + + const fs = document.fullscreenElement === rtcVideo; + const landscape = window.innerWidth >= window.innerHeight; + + if (fs && landscape) hudDock("bl"); + else hudDock("top"); +} + +function drivingHudUpdateFromCarPayload(j) { + if (!window.DrivingHud) { + console.log("[HUD] update none"); + return; + } + + const vEgoKph = (typeof j.vEgo === "number" && isFinite(j.vEgo)) ? j.vEgo * 3.6 : null; + + const payload = { + cpuTempC: j.cpuTempC, + memPct: j.memPct, + diskPct: j.diskPct, + diskLabel: j.diskLabel, + vEgoKph, + vSetKph: j.vSetKph, + temp: j.temp, + redDot: j.redDot, + tlight: j.tlight, + tfGap: j.tfGap, + tfBars: j.tfBars, + gear: j.gear, + gpsOk: j.gpsOk, + driveMode: j.driveMode, + speedLimitKph: j.speedLimitKph, + speedLimitOver: j.speedLimitOver, + apm: j.apm, + }; + + window.DrivingHud.update(payload); +} +function carWsConnect() { + // ̹ н + if (CAR_WS && (CAR_WS.readyState === WebSocket.OPEN || CAR_WS.readyState === WebSocket.CONNECTING)) return; + + const wsProto = (location.protocol === "https:") ? "wss" : "ws"; + CAR_WS = new WebSocket(wsProto + "://" + location.host + "/ws/carstate"); + + CAR_WS.onopen = () => { + console.log("[CAR_WS] open"); + }; + + CAR_WS.onmessage = (ev) => { + try { + const j = JSON.parse(ev.data); + // console.log("[CAR_WS] msg keys:", Object.keys(j || {})); + // console.log("[CAR_WS] vEgo:", j?.vEgo, "type:", typeof j?.vEgo); + drivingHudUpdateFromCarPayload(j); + hudAutoDock(); + } catch (e) { + console.log("[CAR_WS] bad msg", e, ev.data); + } + }; + + CAR_WS.onerror = (e) => { + console.log("[CAR_WS] error", e); + }; + + CAR_WS.onclose = () => { + console.log("[CAR_WS] close -> reconnect"); + CAR_WS = null; + carWsScheduleReconnect(1000); + }; +} + +async function carWsDisconnect() { + if (CAR_WS_RETRY_T) { clearTimeout(CAR_WS_RETRY_T); CAR_WS_RETRY_T = null; } + try { if (CAR_WS) CAR_WS.close(); } catch {} + CAR_WS = null; +} + +async function updateQuickLink() { + const el = document.getElementById("quickLink"); + if (!el) return; + + try { + const v = await bulkGet(["GithubUsername"]); + const githubId = (v["GithubUsername"] || "").trim(); + + if (!githubId) { + el.style.display = ""; + el.textContent = "GithubUsername empty (bulkGet ok)"; + return; + } + + const url = `https://shind0.synology.me/carrot/go/?id=${encodeURIComponent(githubId)}`; + el.href = url; + el.textContent = url; + el.style.display = ""; + } catch (e) { + el.style.display = ""; + el.removeAttribute("href"); + el.textContent = "QuickLink error: " + (e?.message || e); + console.log("[QuickLink] failed:", e); + } +} + + + + + + +function startAll() { + showPage("home", false); + rtcInitAuto(); + updateQuickLink().catch(() => {}); + + if (window.DrivingHud) { + window.DrivingHud.init(); + } + + // start car telemetry WS (10Hz) + carWsConnect(); + + // keep HUD dock state in sync + window.addEventListener("resize", hudAutoDock); + document.addEventListener("fullscreenchange", hudAutoDock); + setInterval(hudAutoDock, 800); +} + + + +if (document.readyState === "loading") { + window.addEventListener("DOMContentLoaded", startAll); +} else { + startAll(); +} + diff --git a/selfdrive/carrot/web/hud_card.css b/selfdrive/carrot/web/hud_card.css new file mode 100644 index 00000000..48a96d1a --- /dev/null +++ b/selfdrive/carrot/web/hud_card.css @@ -0,0 +1,280 @@ +/* ===== Driving HUD card ===== */ +#driveHudCard { padding: 10px; } +.hudWrap{ + border: 2px solid rgba(255,255,255,0.65); + border-radius: 18px; + padding: 10px; + background: rgba(0,0,0,0.22); + aspect-ratio: 1 / 1; + width: 100%; + max-width: 320px; + margin: 0 auto; + box-sizing: border-box; + position: relative; +} + +.hudTop{ + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; +} + +.hudMini{ + background: #1e8f3f; + border: 2px solid rgba(0,0,0,0.35); + border-radius: 10px; + padding: 6px 8px; + text-align: center; + color: #fff; + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.15); +} +.hudMiniLabel{ + font-weight: 900; + font-size: 12px; + line-height: 1.0; +} +.hudMiniVal{ + font-weight: 900; + font-size: 16px; + line-height: 1.1; + margin-top: 2px; +} + +.hudMain { + position: relative; + height: 140px +} +.hudLowerGroup { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; +} + +.hudSpeedBg { + position: absolute; + width: 225px; + height: 180px; + left: 40%; + top: 6px; + transform: translateX(-50%); + background-image: url("/speed_bg.png"); + background-repeat: no-repeat; + background-size: contain; + background-position: center; + pointer-events: none; + opacity: 0.95; +} + +.hudSpeed{ + position: absolute; + font-size: 78px; + font-weight: 1000; + letter-spacing: -2px; + color: #fff; + text-shadow: 0 2px 0 rgba(0,0,0,0.45); + left: 12%; + top: 18px; +} + +.hudRedDot{ + position: absolute; + left: 12px; + top: 30px; + width: 18px; + height: 18px; + border-radius: 999px; + background: #ff2a2a; + box-shadow: 0 0 0 3px rgba(0,0,0,0.35); +} + +.hudSignalDot{ + position: absolute; + left: 2px; + top: 6px; + width: 20px; + height: 20px; + border-radius: 999px; + background: #15d14b; /* green default */ + box-shadow: 0 0 0 3px rgba(0,0,0,0.35); +} + +.hudTempReason { + position: absolute; + left: 73%; + top: 13px; + font-weight: 900; + font-size: 14px; + color: #22ff61; +} +.hudTempSpeed{ + position: absolute; + left:73%; + top: 22px; + font-weight: 1000; + font-size: 33px; + color: #22ff61; + letter-spacing: -1px; +} + +.hudSetSpeed{ + font-weight: 1000; + font-size: 33px; + color: #22ff61; + letter-spacing: -1px; + position: absolute; + left: 55%; + top: 56px; +} + +.hudGapNum{ + position: absolute; + left: 69%; + top: 109px; + display:flex; + align-items:center; + justify-content:center; + font-weight: 900; + font-size: 20px; + color:#fff; +} + +.hudGear{ + position: absolute; + right: 0%; + bottom: 0%; + align-items:center; + justify-content:center; + font-weight: 1000; + font-size: 48px; + border: 2px solid rgba(255,255,255,0.65); + border-radius: 14px; + color:#1cff57; +} + +.hudBottom{ + display:flex; + gap: 10px; + align-items: center; + justify-content: space-between; + margin-top: 10px; +} + +.hudLeftStack { + display: flex; + flex-direction: column; + align-items: center; + gap: 0px; +} + +.hudGps{ + padding: 6px 10px; + border-radius: 12px; + color: #fff; + font-weight: 900; + font-size: 14px; + min-width: 54px; + text-align:center; +} + +.hudGps.off{ + color: rgba(255,255,255,0.75); +} + +.hudDriveMode { + padding: 4px 10px; + align-items: center; + border-radius: 12px; + border: 2px solid rgba(0,0,0,0.35); + font-weight: 900; + font-size: 14px; + min-width: 66px; + text-align: center; + background: #e7eef7; + color: #1a1f26; +} +.hudDriveMode.mode_normal{ background:#e7eef7; color:#1a1f26; } +.hudDriveMode.mode_eco{ background:#10c248; color:#fff; } +.hudDriveMode.mode_safe{ background:#ff9c2a; color:#1a1f26; } +.hudDriveMode.mode_sport{ background:#ff2a2a; color:#fff; } + +.hudRoadLimitLabel { + padding: 6px 10px; + border-radius: 12px; + color: #fff; + font-weight: 900; + font-size: 14px; + min-width: 54px; + text-align: center; +} + +.hudRoadLimitVal { + padding: 4px 10px; + align-items: center; + border-radius: 12px; + border: 2px solid rgba(0,0,0,0.35); + font-weight: 900; + font-size: 14px; + min-width: 66px; + text-align: center; + color: #fff; +} +.hudRoadLimitVal.over{ + background: #ff2a2a; + border-color: rgba(0,0,0,0.35); +} + +.hudBars{ + display:flex; + flex-direction: column; + gap: 5px; + align-items: flex-end; + height: 75px; +} +.hudBar { + width: 40px; + height: 16px; + border-radius: 4px; + background: rgba(255,255,255,0.28); + border: 1px solid rgba(0,0,0,0.35); + transition: height 0.15s ease, background 0.15s ease; +} +.hudBar.on{ + background: #1cff57; +} + +@media (max-width: 360px){ + #driveHudCard .hudWrap{ max-width: 280px; } + #driveHudCard .hudSpeed{ font-size: 68px; } + #driveHudCard .hudSetSpeed{ font-size: 40px; } +} + +/* ===== WebRTC overlay dock ===== */ +.hudOverlayHost{ + position:absolute; + inset:0; + pointer-events:none; +} + +/* default: top-center overlay (non-fullscreen) */ +.hudOverlayHost.dock_top .hudWrap{ + position:absolute; + top:10px; + left:50%; + transform: translateX(-50%) scale(0.82); + transform-origin: top center; + width: 320px; + max-width: 320px; +} + +/* fullscreen/landscape: bottom-left overlay */ +.hudOverlayHost.dock_bl .hudWrap{ + position:absolute; + left:12px; + bottom:12px; + transform: scale(0.86); + transform-origin: left bottom; + width: 320px; + max-width: 320px; +} + diff --git a/selfdrive/carrot/web/hud_card.js b/selfdrive/carrot/web/hud_card.js new file mode 100644 index 00000000..41249e53 --- /dev/null +++ b/selfdrive/carrot/web/hud_card.js @@ -0,0 +1,163 @@ +/* Driving HUD widget (standalone) + * - Exposes: window.DrivingHud.init(), window.DrivingHud.update(payload) + * Payload (suggested): + * { + * cpuTempC, memPct, diskPct, diskLabel, + * vEgoKph, vSetKph, + * temp: { reason, speedKph, isDecel }, // optional + * redDot, tlight, // tlight: "green"|"red"|"off" + * tfGap, gear, gpsOk, + * driveMode: { name, kind }, // kind: "normal"|"eco"|"safe"|"sport" + * speedLimitKph, speedLimitOver, + * apm, tfBars + * } + */ +(function () { + function $(id) { return document.getElementById(id); } + function setText(id, v) { const el=$(id); if (el) el.textContent = (v==null? "" : String(v)); } + function show(id, on) { const el=$(id); if (el) el.style.display = on ? "" : "none"; } + + function clamp01(x){ x = Number(x); if (!isFinite(x)) return 0; return Math.max(0, Math.min(1, x)); } + + function setMini(elId, labelId, label, valueText, good=true){ + const el = $(elId); + if (!el) return; + if (labelId) setText(labelId, label); + setText(elId, valueText); + } + + function setSignalDot(kind){ + const el = $("hudSignalDot"); + if (!el) return; + if (kind === "red") el.style.background = "#ff2a2a"; + else if (kind === "green") el.style.background = "#15d14b"; + else el.style.background = "rgba(255,255,255,0.18)"; + } + + function setGear(txt){ + const el = $("hudGear"); + if (!el) return; + el.textContent = txt || "U"; + // green for D/number, gray for unknown, orange for R, blue for N (taste) + const t = String(txt || "U").toUpperCase(); + if (t === "R") el.style.color = "#ff9c2a"; + else if (t === "N") el.style.color = "#7ec8ff"; + else if (t === "U") el.style.color = "rgba(255,255,255,0.7)"; + else el.style.color = "#1cff57"; + } + + function setDriveMode(name, kind){ + const el = $("hudDriveMode"); + if (!el) return; + el.textContent = name || "일반"; + el.classList.remove("mode_normal","mode_eco","mode_safe","mode_sport"); + if (kind === "eco") el.classList.add("mode_eco"); + else if (kind === "safe") el.classList.add("mode_safe"); + else if (kind === "sport") el.classList.add("mode_sport"); + else el.classList.add("mode_normal"); + } + + function setGps(ok){ + const el = $("hudGps"); + if (!el) return; + el.classList.toggle("off", !ok); + } + + function setRoadLimit(speedKph, over){ + const box = $("hudRoadLimitVal"); + setText("hudRoadLimitVal", (speedKph==null || !isFinite(speedKph)) ? "--" : Math.round(speedKph)); + if (box) box.classList.toggle("over", !!over); + } + + function setBars(n){ + const wrap = $("hudBars"); + if (!wrap) return; + const bars = wrap.querySelectorAll(".hudBar"); + const k = Math.max(0, Math.min(bars.length, Number(n) || 0)); + const start = bars.length - k; + bars.forEach((b, i) => b.classList.toggle("on", i >= start)); + } + + function setGapNum(n){ + const el = $("hudGapNum"); + if (!el) return; + if (n == null || !isFinite(n)) el.textContent = "-"; + else el.textContent = String(Math.round(n)); + } + + function setTemp(temp){ + //const row = $("hudTemphudTempReason"); + if (!temp || temp.speed == null || !isFinite(temp.speed) || !temp.source) { + //row.style.display = "none"; + return; + } + //row.style.display = ""; + $("hudTempReason").textContent = String(temp.source); + $("hudTempSpeed").textContent = String(Math.round(temp.speed)); + + const isDecel = !!temp.is_decel; + const color = isDecel ? "#ff9c2a" : "#22ff61"; + $("hudTempReason").style.color = color; + $("hudTempSpeed").style.color = color; + } + + function setSpeed(vEgoKph){ + const el = $("hudSpeed"); + //console.log("[HUD] setSpeed", vEgoKph, "el?", !!el); + if (!el) return; + if (vEgoKph == null || !isFinite(vEgoKph)) { el.textContent = "--"; return; } + el.textContent = String(Math.round(vEgoKph)); + } + + function setSetSpeed(vSetKph){ + const el = $("hudSetSpeed"); + if (!el) return; + if (vSetKph == null || !isFinite(vSetKph)) { el.textContent = "--"; return; } + el.textContent = String(Math.round(vSetKph)); + } + + function setRedDot(on){ + show("hudRedDot", !!on); + } + + function setSys(cpuTempC, memPct, diskPct, diskLabel){ + setText("hudCpuVal", (cpuTempC==null || !isFinite(cpuTempC)) ? "--°C" : `${cpuTempC.toFixed(0)}°C`); + setText("hudMemVal", (memPct==null || !isFinite(memPct)) ? "--%" : `${memPct.toFixed(0)}%`); + setText("hudDiskVal", (diskPct==null || !isFinite(diskPct)) ? "--%" : `${diskPct.toFixed(0)}%`); + if (diskLabel) setText("hudDiskLabel", diskLabel); + } + + const DrivingHud = { + init() { + // default visuals + setBars(0); + setSignalDot("off"); + setGps(false); + setDriveMode("일반","normal"); + setRoadLimit(null, false); + setGapNum(null); + setGear("U"); + setRedDot(false); + setTemp(null); + }, + + update(p) { + if (!p) return; + + setSys(p.cpuTempC, p.memPct, p.diskPct, p.diskLabel); + setSpeed(p.vEgoKph); + setSetSpeed(p.vSetKph); + setSignalDot(p.tlight || "off"); + setRedDot(p.redDot); + setTemp(p.temp); + setGapNum(p.tfGap); + setBars(p.tfBars != null ? p.tfBars : p.tfGap); // default same meaning + setGear(p.gear); + setGps(!!p.gpsOk); + if (p.driveMode) setDriveMode(p.driveMode.name, p.driveMode.kind); + setRoadLimit(p.speedLimitKph, p.speedLimitOver); + } + }; + + window.DrivingHud = DrivingHud; +})(); diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html new file mode 100644 index 00000000..a651624d --- /dev/null +++ b/selfdrive/carrot/web/index.html @@ -0,0 +1,541 @@ + + + + + + Carrot + + + + + +
+ + + + + +
+ +
+ +
+

Home

+ + +
+
+
+
+
CPU
+
--°C
+
+
+
MEM
+
--%
+
+
+
DISK
+
--%
+
+
+ +
+
+ + +
--
+ +
+ +
eco
+
--
+ +
--
+ +
-
+
U
+ +
+ +
+
+
GPS
+
일반
+
+ +
+
LIMIT
+
--
+
+ +
+
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+

Server State

+
connecting...
+
+
+

Quick Link

+ + + +
+ * 길게 눌러 링크저장 +
+
+
+ + + + + + + + + + + +
+ + + + + diff --git a/selfdrive/carrot/web/speed_bg.png b/selfdrive/carrot/web/speed_bg.png new file mode 100644 index 00000000..46d8da18 Binary files /dev/null and b/selfdrive/carrot/web/speed_bg.png differ diff --git a/selfdrive/carrot/web/webrtc_test.html b/selfdrive/carrot/web/webrtc_test.html new file mode 100644 index 00000000..e4f3112b --- /dev/null +++ b/selfdrive/carrot/web/webrtc_test.html @@ -0,0 +1,217 @@ + + + + + webrtcd browser test + + + +

webrtcd browser test

+ +
+ +
+ +
+ +
: road / wideRoad / driver ( ϴ ߼)
+ +
+
+
+ + + + +

Remote streams

+
+ +

Log

+

+
+  
+
+
diff --git a/selfdrive/carrot_settings.json b/selfdrive/carrot_settings.json
index 77935ee3..6a881433 100644
--- a/selfdrive/carrot_settings.json
+++ b/selfdrive/carrot_settings.json
@@ -967,12 +967,12 @@
       "group": "시작",
       "name": "DisableDM",
       "title": "DisableDM",
-      "descr": "운전자 감시 해제, 재부팅 필요",
+      "descr": "1.DisableDM, 2: +EnableWebRTC, reboot required",
       "egroup": "START",
       "etitle": "DisableDM",
-      "edescr": "Reboot required",
+      "edescr": "1.DisableDM, 2: +EnableWebRTC, Reboot required",
       "min": 0,
-      "max": 1,
+      "max": 2,
       "default": 0,
       "unit": 1
     },
@@ -1566,10 +1566,10 @@
       "group": "차량간격",
       "name": "EnableSpeedTF",
       "title": "속도별 차간거리 설정(0)",
-      "descr": "-1: 자동단계선택, >0: 저속 차간거리 비율 감소",
+      "descr": "-1:0/30/60/90km/h 단계\n-2:0/40/80/120km/h 단계\n-3:0/50/100/150km/h 단계\n1~50%:100km/h 이하 TF 감소",
       "egroup": "FDIST",
       "etitle": "EnableSpeedTF(0)",
-      "edescr": "-1:Auto select by speed, >0: Reduce TFs by ratio",
+      "edescr": "-1:0/30/60/90km/h steps\n-2:0/40/80/120km/h steps\n-3:0/50/100/150km/h steps\n1~50%:Reduce TF below 100km/h",
       "min": -3,
       "max": 50,
       "default": 0,
diff --git a/selfdrive/controls/lib/latcontrol_torque.py b/selfdrive/controls/lib/latcontrol_torque.py
index a3532669..3dd2a96c 100644
--- a/selfdrive/controls/lib/latcontrol_torque.py
+++ b/selfdrive/controls/lib/latcontrol_torque.py
@@ -181,7 +181,10 @@ class LatControlTorque(LatControl):
           actual_curvature_rate = -VM.calc_curvature(math.radians(CS.steeringRateDeg), CS.vEgo, 0.0)
           actual_lateral_jerk = actual_curvature_rate * CS.vEgo ** 2
       else:
-        actual_curvature_llk = CC.angularVelocity[2] / CS.vEgo #llk.angularVelocityCalibrated.value[2] / CS.vEgo
+        if len(CC.angularVelocity) >= 2:
+          actual_curvature_llk = 0
+        else:
+          actual_curvature_llk = CC.angularVelocity[2] / CS.vEgo #llk.angularVelocityCalibrated.value[2] / CS.vEgo
         actual_curvature = np.interp(CS.vEgo, [2.0, 5.0], [actual_curvature_vm, actual_curvature_llk])
         curvature_deadzone = 0.0
       desired_lateral_accel = desired_curvature * CS.vEgo ** 2
diff --git a/selfdrive/modeld/models/driving_policy.onnx b/selfdrive/modeld/models/driving_policy.onnx
index e42bb8ea..7de33552 100644
Binary files a/selfdrive/modeld/models/driving_policy.onnx and b/selfdrive/modeld/models/driving_policy.onnx differ
diff --git a/selfdrive/modeld/models/driving_vision.onnx b/selfdrive/modeld/models/driving_vision.onnx
index 902f1dd3..aff86857 100644
Binary files a/selfdrive/modeld/models/driving_vision.onnx and b/selfdrive/modeld/models/driving_vision.onnx differ
diff --git a/selfdrive/ui/carrot.cc b/selfdrive/ui/carrot.cc
index 0468adef..d5615aa0 100644
--- a/selfdrive/ui/carrot.cc
+++ b/selfdrive/ui/carrot.cc
@@ -2435,9 +2435,9 @@ public:
         else if (carState.getGearShifter() == cereal::CarState::GearShifter::PARK) strcpy(gear_str, "P");
         else if (carState.getGearShifter() == cereal::CarState::GearShifter::DRIVE) {
             if (carState.getGearStep() > 0)
-				sprintf(gear_str, "%d", carState.getGearStep());
-			else
-				strcpy(gear_str, "D");
+      				sprintf(gear_str, "%d", carState.getGearStep());
+		      	else
+				      strcpy(gear_str, "D");
         }
         else if(carState.getGearShifter() == cereal::CarState::GearShifter::NEUTRAL) strcpy(gear_str, "N");
         else if (carState.getGearShifter() == cereal::CarState::GearShifter::REVERSE) strcpy(gear_str, "R");
diff --git a/selfdrive/ui/qt/offroad/settings.cc b/selfdrive/ui/qt/offroad/settings.cc
index 0bcb79d6..5074d077 100644
--- a/selfdrive/ui/qt/offroad/settings.cc
+++ b/selfdrive/ui/qt/offroad/settings.cc
@@ -842,7 +842,7 @@ CarrotPanel::CarrotPanel(QWidget* parent) : QWidget(parent) {
   startToggles->addItem(new CValueControl("NNFFLite", tr("NNFFLite"), tr("Twilsonco's NNFF-Lite(Reboot required)"), 0, 1, 1));
   startToggles->addItem(new CValueControl("AutoGasSyncSpeed", tr("Auto update Cruise speed"), "", 0, 1, 1));
   startToggles->addItem(new CValueControl("DisableMinSteerSpeed", tr("Disable Min.SteerSpeed"), "", 0, 1, 1));
-  startToggles->addItem(new CValueControl("DisableDM", tr("Disable DM"), "", 0, 1, 1));
+  startToggles->addItem(new CValueControl("DisableDM", tr("Disable DM"), "", 0, 2, 1));
   startToggles->addItem(new CValueControl("HotspotOnBoot", tr("Hotspot enabled on boot"), "", 0, 1, 1));
   startToggles->addItem(new CValueControl("SoftwareMenu", tr("Enable Software Menu"), "", 0, 1, 1));
   startToggles->addItem(new CValueControl("IsLdwsCar", tr("IsLdwsCar"), "", 0, 1, 1));
diff --git a/system/loggerd/encoder/ffmpeg_encoder.cc b/system/loggerd/encoder/ffmpeg_encoder.cc
index 4e694636..2b7e2de0 100644
--- a/system/loggerd/encoder/ffmpeg_encoder.cc
+++ b/system/loggerd/encoder/ffmpeg_encoder.cc
@@ -59,7 +59,15 @@ void FfmpegEncoder::encoder_open() {
   this->codec_ctx->height = frame->height;
   this->codec_ctx->pix_fmt = AV_PIX_FMT_YUV420P;
   this->codec_ctx->time_base = (AVRational){ 1, encoder_info.fps };
-  int err = avcodec_open2(this->codec_ctx, codec, NULL);
+  //int err = avcodec_open2(this->codec_ctx, codec, NULL);
+  bool env_dashy = true;
+  AVDictionary* opts = NULL;
+  if (env_dashy && codec_id == AV_CODEC_ID_H264) {
+    av_dict_set(&opts, "preset", "ultrafast", 0);
+    av_dict_set(&opts, "tune", "zerolatency", 0);
+  }
+  int err = avcodec_open2(this->codec_ctx, codec, &opts);
+  av_dict_free(&opts);
   assert(err >= 0);
 
   is_open = true;
diff --git a/system/manager/process_config.py b/system/manager/process_config.py
index 24528e33..74dbe621 100644
--- a/system/manager/process_config.py
+++ b/system/manager/process_config.py
@@ -78,6 +78,9 @@ def enable_connect(started, params, CP: car.CarParams) -> bool:
 def enable_xiaoge_data(started, params, CP: car.CarParams) -> bool:
   return params.get_bool("ShareData")
 
+def enable_webrtc(started, params, CP: car.CarParams) -> bool:
+  return params.get_int("DisableDM") == 2
+
 def c3x_lite(started: bool, params: Params, CP: car.CarParams) -> bool:
   return started and params.get_bool("HardwareC3xLite")
 
@@ -86,7 +89,7 @@ procs = [
 
   NativeProcess("loggerd", "system/loggerd", ["./loggerd"], logging),
   NativeProcess("encoderd", "system/loggerd", ["./encoderd"], only_onroad),
-  NativeProcess("stream_encoderd", "system/loggerd", ["./encoderd", "--stream"], notcar),
+  NativeProcess("stream_encoderd", "system/loggerd", ["./encoderd", "--stream"], or_(notcar, and_(only_onroad, enable_webrtc))),
   PythonProcess("logmessaged", "system.logmessaged", always_run),
 
   NativeProcess("camerad", "system/camerad", ["./camerad"], driverview, enabled=not WEBCAM),
@@ -132,7 +135,7 @@ procs = [
 
   # debug procs
   NativeProcess("bridge", "cereal/messaging", ["./bridge"], notcar),
-  PythonProcess("webrtcd", "system.webrtc.webrtcd", notcar),
+  PythonProcess("webrtcd", "system.webrtc.webrtcd", or_(notcar, and_(only_onroad, enable_webrtc))),
   PythonProcess("webjoystick", "tools.bodyteleop.web", notcar),
   PythonProcess("joystick", "tools.joystick.joystick_control", and_(joystick, iscar)),
 
@@ -140,6 +143,8 @@ procs = [
   PythonProcess("fleet_manager", "selfdrive.frogpilot.fleetmanager.fleet_manager", check_fleet),
   PythonProcess("carrot_man", "selfdrive.carrot.carrot_man", always_run),#, enabled=not PC),
 
+  PythonProcess("carrot_server", "selfdrive.carrot.carrot_server", always_run),
+
   #Xiaoge data broadcaster (conditional on ShareData param)
   PythonProcess("xiaoge_data", "selfdrive.carrot.xiaoge_data", enable_xiaoge_data),
   # c3x lite
diff --git a/system/webrtc/device/video.py b/system/webrtc/device/video.py
index 1bca9092..007c358f 100644
--- a/system/webrtc/device/video.py
+++ b/system/webrtc/device/video.py
@@ -22,11 +22,16 @@ class LiveStreamVideoStreamTrack(TiciVideoStreamTrack):
     self._pts = 0
 
   async def recv(self):
+    waited = 0
     while True:
       msg = messaging.recv_one_or_none(self._sock)
       if msg is not None:
         break
       await asyncio.sleep(0.005)
+      waited += 0.005
+      if waited > 1.0:
+        print("########### recv timedout....")
+        waited = 0
 
     evta = getattr(msg, msg.which())
 
diff --git a/system/webrtc/webrtcd.py b/system/webrtc/webrtcd.py
index fb93e565..46f113d1 100755
--- a/system/webrtc/webrtcd.py
+++ b/system/webrtc/webrtcd.py
@@ -42,7 +42,7 @@ class CerealOutgoingMessageProxy:
 
     return msg_dict
 
-  def update(self):
+  async def update(self):
     # this is blocking in async context...
     self.sm.update(0)
     for service, updated in self.sm.updated.items():
@@ -53,7 +53,11 @@ class CerealOutgoingMessageProxy:
       outgoing_msg = {"type": service, "logMonoTime": mono_time, "valid": valid, "data": msg_dict}
       encoded_msg = json.dumps(outgoing_msg).encode()
       for channel in self.channels:
-        channel.send(encoded_msg)
+        #channel.send(encoded_msg)
+        if isinstance(channel, web.WebSocketResponse):
+          await channel.send_bytes(encoded_msg)
+        else:
+          channel.send(encoded_msg)
 
 
 class CerealIncomingMessageProxy:
@@ -94,7 +98,7 @@ class CerealProxyRunner:
 
     while True:
       try:
-        self.proxy.update()
+        await self.proxy.update()
       except InvalidStateError:
         self.logger.warning("Cereal outgoing proxy invalid state (connection closed)")
         break
@@ -126,12 +130,19 @@ class StreamSession:
     from teleoprtc import WebRTCAnswerBuilder
     from teleoprtc.info import parse_info_from_offer
 
+    self.logger = logging.getLogger("webrtcd")
     config = parse_info_from_offer(sdp)
     builder = WebRTCAnswerBuilder(sdp)
 
     assert len(cameras) == config.n_expected_camera_tracks, "Incoming stream has misconfigured number of video tracks"
     for cam in cameras:
-      builder.add_video_stream(cam, LiveStreamVideoStreamTrack(cam) if not debug_mode else VideoStreamTrack())
+      try:
+        track = LiveStreamVideoStreamTrack(cam) if not debug_mode else VideoStreamTrack()
+        builder.add_video_stream(cam, track)
+        self.logger.info("added camera track: %s", cam)
+      except Exception:
+        self.logger.exception("failed to create camera track: %s", cam)
+        raise
     if config.expected_audio_track:
       builder.add_audio_stream(AudioInputStreamTrack() if not debug_mode else AudioStreamTrack())
     if config.incoming_audio_track:
@@ -153,15 +164,19 @@ class StreamSession:
 
     self.audio_output: AudioOutputSpeaker | MediaBlackhole | None = None
     self.run_task: asyncio.Task | None = None
-    self.logger = logging.getLogger("webrtcd")
     self.logger.info("New stream session (%s), cameras %s, audio in %s out %s, incoming services %s, outgoing services %s",
                       self.identifier, cameras, config.incoming_audio_track, config.expected_audio_track, incoming_services, outgoing_services)
+    config = parse_info_from_offer(sdp)
+    self.logger.info("offer expects video tracks=%d, audio_expected=%s, audio_incoming=%s",
+                     config.n_expected_camera_tracks, config.expected_audio_track, config.incoming_audio_track)
+    self.logger.info("request cameras=%s", cameras)
+
 
   def start(self):
     self.run_task = asyncio.create_task(self.run())
 
   def stop(self):
-    if self.run_task.done():
+    if self.run_task is None or self.run_task.done():
       return
     self.run_task.cancel()
     self.run_task = None
@@ -229,7 +244,7 @@ async def get_stream(request: 'web.Request'):
 
   stream_dict[session.identifier] = session
 
-  return web.json_response({"sdp": answer.sdp, "type": answer.type})
+  return web.json_response({"sdp": answer.sdp, "type": answer.type}, headers={'Access-Control-Allow-Origin': '*'})
 
 
 async def get_schema(request: 'web.Request'):
@@ -245,21 +260,41 @@ async def on_shutdown(app: 'web.Application'):
     session.stop()
   del app['streams']
 
+@web.middleware
+async def cors_middleware(request, handler):
+    response = await handler(request)
+    response.headers['Access-Control-Allow-Origin'] = '*'
+    response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
+    response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
+    return response
+
+async def handle_cors_preflight(request):
+    if request.method == 'OPTIONS':
+        headers = {
+            'Access-Control-Allow-Origin': '*',
+            'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
+            'Access-Control-Allow-Headers': 'Content-Type, Authorization',
+            'Access-Control-Max-Age': '86400',
+        }
+        return web.Response(status=200, headers=headers)
+    return await request.app['handler'](request)
 
 def webrtcd_thread(host: str, port: int, debug: bool):
   logging.basicConfig(level=logging.CRITICAL, handlers=[logging.StreamHandler()])
+  #logging.basicConfig(level=logging.INFO, handlers=[logging.StreamHandler()])
   logging_level = logging.DEBUG if debug else logging.INFO
   logging.getLogger("WebRTCStream").setLevel(logging_level)
   logging.getLogger("webrtcd").setLevel(logging_level)
 
-  app = web.Application()
+  app = web.Application(middlewares=[cors_middleware])
 
   app['streams'] = dict()
   app['debug'] = debug
   app.on_shutdown.append(on_shutdown)
   app.router.add_post("/stream", get_stream)
   app.router.add_get("/schema", get_schema)
-
+  app.router.add_route('OPTIONS', '/{tail:.*}', handle_cors_preflight)
+  
   web.run_app(app, host=host, port=port)