CD210 model, WebCarrotMan, fix SpeedTF (#248)
* scc_contro: a_val=> no jerk limitation. * fix.. * fix.. * fix.. * test. for isg * carrot web server * fix.. change branches.. * fix.. * CD210 model.. * fix.. * fix.. * ff * fix.. * test full screen * fix.. * fix.. * disable webrtc * fix.. * fix.. enableWebRTC, * fix.. * revert dm model. * register my ip * fix.. * test * fix.. * fix.. * fix.. speed TF, more TFs. 0, 10, 20, 30% * fix.. * fix.. * fix.. * fix.. * fix.. * fix * fix.. * fix.. * ff * ff * fix.. quick link * fix.. * fix.. * fix quick link... text.. * fix SCC error (canfd) * fix.. speed TF < 0 * hud * fix.. * fix.. tf.. * fleet.. * release.. * web cmd * fix run cmd.. * fix cmd * cmd allow * fix.. * fix.. release..
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -0,0 +1,541 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Carrot</title>
|
||||
<link rel="stylesheet" href="/hud_card.css?v=2602-01" />
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0;
|
||||
background: #0b0d10;
|
||||
color: #e8eef5;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: #11161d;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #2a3542;
|
||||
background: #141b24;
|
||||
color: #e8eef5;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
background: #1e2a3a;
|
||||
border-color: #3b5168;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #253040;
|
||||
background: #0f141c;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #9bb0c6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.groupBtn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.setting {
|
||||
border: 1px solid #253040;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
background: #0c1118;
|
||||
}
|
||||
|
||||
.settingTop {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #9bb0c6;
|
||||
}
|
||||
|
||||
.descr {
|
||||
white-space: pre-wrap;
|
||||
margin-top: 6px;
|
||||
color: #c7d6e6;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ctrl {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pill {
|
||||
border: 1px solid #2a3542;
|
||||
background: #141b24;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: #c7d6e6;
|
||||
}
|
||||
|
||||
.smallBtn {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a3542;
|
||||
background: #141b24;
|
||||
color: #e8eef5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.val {
|
||||
min-width: 84px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.langToggle {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btnRow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.backupRow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.restoreRow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.restoreRow input[type="file"] {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* screen transition */
|
||||
.screen {
|
||||
transition: opacity .16s ease, transform .16s ease;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.screen.hidden {
|
||||
opacity: 0;
|
||||
transform: translateX(12px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Mobile improvements */
|
||||
@media (max-width: 640px) {
|
||||
.topbar {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.settingTop {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ctrl {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.val {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.smallBtn {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.rtcVideo {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
background: #000;
|
||||
object-fit: contain;
|
||||
/* 세로모드 기본: 너무 작게 안 만들고 싶으면 제한 안 둬도 됨 */
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
.rtcVideo {
|
||||
max-height: 40vh; /* 35~50vh 사이에서 취향 */
|
||||
width: auto; /* 가로모드에서 “폭 100%” 강제 안 함 */
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Fullscreen override (video or wrapper) ===== */
|
||||
/* ===== Fullscreen override (wrapper) ===== */
|
||||
#rtcWrap:fullscreen,
|
||||
#rtcWrap:-webkit-full-screen {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: #000 !important;
|
||||
z-index: 9999 !important;
|
||||
/* fullscreen에서만 중앙정렬 */
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
/* fullscreen일 때 video는 무조건 꽉 채움 */
|
||||
#rtcWrap:fullscreen #rtcVideo,
|
||||
#rtcWrap:-webkit-full-screen #rtcVideo {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
object-fit: contain !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<button id="btnHome" class="btn active">Home</button>
|
||||
<button id="btnSetting" class="btn">Setting</button>
|
||||
<button id="btnTools" class="btn">Tools</button>
|
||||
<button id="btnFleet" class="btn">Fleet</button>
|
||||
<button id="btnLang" class="btn langToggle">Lang: <span id="langLabel">KO</span></button>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<!-- HOME -->
|
||||
<div id="pageHome" class="card">
|
||||
<h3 style="margin:0 0 8px 0;">Home</h3>
|
||||
|
||||
<!-- Driving HUD (card mode). JS will auto-dock this into WebRTC overlay when video is visible. -->
|
||||
<div id="driveHudCard" class="card" style="margin:12px 0 0 0;">
|
||||
<div class="hudWrap" id="hudRoot">
|
||||
<div class="hudTop">
|
||||
<div class="hudMini">
|
||||
<div class="hudMiniLabel">CPU</div>
|
||||
<div class="hudMiniVal" id="hudCpuVal">--°C</div>
|
||||
</div>
|
||||
<div class="hudMini">
|
||||
<div class="hudMiniLabel">MEM</div>
|
||||
<div class="hudMiniVal" id="hudMemVal">--%</div>
|
||||
</div>
|
||||
<div class="hudMini">
|
||||
<div class="hudMiniLabel" id="hudDiskLabel">DISK</div>
|
||||
<div class="hudMiniVal" id="hudDiskVal">--%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hudLowerGroup">
|
||||
<div class="hudMain">
|
||||
<div class="hudSpeedBg" aria-hidden="true"></div>
|
||||
<div class="hudRedDot" id="hudRedDot" style="display:none;"></div>
|
||||
<div class="hudSpeed" id="hudSpeed">--</div>
|
||||
|
||||
<div class="hudSignalDot" id="hudSignalDot"></div>
|
||||
|
||||
<div class="hudTempReason" id="hudTempReason">eco</div>
|
||||
<div class="hudTempSpeed" id="hudTempSpeed">--</div>
|
||||
|
||||
<div class="hudSetSpeed" id="hudSetSpeed">--</div>
|
||||
|
||||
<div class="hudGapNum" id="hudGapNum">-</div>
|
||||
<div class="hudGear" id="hudGear">U</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="hudBottom">
|
||||
<div class="hudLeftStack">
|
||||
<div class="hudGps" id="hudGps">GPS</div>
|
||||
<div class="hudDriveMode mode_normal" id="hudDriveMode">일반</div>
|
||||
</div>
|
||||
|
||||
<div class="hudRoadLimitBox">
|
||||
<div class="hudRoadLimitLabel">LIMIT</div>
|
||||
<div class="hudRoadLimitVal" id="hudRoadLimitVal">--</div>
|
||||
</div>
|
||||
|
||||
<div class="hudBars" id="hudBars">
|
||||
<div class="hudBar"></div>
|
||||
<div class="hudBar"></div>
|
||||
<div class="hudBar"></div>
|
||||
<div class="hudBar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server State (기존 유지) -->
|
||||
<div id="rtcCard" class="card" style="margin:12px 0 0 0; display:none;">
|
||||
<h4 style="margin:0 0 8px 0;">WebRTC</h4>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
||||
<div class="muted" id="rtcStatus">connecting...</div>
|
||||
<button id="btnRtcFs" class="smallBtn">Full</button>
|
||||
</div>
|
||||
<div style="height:8px;"></div>
|
||||
|
||||
<!-- wrapper 추가 (relative) -->
|
||||
<div id="rtcWrap" style="position:relative;">
|
||||
<video id="rtcVideo"
|
||||
class="rtcVideo"
|
||||
autoplay playsinline muted style="display:none;">
|
||||
</video>
|
||||
|
||||
<!-- HUD overlay host (video overlay mode) -->
|
||||
<div id="hudOverlayHost" class="hudOverlayHost" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:10px;"></div>
|
||||
|
||||
<div class="card" style="margin:0;">
|
||||
<h4 style="margin:0 0 6px 0;">Server State</h4>
|
||||
<pre id="stateBox" class="pill" style="display:block; padding:10px; border-radius:14px;">connecting...</pre>
|
||||
</div>
|
||||
<div class="card" style="margin-top:12px;">
|
||||
<h4 style="margin:0 0 6px 0;">Quick Link</h4>
|
||||
|
||||
<a id="quickLink" href="#" target="_blank" rel="noopener"
|
||||
style="word-break:break-all; display:none; text-decoration:none; color:#9bb0c6;">
|
||||
</a>
|
||||
|
||||
<div class="muted" style="margin-top:6px;">
|
||||
* 길게 눌러 링크저장
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAR SELECT (new page) -->
|
||||
<div id="pageCar" class="card" style="display:none;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
||||
<h3 id="carTitle" style="margin:0 0 8px 0; cursor:pointer;">Car Select</h3>
|
||||
<button id="btnBackCar" class="btn">Back</button>
|
||||
</div>
|
||||
<!-- Current Car -->
|
||||
<div class="card" style="margin:0 0 12px 0;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
||||
<!-- 여기 헤더가 차량명 -->
|
||||
<div id="curCarLabelCar" style="font-weight:900; font-size:14px; line-height:1.2;">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="muted" id="carMeta">loading...</div>
|
||||
<div style="height:10px;"></div>
|
||||
|
||||
<!-- Maker screen -->
|
||||
<div id="carScreenMakers" class="screen">
|
||||
<div class="card" style="margin:0;">
|
||||
<h4 style="margin:0 0 10px 0;">Makers</h4>
|
||||
<div id="makerList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model screen -->
|
||||
<div id="carScreenModels" class="screen hidden" style="display:none;">
|
||||
<div class="card" style="margin:0;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
||||
<h4 id="modelTitle" style="margin:0; cursor:pointer;">Models</h4>
|
||||
<div class="muted" id="modelMeta">-</div>
|
||||
</div>
|
||||
<div style="height:10px;"></div>
|
||||
<div id="modelList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SETTING -->
|
||||
<div id="pageSetting" class="card" style="display:none;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
||||
<h3 id="settingTitle" style="margin:0 0 8px 0; cursor:pointer;">Setting</h3>
|
||||
<button id="btnBackGroups" class="btn" style="display:none;">Back</button>
|
||||
</div>
|
||||
|
||||
<div class="muted" id="settingsMeta">loading...</div>
|
||||
<div style="height:10px;"></div>
|
||||
|
||||
<!-- Current Car -->
|
||||
<div class="card" style="margin:0 0 12px 0;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
||||
<!-- 여기 헤더가 차량명 -->
|
||||
<div id="curCarLabelSetting" style="font-weight:900; font-size:14px; line-height:1.2;">-</div>
|
||||
<button id="btnChangeCar" class="btn">Change</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups Screen -->
|
||||
<div id="settingScreenGroups" class="screen">
|
||||
<div class="card" style="margin:0;">
|
||||
<h4 style="margin:0 0 10px 0;">Groups</h4>
|
||||
<div id="groupList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items Screen -->
|
||||
<div id="settingScreenItems" class="screen hidden" style="display:none;">
|
||||
<div class="card" style="margin:0;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
||||
<h4 id="itemsTitle" style="margin:0; cursor:pointer;">Items</h4>
|
||||
<div class="muted" id="groupMeta">-</div>
|
||||
</div>
|
||||
<div style="height:10px;"></div>
|
||||
<div id="items"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOOLS -->
|
||||
<div id="pageTools" class="card" style="display:none;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
||||
<h3 style="margin:0 0 8px 0;">Tools</h3>
|
||||
<button id="btnToolsBack" class="btn">Back</button>
|
||||
</div>
|
||||
|
||||
<div class="muted" id="toolsMeta">-</div>
|
||||
<div style="height:10px;"></div>
|
||||
|
||||
<!-- Git / Update -->
|
||||
<div class="card" style="margin:0 0 12px 0;">
|
||||
<h4 style="margin:0 0 10px 0;">Git Commands</h4>
|
||||
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||
<button id="btnGitPull" class="btn">git pull</button>
|
||||
<button id="btnGitSync" class="btn">git sync</button>
|
||||
<button id="btnGitReset" class="btn">git reset</button>
|
||||
<button id="btnGitBranch" class="btn">change branch</button>
|
||||
</div>
|
||||
|
||||
<div class="muted" style="margin-top:10px;">
|
||||
* reset/branch는 위험할 수 있으니 confirm 뜹니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System / User -->
|
||||
<div class="card" style="margin:0;">
|
||||
<h4 style="margin:0 0 10px 0;">User / System</h4>
|
||||
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||
<button id="btnSendTmuxLog" class="btn">tmux log</button>
|
||||
<button id="btnDeleteVideos" class="btn">delete all videos</button>
|
||||
<button id="btnDeleteLogs" class="btn">delete all logs</button>
|
||||
<button id="btnReboot" class="btn">reboot</button>
|
||||
</div>
|
||||
|
||||
<div class="muted" style="margin-top:10px;">
|
||||
* delete/reboot는 confirm 후 실행합니다.
|
||||
</div>
|
||||
|
||||
<!-- Backup / Restore -->
|
||||
<div class="card">
|
||||
<div class="backupRow">
|
||||
<button id="btnBackupSettings" class="btn">backup settings</button>
|
||||
</div>
|
||||
|
||||
<div class="restoreRow">
|
||||
<input id="restoreFile" type="file" accept="application/json" />
|
||||
<button id="btnRestoreSettings" class="btn">restore settings</button>
|
||||
</div>
|
||||
|
||||
<div class="muted">
|
||||
* restore 후 reboot 권장
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h4 style="margin:0 0 6px 0;">System Command</h4>
|
||||
|
||||
<div class="muted" style="margin-bottom:8px;">
|
||||
Allowed: git pull/status/branch/log, df, free, uptime (whitelist)
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||||
<input id="sysCmdInput"
|
||||
type="text"
|
||||
placeholder='e.g. git pull'
|
||||
style="flex:1; min-width:220px; padding:10px; border-radius:12px; border:1px solid #2a3542; background:#0c1118; color:#e8eef5;" />
|
||||
<button id="btnSysCmdRun" class="smallBtn">Run</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="height:12px;"></div>
|
||||
|
||||
<!-- output -->
|
||||
<div class="card" style="margin:0;">
|
||||
<h4 style="margin:0 0 8px 0;">Output</h4>
|
||||
<pre id="toolsOut" class="pill" style="display:block; padding:10px; border-radius:14px; min-height:120px;">-</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- BRANCH SELECT -->
|
||||
<div id="pageBranch" class="card" style="display:none;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
||||
<h3 id="branchTitle" style="margin:0 0 8px 0; cursor:pointer;">Branch Select</h3>
|
||||
<button id="btnBackBranch" class="btn">Back</button>
|
||||
</div>
|
||||
|
||||
<div class="muted" id="branchMeta">-</div>
|
||||
<div style="height:10px;"></div>
|
||||
|
||||
<div id="branchList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/hud_card.js?v=2602-02"></script>
|
||||
<script src="/app.js?v=2602-02"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,217 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>webrtcd browser test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 48%;
|
||||
background: #000;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #111;
|
||||
color: #0f0;
|
||||
padding: 8px;
|
||||
height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
input, button {
|
||||
padding: 6px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>webrtcd browser test</h2>
|
||||
|
||||
<label>Device IP:Port (webrtcd)</label><br />
|
||||
<input id="baseUrl" style="width:420px" value="http://192.168.0.171:5001" />
|
||||
<br />
|
||||
|
||||
<label>Cameras (comma separated)</label><br />
|
||||
<input id="cameras" style="width:420px" value="road" />
|
||||
<div style="opacity:0.8">예: road / wideRoad / driver 등 (서버가 기대하는 개수와 맞추세요)</div>
|
||||
|
||||
<label><input type="checkbox" id="sendMic" /> Send microphone to device (incoming_audio_track)</label><br />
|
||||
<label><input type="checkbox" id="recvAudio" checked /> Receive audio from device (expected_audio_track)</label><br />
|
||||
<label><input type="checkbox" id="useDataChannel" checked /> Create datachannel (messaging)</label><br />
|
||||
|
||||
<button id="btnStart">Start</button>
|
||||
<button id="btnStop" disabled>Stop</button>
|
||||
|
||||
<h3>Remote streams</h3>
|
||||
<div class="row" id="videos"></div>
|
||||
|
||||
<h3>Log</h3>
|
||||
<pre id="log"></pre>
|
||||
|
||||
<script>
|
||||
const logEl = document.getElementById("log");
|
||||
function log(...args) {
|
||||
const s = args.map(a => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
|
||||
console.log(s);
|
||||
logEl.textContent += s + "\n";
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
let pc = null;
|
||||
let dc = null;
|
||||
let localStream = null;
|
||||
|
||||
async function start() {
|
||||
const baseUrl = document.getElementById("baseUrl").value.trim().replace(/\/+$/, "");
|
||||
const cameras = document.getElementById("cameras").value.split(",").map(s => s.trim()).filter(Boolean);
|
||||
const sendMic = document.getElementById("sendMic").checked;
|
||||
const recvAudio = document.getElementById("recvAudio").checked;
|
||||
const useDataChannel = document.getElementById("useDataChannel").checked;
|
||||
|
||||
if (!baseUrl) return log("baseUrl empty");
|
||||
if (!cameras.length) return log("cameras empty");
|
||||
|
||||
pc = new RTCPeerConnection({
|
||||
// 같은 LAN이면 STUN 없어도 되는 경우가 많습니다.
|
||||
iceServers: []
|
||||
});
|
||||
|
||||
pc.oniceconnectionstatechange = () => log("ICE:", pc.iceConnectionState);
|
||||
pc.onconnectionstatechange = () => log("PC :", pc.connectionState);
|
||||
|
||||
// --- Remote track handler ---
|
||||
const videosDiv = document.getElementById("videos");
|
||||
videosDiv.innerHTML = "";
|
||||
let videoCount = 0;
|
||||
pc.ontrack = (ev) => {
|
||||
log("ontrack kind=", ev.track.kind, "streams=", ev.streams.length);
|
||||
const stream = ev.streams[0] || new MediaStream([ev.track]);
|
||||
|
||||
if (ev.track.kind === "video") {
|
||||
videoCount++;
|
||||
const v = document.createElement("video");
|
||||
v.autoplay = true;
|
||||
v.playsInline = true;
|
||||
v.controls = true;
|
||||
v.muted = true; // 브라우저 자동재생 정책 회피 (영상은 mute)
|
||||
v.srcObject = stream;
|
||||
v.title = "remote video " + videoCount;
|
||||
videosDiv.appendChild(v);
|
||||
} else if (ev.track.kind === "audio") {
|
||||
// 오디오는 mute 하면 안 들리니 별도 오디오 엘리먼트
|
||||
const a = document.createElement("audio");
|
||||
a.autoplay = true;
|
||||
a.controls = true;
|
||||
a.srcObject = stream;
|
||||
videosDiv.appendChild(a);
|
||||
}
|
||||
};
|
||||
|
||||
// --- DataChannel (optional) ---
|
||||
if (useDataChannel) {
|
||||
dc = pc.createDataChannel("messaging"); // label이 서버 기대와 다르면 안 붙을 수 있음
|
||||
dc.onopen = () => log("DC open");
|
||||
dc.onclose = () => log("DC close");
|
||||
dc.onerror = (e) => log("DC error", e);
|
||||
dc.onmessage = (e) => {
|
||||
// 서버는 json bytes를 보냅니다.
|
||||
try {
|
||||
const txt = typeof e.data === "string" ? e.data : new TextDecoder().decode(e.data);
|
||||
const obj = JSON.parse(txt);
|
||||
log("DC msg:", obj.type);
|
||||
} catch (err) {
|
||||
log("DC raw msg:", e.data);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- Transceivers: video recvonly for each camera ---
|
||||
for (let i = 0; i < cameras.length; i++) {
|
||||
pc.addTransceiver("video", { direction: "recvonly" });
|
||||
}
|
||||
|
||||
// --- Audio: receive from device (expected_audio_track) ---
|
||||
if (recvAudio) {
|
||||
pc.addTransceiver("audio", { direction: "recvonly" });
|
||||
}
|
||||
|
||||
// --- Audio: send mic to device (incoming_audio_track) ---
|
||||
if (sendMic) {
|
||||
// getUserMedia는 보통 HTTPS 또는 localhost에서만 됩니다.
|
||||
// (LAN IP로 열면 막힐 수 있어요)
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
for (const track of localStream.getAudioTracks()) {
|
||||
pc.addTrack(track, localStream);
|
||||
}
|
||||
}
|
||||
|
||||
// Create offer
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
// Wait ICE gathering complete (간단히)
|
||||
await new Promise((resolve) => {
|
||||
if (pc.iceGatheringState === "complete") return resolve();
|
||||
pc.onicegatheringstatechange = () => {
|
||||
if (pc.iceGatheringState === "complete") resolve();
|
||||
};
|
||||
});
|
||||
|
||||
log("POST /stream ...");
|
||||
const res = await fetch(baseUrl + "/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sdp: pc.localDescription.sdp,
|
||||
cameras: cameras,
|
||||
bridge_services_in: [], // 필요하면 예: ["controlsState"]
|
||||
bridge_services_out: [] // 필요하면 예: ["carState","deviceState"]
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const t = await res.text();
|
||||
throw new Error("stream failed: " + res.status + " " + t);
|
||||
}
|
||||
|
||||
const answer = await res.json();
|
||||
log("answer type:", answer.type);
|
||||
await pc.setRemoteDescription({ type: answer.type, sdp: answer.sdp });
|
||||
|
||||
document.getElementById("btnStart").disabled = true;
|
||||
document.getElementById("btnStop").disabled = false;
|
||||
log("started.");
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (dc) { try { dc.close(); } catch(e) {} dc = null; }
|
||||
if (pc) {
|
||||
try { pc.getSenders().forEach(s => s.track && s.track.stop && s.track.stop()); } catch(e) {}
|
||||
try { pc.close(); } catch(e) {}
|
||||
pc = null;
|
||||
}
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(t => t.stop());
|
||||
localStream = null;
|
||||
}
|
||||
document.getElementById("btnStart").disabled = false;
|
||||
document.getElementById("btnStop").disabled = true;
|
||||
log("stopped.");
|
||||
}
|
||||
|
||||
document.getElementById("btnStart").onclick = () => start().catch(e => log("ERR:", e.message));
|
||||
document.getElementById("btnStop").onclick = () => stop();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user