1539 lines
50 KiB
HTML
1539 lines
50 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
<title>ExoMy // Ground Control</title>
|
|
<script type="text/javascript" src="./vendor/roslib.min.js"></script>
|
|
<link rel="stylesheet" href="./app.css?v=20260522105000">
|
|
</head>
|
|
<body class="page-index">
|
|
<header>
|
|
<svg class="hdr-icon" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
<ellipse cx="14" cy="51" rx="6" ry="7" fill="#FFB21F"/>
|
|
<ellipse cx="28" cy="50" rx="6" ry="8" fill="#FFB21F"/>
|
|
<ellipse cx="43" cy="50" rx="6" ry="8" fill="#FFB21F"/>
|
|
<ellipse cx="55" cy="49" rx="5" ry="7" fill="#FFB21F"/>
|
|
<path d="M11 36L23 30H50L58 32L47 37H14L11 36Z" fill="#1F43C9"/>
|
|
<rect x="24" y="14" width="7" height="23" rx="3.5" fill="#1B120F"/>
|
|
<path d="M18 38L25 36L41 36L51 40L51 46L42 49L38 59L31 59L29 50L19 47L18 38Z" fill="#1B120F"/>
|
|
<rect x="21" y="38" width="5" height="10" rx="2.5" fill="#1B120F"/>
|
|
<rect x="43" y="39" width="5" height="11" rx="2.5" fill="#1B120F"/>
|
|
<path d="M15 41L9 44L8 49" stroke="#1B120F" stroke-width="4" stroke-linecap="round"/>
|
|
<path d="M49 41L57 43L57 47" stroke="#1B120F" stroke-width="4" stroke-linecap="round"/>
|
|
<path d="M14 16C14 12 17 9 22 9H32C38 9 41 12 41 16C41 22 36 26 28 26H20C16 26 14 23 14 16Z" fill="#FFB21F"/>
|
|
<circle cx="21" cy="15.5" r="3.2" fill="white"/>
|
|
<circle cx="30.5" cy="14.5" r="3.6" fill="white"/>
|
|
<circle cx="21.5" cy="17" r="1.8" fill="#000"/>
|
|
<circle cx="30.8" cy="16" r="2.1" fill="#000"/>
|
|
<path d="M25 18C25.8 19.2 27.2 19.2 28 18V20.2C28 21.8 26.8 22.7 25.5 22.7C24.2 22.7 23 21.8 23 20.2V18C23.6 18.8 24.4 19.1 25 18Z" fill="#1B120F"/>
|
|
<path d="M12 12C13.5 9.2 17.8 8.2 21 10" stroke="#1B120F" stroke-width="2.8" stroke-linecap="round"/>
|
|
<path d="M29 7.5C31.8 6.2 35.6 7.1 37.6 9.7" stroke="#1B120F" stroke-width="2.8" stroke-linecap="round"/>
|
|
</svg>
|
|
|
|
<span class="hdr-title">EXO<em>MY</em> GROUND CTRL</span>
|
|
<div class="hdr-div"></div>
|
|
<div class="hdr-badge">Mission Ops</div>
|
|
<div class="hdr-gap"></div>
|
|
<a class="admin-link" href="./admin.html">Admin</a>
|
|
<div class="hdr-div"></div>
|
|
<div class="mission-ok"><div class="pulse-dot"></div><span id="cuno-connection-status">Cuno offline</span></div>
|
|
<div class="hdr-div"></div>
|
|
<div class="hdr-clocks">
|
|
<div class="hdr-clk">
|
|
<div class="hdr-clk-lbl">Session</div>
|
|
<div class="hdr-clk-val ora" id="met">00:00:00</div>
|
|
</div>
|
|
<div class="hdr-div"></div>
|
|
<div class="hdr-clk">
|
|
<div class="hdr-clk-lbl">UTC</div>
|
|
<div class="hdr-clk-val" id="utc">--:--:--</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="main">
|
|
<div class="col-l">
|
|
<div class="panel" id="cam-panel">
|
|
<div class="p-hdr">
|
|
<div class="p-dot"></div>
|
|
<span class="p-lbl">Kamera - CAM01</span>
|
|
<span class="p-tag">Live Stream</span>
|
|
</div>
|
|
<div class="cam-port">
|
|
<div class="cam-frame" id="video_frame">
|
|
<img id="video_output" alt="Kamerabild vom ExoMy" />
|
|
<div class="c-rec" id="cam-live-indicator"><div class="c-rec-dot"></div><span id="cam-live-text">LIVE</span></div>
|
|
<div class="c-tl">
|
|
CAM: <span id="cam-state">Verbinde…</span><br>
|
|
MODE: <span id="overlay-mode">Ackermann</span>
|
|
</div>
|
|
<div class="c-tr">
|
|
ROS: <span id="ros-state">Offline</span><br>
|
|
CTRL: <span id="input-state">Joystick</span>
|
|
</div>
|
|
<div class="c-bl">
|
|
GPS: <span id="gps-state">Offline</span><br>
|
|
POS: <span id="gps-accuracy">--</span><br>
|
|
LAT: <span id="gps-lat">--</span><br>
|
|
LON: <span id="gps-lon">--</span>
|
|
</div>
|
|
<div class="c-br">
|
|
HOST: <span id="cam-host">--</span><br>
|
|
STREAM: 8083
|
|
</div>
|
|
<div class="c-bm">
|
|
V: <span id="overlay-speed">0,0</span> cm/s<br>
|
|
S: <span id="overlay-distance">0,00</span> m
|
|
</div>
|
|
<svg class="c-track" id="track-overlay" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
<path id="track-center" class="track-center" d=""></path>
|
|
<path id="track-left" class="track-edge" d=""></path>
|
|
<path id="track-right" class="track-edge" d=""></path>
|
|
</svg>
|
|
<div class="c-xhair">
|
|
<svg viewBox="-90 -90 180 180" xmlns="http://www.w3.org/2000/svg" width="180" height="180">
|
|
<!-- äußerer rotierender Strichelring -->
|
|
<circle class="xh-rot" cx="0" cy="0" r="82" fill="none" stroke="rgba(0,255,145,.2)" stroke-width="1" stroke-dasharray="4 10"/>
|
|
<!-- mittlerer Ring, Gegenrichtung -->
|
|
<circle class="xh-rot-rev" cx="0" cy="0" r="60" fill="none" stroke="rgba(0,255,145,.15)" stroke-width=".75" stroke-dasharray="2 16"/>
|
|
<!-- innerer fester Ring -->
|
|
<circle cx="0" cy="0" r="16" fill="none" stroke="rgba(0,255,145,.3)" stroke-width=".75"/>
|
|
<!-- Kreuzlinien mit Lücke in der Mitte -->
|
|
<line x1="-86" y1="0" x2="-19" y2="0" stroke="rgba(0,255,145,.6)" stroke-width="1"/>
|
|
<line x1="19" y1="0" x2="86" y2="0" stroke="rgba(0,255,145,.6)" stroke-width="1"/>
|
|
<line x1="0" y1="-86" x2="0" y2="-19" stroke="rgba(0,255,145,.6)" stroke-width="1"/>
|
|
<line x1="0" y1="19" x2="0" y2="86" stroke="rgba(0,255,145,.6)" stroke-width="1"/>
|
|
<!-- Skalenstriche bei r=60 -->
|
|
<line x1="-60" y1="-6" x2="-60" y2="6" stroke="rgba(0,255,145,.45)" stroke-width=".75"/>
|
|
<line x1="60" y1="-6" x2="60" y2="6" stroke="rgba(0,255,145,.45)" stroke-width=".75"/>
|
|
<line x1="-6" y1="-60" x2="6" y2="-60" stroke="rgba(0,255,145,.45)" stroke-width=".75"/>
|
|
<line x1="-6" y1="60" x2="6" y2="60" stroke="rgba(0,255,145,.45)" stroke-width=".75"/>
|
|
<!-- Skalenstriche bei r=38 -->
|
|
<line x1="-38" y1="-3.5" x2="-38" y2="3.5" stroke="rgba(0,255,145,.3)" stroke-width=".5"/>
|
|
<line x1="38" y1="-3.5" x2="38" y2="3.5" stroke="rgba(0,255,145,.3)" stroke-width=".5"/>
|
|
<line x1="-3.5" y1="-38" x2="3.5" y2="-38" stroke="rgba(0,255,145,.3)" stroke-width=".5"/>
|
|
<line x1="-3.5" y1="38" x2="3.5" y2="38" stroke="rgba(0,255,145,.3)" stroke-width=".5"/>
|
|
<!-- Eckklammern -->
|
|
<path d="M-74,-58 L-74,-74 L-58,-74" fill="none" stroke="rgba(0,255,145,.75)" stroke-width="1.5"/>
|
|
<path d="M58,-74 L74,-74 L74,-58" fill="none" stroke="rgba(0,255,145,.75)" stroke-width="1.5"/>
|
|
<path d="M-74,58 L-74,74 L-58,74" fill="none" stroke="rgba(0,255,145,.75)" stroke-width="1.5"/>
|
|
<path d="M58,74 L74,74 L74,58" fill="none" stroke="rgba(0,255,145,.75)" stroke-width="1.5"/>
|
|
<!-- pulsierender Ring -->
|
|
<circle class="xh-pulse" cx="0" cy="0" r="6" fill="none" stroke="rgba(0,255,145,.7)" stroke-width="1"/>
|
|
<!-- Mittelpunkt -->
|
|
<circle cx="0" cy="0" r="2.5" fill="rgba(0,255,145,.9)"/>
|
|
<circle cx="0" cy="0" r="1" fill="rgba(255,255,255,.7)"/>
|
|
</svg>
|
|
</div>
|
|
<div class="c-scanlines"></div>
|
|
<div class="c-vignette"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-r">
|
|
<div class="panel collapsed" id="stat-panel">
|
|
<div class="p-hdr stat-panel-toggle" id="stat-panel-toggle" role="button" tabindex="0" aria-expanded="false" aria-controls="stat-panel-body">
|
|
<div class="p-dot"></div>
|
|
<span class="p-lbl">Systemstatus</span>
|
|
<span class="p-tag">Web Control</span>
|
|
<span class="stat-toggle-indicator" aria-hidden="true">+</span>
|
|
</div>
|
|
<div class="stat-list" id="stat-panel-body">
|
|
|
|
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Status</span><div class="ss-hdr-line"></div></div>
|
|
|
|
<div class="sr">
|
|
<span class="sr-led led-ok"></span>
|
|
<span class="sr-key">Link</span>
|
|
<span class="sr-val sval-conn"><span class="pulse-dot"></span><span id="status">Verbinde…</span></span>
|
|
</div>
|
|
<div class="sr">
|
|
<span class="sr-led led-blue"></span>
|
|
<span class="sr-key">Modus</span>
|
|
<span class="sr-val b" id="stat-mode">Ackermann</span>
|
|
</div>
|
|
<div class="sr">
|
|
<span class="sr-led led-warn"></span>
|
|
<span class="sr-key">Motoren</span>
|
|
<span class="sr-val o" id="stat-motors">Unbekannt</span>
|
|
</div>
|
|
<div class="sr">
|
|
<span class="sr-led led-blue"></span>
|
|
<span class="sr-key">Eingabe</span>
|
|
<span class="sr-val b" id="stat-input">Joystick</span>
|
|
</div>
|
|
|
|
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Telemetrie</span><div class="ss-hdr-line"></div></div>
|
|
|
|
<div class="sr sr-gauge gauge-cpu">
|
|
<span class="sr-key">CPU</span>
|
|
<div class="gauge"><div class="gauge-fill" id="bar-cpu"></div></div>
|
|
<span class="gauge-num" id="val-cpu">--</span>
|
|
</div>
|
|
<div class="sr sr-gauge gauge-temp">
|
|
<span class="sr-key">Temp</span>
|
|
<div class="gauge"><div class="gauge-fill" id="bar-temp"></div></div>
|
|
<span class="gauge-num" id="val-temp">--</span>
|
|
</div>
|
|
<div class="sr sr-gauge gauge-mem">
|
|
<span class="sr-key">RAM</span>
|
|
<div class="gauge"><div class="gauge-fill" id="bar-mem"></div></div>
|
|
<span class="gauge-num" id="val-mem">--</span>
|
|
</div>
|
|
|
|
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Netzwerk</span><div class="ss-hdr-line"></div></div>
|
|
|
|
<div class="sr">
|
|
<span class="sr-led led-ok"></span>
|
|
<span class="sr-key">WLAN</span>
|
|
<span class="sr-val b" id="stat-wifi">--</span>
|
|
</div>
|
|
<div class="sr">
|
|
<span class="sr-led led-blue"></span>
|
|
<span class="sr-key">IP</span>
|
|
<span class="sr-val b" id="stat-ips">--</span>
|
|
</div>
|
|
<div class="sr">
|
|
<span class="sr-led led-blue"></span>
|
|
<span class="sr-key">Uptime</span>
|
|
<span class="sr-val b" id="stat-uptime">--</span>
|
|
</div>
|
|
<div class="sr">
|
|
<span class="sr-led led-blue"></span>
|
|
<span class="sr-key">Disk</span>
|
|
<span class="sr-val b" id="stat-disk">--</span>
|
|
</div>
|
|
<div class="sr">
|
|
<span class="sr-led led-off"></span>
|
|
<span class="sr-key">Spannung</span>
|
|
<span class="sr-val b" id="stat-undervoltage">--</span>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel" id="joy-panel">
|
|
<div class="p-hdr">
|
|
<div class="p-dot"></div>
|
|
<span class="p-lbl">Lokomotion</span>
|
|
<div class="mode-toggle">
|
|
<button class="mtbtn active" id="mt-joy">Joystick</button>
|
|
<button class="mtbtn" id="mt-dpad">D-Pad</button>
|
|
</div>
|
|
</div>
|
|
<div class="drive-inline">
|
|
<button class="dbtn active" data-mode="ackermann" data-button="1">ACK</button>
|
|
<button class="dbtn" data-mode="turn-on-point" data-button="0">TOP</button>
|
|
<button class="dbtn" data-mode="crab" data-button="3">CRAB</button>
|
|
<button class="dbtn" id="motor-toggle">MOT</button>
|
|
</div>
|
|
|
|
<div class="joy-area" id="joy-area">
|
|
<div class="joy-base" id="jbase">
|
|
<div class="jg-h"></div><div class="jg-v"></div>
|
|
<div class="jring jr1"></div>
|
|
<div class="jring jr2"></div>
|
|
<div class="joy-thumb" id="jthumb"></div>
|
|
</div>
|
|
<div class="joy-vals">
|
|
<div class="joy-ax">
|
|
<div class="joy-ax-n" id="joy-x-label">X-Achse</div>
|
|
<div class="joy-ax-v" id="jx">+0.00</div>
|
|
</div>
|
|
<div class="joy-ax">
|
|
<div class="joy-ax-n">Y-Achse</div>
|
|
<div class="joy-ax-v" id="jy">+0.00</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dpad-wrap" id="dpad-area">
|
|
<div class="dpad-grid">
|
|
<button class="dpad-btn dpad-diag" data-x="-0.71" data-y="0.71">↖</button>
|
|
<button class="dpad-btn" data-x="0" data-y="1">▲</button>
|
|
<button class="dpad-btn dpad-diag" data-x="0.71" data-y="0.71">↗</button>
|
|
<button class="dpad-btn" data-x="-1" data-y="0">◄</button>
|
|
<div class="dpad-mid"></div>
|
|
<button class="dpad-btn" data-x="1" data-y="0">►</button>
|
|
<button class="dpad-btn dpad-diag" data-x="-0.71" data-y="-0.71">↙</button>
|
|
<button class="dpad-btn" data-x="0" data-y="-1">▼</button>
|
|
<button class="dpad-btn dpad-diag" data-x="0.71" data-y="-0.71">↘</button>
|
|
</div>
|
|
<div class="joy-vals">
|
|
<div class="joy-ax">
|
|
<div class="joy-ax-n" id="dpad-x-label">X-Achse</div>
|
|
<div class="joy-ax-v" id="dpx">+0.00</div>
|
|
</div>
|
|
<div class="joy-ax">
|
|
<div class="joy-ax-n">Y-Achse</div>
|
|
<div class="joy-ax-v" id="dpy">+0.00</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
var hostUrl = window.location.hostname;
|
|
var ros = null;
|
|
var joyListener = null;
|
|
var roverCommandListener = null;
|
|
var motorCommandsListener = null;
|
|
var gpsFixListener = null;
|
|
var gpsStatusListener = null;
|
|
var gpsDiagnosticsListener = null;
|
|
var rosReconnectTimer = null;
|
|
var rosDisconnectTimer = null;
|
|
var cameraReconnectTimer = null;
|
|
var cameraRecoveryTimers = [];
|
|
var lastCameraServiceState = "";
|
|
var lastCameraSettingsVersion = null;
|
|
var publishTimer = null;
|
|
var statusTimer = null;
|
|
var axes = [0, 0, 0, 0, 0, 0];
|
|
var buttons = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
|
var driveMode = "ackermann";
|
|
var motorsEnabled = true;
|
|
var inputMode = "joystick";
|
|
var metBase = Date.now();
|
|
var dragging = false;
|
|
var lastGpsDiagnostics = null;
|
|
var REQUIRED_CALIBRATION_POINTS = [0, 20, 30, 40, 50, 60, 80, 100];
|
|
var driveEstimator = {
|
|
drivePwmNeutralAverage: 307,
|
|
drivePwmRange: 100,
|
|
pwmFrequencyHz: 50,
|
|
speedMps: 0,
|
|
distanceM: 0,
|
|
lastTickMs: Date.now(),
|
|
motorsEnabled: false,
|
|
locomotionMode: 1,
|
|
motorSpeeds: [0, 0, 0, 0, 0, 0],
|
|
calibrationPoints: []
|
|
};
|
|
var trajectoryOverlay = {
|
|
steering: 90,
|
|
velocity: 0
|
|
};
|
|
var pageConnection = {
|
|
apiOk: false,
|
|
apiLastAt: 0,
|
|
rosOk: false,
|
|
rosLastAt: 0,
|
|
gpsLastAt: 0,
|
|
state: "offline"
|
|
};
|
|
|
|
function setText(id, text) {
|
|
var el = document.getElementById(id);
|
|
if (el) {
|
|
el.textContent = text;
|
|
}
|
|
}
|
|
|
|
function formatNumber(value, digits) {
|
|
return Number(value || 0).toLocaleString("de-DE", {
|
|
minimumFractionDigits: digits,
|
|
maximumFractionDigits: digits
|
|
});
|
|
}
|
|
|
|
function formatSpeedCms(speedMps, digits) {
|
|
return formatNumber(Number(speedMps || 0) * 100, digits);
|
|
}
|
|
|
|
function cloneCalibrationPoints(points) {
|
|
return (points || []).map(function (item) {
|
|
return {
|
|
percent: Number(item.percent),
|
|
speed_mps: Number(item.speed_mps)
|
|
};
|
|
});
|
|
}
|
|
|
|
function normalizeCalibrationPoints(points) {
|
|
var source = cloneCalibrationPoints(points).sort(function (a, b) {
|
|
return a.percent - b.percent;
|
|
});
|
|
if (!source.length) {
|
|
source = [{ percent: 0, speed_mps: 0 }];
|
|
}
|
|
|
|
return REQUIRED_CALIBRATION_POINTS.map(function (targetPercent) {
|
|
var exact = source.find(function (point) {
|
|
return Number(point.percent) === Number(targetPercent);
|
|
});
|
|
if (exact) {
|
|
return {
|
|
percent: targetPercent,
|
|
speed_mps: Number(exact.speed_mps || 0)
|
|
};
|
|
}
|
|
|
|
var lower = null;
|
|
var upper = null;
|
|
source.forEach(function (point) {
|
|
if (point.percent < targetPercent) lower = point;
|
|
if (!upper && point.percent > targetPercent) upper = point;
|
|
});
|
|
|
|
if (!lower && upper) {
|
|
return {
|
|
percent: targetPercent,
|
|
speed_mps: Number(upper.speed_mps || 0)
|
|
};
|
|
}
|
|
if (lower && !upper) {
|
|
return {
|
|
percent: targetPercent,
|
|
speed_mps: Number(lower.speed_mps || 0)
|
|
};
|
|
}
|
|
if (lower && upper) {
|
|
var span = Number(upper.percent) - Number(lower.percent);
|
|
var ratio = span <= 0 ? 0 : (targetPercent - Number(lower.percent)) / span;
|
|
return {
|
|
percent: targetPercent,
|
|
speed_mps: Number(lower.speed_mps || 0) + (Number(upper.speed_mps || 0) - Number(lower.speed_mps || 0)) * ratio
|
|
};
|
|
}
|
|
|
|
return {
|
|
percent: targetPercent,
|
|
speed_mps: 0
|
|
};
|
|
});
|
|
}
|
|
|
|
function interpolateCalibrationSpeed(percentMagnitude) {
|
|
var points = driveEstimator.calibrationPoints;
|
|
if (!points || !points.length) return 0;
|
|
var p = Math.max(0, Math.min(100, Math.abs(percentMagnitude)));
|
|
if (p <= points[0].percent) return Number(points[0].speed_mps || 0);
|
|
for (var i = 1; i < points.length; i += 1) {
|
|
var left = points[i - 1];
|
|
var right = points[i];
|
|
if (p <= right.percent) {
|
|
var span = right.percent - left.percent;
|
|
if (span <= 0) return Number(right.speed_mps || 0);
|
|
var ratio = (p - left.percent) / span;
|
|
return Number(left.speed_mps || 0) + (Number(right.speed_mps || 0) - Number(left.speed_mps || 0)) * ratio;
|
|
}
|
|
}
|
|
return Number(points[points.length - 1].speed_mps || 0);
|
|
}
|
|
|
|
function getAverageMotorSpeedPercent() {
|
|
if (!driveEstimator.motorSpeeds || !driveEstimator.motorSpeeds.length) return 0;
|
|
var sum = 0;
|
|
var count = 0;
|
|
driveEstimator.motorSpeeds.forEach(function (value) {
|
|
if (typeof value === "number" && !isNaN(value)) {
|
|
sum += value;
|
|
count += 1;
|
|
}
|
|
});
|
|
return count ? (sum / count) : 0;
|
|
}
|
|
|
|
function isTranslationMode() {
|
|
return driveEstimator.locomotionMode !== 2;
|
|
}
|
|
|
|
function updateDriveEstimatorModel() {
|
|
var nowMs = Date.now();
|
|
var dt = Math.max(0, (nowMs - driveEstimator.lastTickMs) / 1000.0);
|
|
driveEstimator.lastTickMs = nowMs;
|
|
|
|
var avgSpeedPercent = getAverageMotorSpeedPercent();
|
|
var direction = avgSpeedPercent > 0 ? 1 : (avgSpeedPercent < 0 ? -1 : 0);
|
|
var speedMagnitude = isTranslationMode() && driveEstimator.motorsEnabled
|
|
? interpolateCalibrationSpeed(avgSpeedPercent)
|
|
: 0;
|
|
var speedMps = direction === 0 ? 0 : speedMagnitude * direction;
|
|
|
|
if (Math.abs(speedMps) < 0.0005) speedMps = 0;
|
|
if (dt > 0 && dt < 2.5) driveEstimator.distanceM += Math.abs(speedMps) * dt;
|
|
driveEstimator.speedMps = speedMps;
|
|
}
|
|
|
|
function renderDriveEstimatorOverlay() {
|
|
if (!pageConnection.rosOk) {
|
|
setText("overlay-speed", "--");
|
|
setText("overlay-distance", "--");
|
|
return;
|
|
}
|
|
updateDriveEstimatorModel();
|
|
setText("overlay-speed", formatSpeedCms(driveEstimator.speedMps, 1));
|
|
setText("overlay-distance", formatNumber(driveEstimator.distanceM, 2));
|
|
}
|
|
|
|
function setPath(id, d) {
|
|
var node = document.getElementById(id);
|
|
if (node) node.setAttribute("d", d || "");
|
|
}
|
|
|
|
function renderTrajectoryOverlay() {
|
|
var overlay = document.getElementById("track-overlay");
|
|
if (!overlay) return;
|
|
|
|
var moving = driveEstimator.motorsEnabled && Math.abs(driveEstimator.speedMps) > 0.005;
|
|
overlay.style.opacity = moving ? "1" : "0.22";
|
|
var baseX = 50;
|
|
var baseY = 92;
|
|
var laneHalf = 9;
|
|
var topY = 18;
|
|
var leftStart = baseX - laneHalf;
|
|
var rightStart = baseX + laneHalf;
|
|
|
|
if (!moving) {
|
|
setPath("track-center", "M " + baseX + " " + baseY + " L " + baseX + " " + topY);
|
|
setPath("track-left", "M " + leftStart + " " + baseY + " L " + leftStart + " " + topY);
|
|
setPath("track-right", "M " + rightStart + " " + baseY + " L " + rightStart + " " + topY);
|
|
return;
|
|
}
|
|
|
|
var mode = driveEstimator.locomotionMode;
|
|
var steering = Number(trajectoryOverlay.steering || 90);
|
|
var direction = driveEstimator.speedMps < -0.005 ? -1 : 1;
|
|
|
|
if (mode === 3) {
|
|
var crabOffset = Math.max(-24, Math.min(24, (steering >= 0 ? steering - 90 : steering + 90) * 0.26));
|
|
setPath("track-center", "M " + baseX + " " + baseY + " L " + (baseX + crabOffset) + " " + topY);
|
|
setPath("track-left", "M " + (baseX - laneHalf) + " " + baseY + " L " + (baseX - laneHalf + crabOffset) + " " + topY);
|
|
setPath("track-right", "M " + (baseX + laneHalf) + " " + baseY + " L " + (baseX + laneHalf + crabOffset) + " " + topY);
|
|
return;
|
|
}
|
|
|
|
if (mode === 2) {
|
|
var pointTurnRight = steering > 95 || steering < -95;
|
|
var sweep = pointTurnRight ? 1 : 0;
|
|
var radiusOuter = 34;
|
|
var radiusInner = 20;
|
|
var centerX = pointTurnRight ? 78 : 22;
|
|
var startLeftX = pointTurnRight ? 40 : 24;
|
|
var startRightX = pointTurnRight ? 58 : 42;
|
|
setPath("track-center", "M " + baseX + " " + baseY + " A 27 27 0 0 " + sweep + " " + centerX + " 42");
|
|
setPath("track-left", "M " + startLeftX + " " + baseY + " A " + radiusOuter + " " + radiusOuter + " 0 0 " + sweep + " " + centerX + " 18");
|
|
setPath("track-right", "M " + startRightX + " " + baseY + " A " + radiusInner + " " + radiusInner + " 0 0 " + sweep + " " + centerX + " 34");
|
|
return;
|
|
}
|
|
|
|
var normalizedTurn;
|
|
if (steering > 90) {
|
|
normalizedTurn = Math.min(1, (steering - 90) / 90);
|
|
} else {
|
|
normalizedTurn = -Math.min(1, (90 - steering) / 90);
|
|
}
|
|
normalizedTurn *= direction;
|
|
|
|
var endX = baseX + normalizedTurn * 26;
|
|
var controlY = 58;
|
|
var controlBias = normalizedTurn * 18;
|
|
var centerPath = "M " + baseX + " " + baseY + " Q " + (baseX + controlBias * 0.6) + " " + controlY + " " + endX + " " + topY;
|
|
var leftPath = "M " + leftStart + " " + baseY + " Q " + (leftStart + controlBias) + " " + controlY + " " + (endX - laneHalf) + " " + topY;
|
|
var rightPath = "M " + rightStart + " " + baseY + " Q " + (rightStart + controlBias) + " " + controlY + " " + (endX + laneHalf) + " " + topY;
|
|
|
|
if (Math.abs(normalizedTurn) < 0.04) {
|
|
centerPath = "M " + baseX + " " + baseY + " L " + baseX + " " + topY;
|
|
leftPath = "M " + leftStart + " " + baseY + " L " + leftStart + " " + topY;
|
|
rightPath = "M " + rightStart + " " + baseY + " L " + rightStart + " " + topY;
|
|
}
|
|
|
|
setPath("track-center", centerPath);
|
|
setPath("track-left", leftPath);
|
|
setPath("track-right", rightPath);
|
|
}
|
|
|
|
function fetchDriveEstimatorData() {
|
|
fetch(window.location.protocol + "//" + hostUrl + ":8082/api/drive-estimator/config", {
|
|
cache: "no-store"
|
|
}).then(function (response) {
|
|
if (!response.ok) throw new Error("config");
|
|
return response.json();
|
|
}).then(function (payload) {
|
|
var estimator = payload.estimator || {};
|
|
driveEstimator.drivePwmNeutralAverage = Number(estimator.drive_pwm_neutral_average || driveEstimator.drivePwmNeutralAverage);
|
|
driveEstimator.drivePwmRange = Number(estimator.drive_pwm_range || driveEstimator.drivePwmRange);
|
|
driveEstimator.pwmFrequencyHz = Number(estimator.pwm_frequency_hz || driveEstimator.pwmFrequencyHz);
|
|
}).catch(function () {});
|
|
|
|
fetch(window.location.protocol + "//" + hostUrl + ":8082/api/drive-estimator/calibration", {
|
|
cache: "no-store"
|
|
}).then(function (response) {
|
|
if (!response.ok) throw new Error("calibration");
|
|
return response.json();
|
|
}).then(function (payload) {
|
|
var calibration = payload.calibration || {};
|
|
var points = Array.isArray(calibration.points) ? calibration.points : [];
|
|
driveEstimator.calibrationPoints = normalizeCalibrationPoints(points);
|
|
renderDriveEstimatorOverlay();
|
|
}).catch(function () {});
|
|
}
|
|
|
|
function formatAxis(value) {
|
|
return (value >= 0 ? "+" : "") + value.toFixed(2);
|
|
}
|
|
|
|
function axisBarWidth(value) {
|
|
return Math.max(0, Math.min(100, ((value + 1) / 2) * 100)).toFixed(0) + "%";
|
|
}
|
|
|
|
function percentTextToWidth(value) {
|
|
if (!value || typeof value !== "string") {
|
|
return "0%";
|
|
}
|
|
var numeric = parseInt(value.replace("%", ""), 10);
|
|
if (isNaN(numeric)) {
|
|
return "0%";
|
|
}
|
|
return Math.max(0, Math.min(100, numeric)) + "%";
|
|
}
|
|
|
|
function temperatureTextToWidth(value) {
|
|
if (!value || typeof value !== "string") {
|
|
return "0%";
|
|
}
|
|
var numeric = parseFloat(value.replace("°C", "").replace(",", "."));
|
|
if (isNaN(numeric)) {
|
|
return "0%";
|
|
}
|
|
return Math.max(0, Math.min(100, ((numeric - 20) / 60) * 100)).toFixed(0) + "%";
|
|
}
|
|
|
|
function updateAxesDisplay(x, y) {
|
|
setText("jx", formatAxis(x));
|
|
setText("jy", formatAxis(y));
|
|
setText("dpx", formatAxis(x));
|
|
setText("dpy", formatAxis(y));
|
|
}
|
|
|
|
function setGpsAccuracyDisplay(label) {
|
|
var node = document.getElementById("gps-accuracy");
|
|
if (!node) return;
|
|
|
|
node.classList.remove("gps-quality-good", "gps-quality-mid", "gps-quality-bad");
|
|
if (typeof label !== "string" || !label) {
|
|
node.textContent = "--";
|
|
return;
|
|
}
|
|
|
|
node.textContent = label;
|
|
}
|
|
|
|
function setGpsAccuracyState(label, tone) {
|
|
var node = document.getElementById("gps-accuracy");
|
|
if (!node) return;
|
|
|
|
node.classList.remove("gps-quality-good", "gps-quality-mid", "gps-quality-bad");
|
|
node.textContent = label || "--";
|
|
if (tone === "good") {
|
|
node.classList.add("gps-quality-good");
|
|
} else if (tone === "mid") {
|
|
node.classList.add("gps-quality-mid");
|
|
} else if (tone === "bad") {
|
|
node.classList.add("gps-quality-bad");
|
|
}
|
|
}
|
|
|
|
function setGpsSatelliteSummary(value, connected) {
|
|
if (connected && typeof value === "number" && isFinite(value) && value >= 0) {
|
|
setText("gps-state", String(Math.round(value)) + " Sat");
|
|
return;
|
|
}
|
|
setText("gps-state", "Offline");
|
|
}
|
|
|
|
function updateGpsAccuracyFromDiagnostics() {
|
|
if (!lastGpsDiagnostics || !lastGpsDiagnostics.connected || !lastGpsDiagnostics.valid) {
|
|
setGpsAccuracyState("--", "");
|
|
return;
|
|
}
|
|
|
|
var fixQuality = Number(lastGpsDiagnostics.fix_quality || 0);
|
|
var hdop = Number(lastGpsDiagnostics.hdop);
|
|
var pdop = Number(lastGpsDiagnostics.pdop);
|
|
var satellitesUsed = Number(lastGpsDiagnostics.satellites_used || 0);
|
|
var lastFixAge = Number(lastGpsDiagnostics.last_fix_age_s || 0);
|
|
|
|
if (fixQuality <= 0 || isNaN(hdop)) {
|
|
setGpsAccuracyState("schlecht", "bad");
|
|
return;
|
|
}
|
|
|
|
var label = "schlecht";
|
|
var tone = "bad";
|
|
|
|
if (hdop <= 1.2 && (isNaN(pdop) || pdop <= 2.0) && satellitesUsed >= 10 && lastFixAge <= 2.0) {
|
|
label = "sehr gut";
|
|
tone = "good";
|
|
} else if (hdop <= 2.5 && (isNaN(pdop) || pdop <= 4.0) && satellitesUsed >= 6 && lastFixAge <= 4.0) {
|
|
label = "gut";
|
|
tone = "good";
|
|
} else if (hdop <= 4.5 && (isNaN(pdop) || pdop <= 7.0) && satellitesUsed >= 4 && lastFixAge <= 8.0) {
|
|
label = "mäßig";
|
|
tone = "mid";
|
|
}
|
|
|
|
if (satellitesUsed < 5 || lastFixAge > 8.0) {
|
|
label = "schlecht";
|
|
tone = "bad";
|
|
}
|
|
|
|
setGpsAccuracyState(label, tone);
|
|
}
|
|
|
|
function updateGpsOverlayFromFix(message) {
|
|
if (!message) return;
|
|
markRosFresh();
|
|
markGpsFresh();
|
|
|
|
var hasFix = message.status && message.status.status >= 0;
|
|
if (!hasFix) {
|
|
setGpsAccuracyState("--", "");
|
|
setText("gps-lat", "--");
|
|
setText("gps-lon", "--");
|
|
return;
|
|
}
|
|
|
|
updateGpsAccuracyFromDiagnostics();
|
|
if (lastGpsDiagnostics && lastGpsDiagnostics.connected) {
|
|
setGpsSatelliteSummary(Number(lastGpsDiagnostics.satellites_used || lastGpsDiagnostics.satellites_visible || 0), true);
|
|
}
|
|
setText("gps-lat", formatNumber(Number(message.latitude || 0), 5));
|
|
setText("gps-lon", formatNumber(Number(message.longitude || 0), 5));
|
|
}
|
|
|
|
function updateGpsOverlayFromStatus(text) {
|
|
markRosFresh();
|
|
markGpsFresh();
|
|
var match = String(text || "").match(/(\d+)\s*Sat/i);
|
|
if (match) {
|
|
setGpsSatelliteSummary(Number(match[1]), true);
|
|
} else if (String(text || "").trim()) {
|
|
setGpsSatelliteSummary(0, true);
|
|
}
|
|
}
|
|
|
|
function updateGpsOverlayFromDiagnostics(text) {
|
|
markRosFresh();
|
|
markGpsFresh();
|
|
try {
|
|
lastGpsDiagnostics = JSON.parse(String(text || "{}"));
|
|
} catch (error) {
|
|
lastGpsDiagnostics = null;
|
|
}
|
|
if (lastGpsDiagnostics && lastGpsDiagnostics.connected) {
|
|
setGpsSatelliteSummary(Number(lastGpsDiagnostics.satellites_used || lastGpsDiagnostics.satellites_visible || 0), true);
|
|
}
|
|
updateGpsAccuracyFromDiagnostics();
|
|
}
|
|
|
|
function updateModeDisplay() {
|
|
var label = "Ackermann";
|
|
if (driveMode === "turn-on-point") {
|
|
label = "Turn on Point";
|
|
} else if (driveMode === "crab") {
|
|
label = "Crab";
|
|
}
|
|
setText("overlay-mode", label);
|
|
setText("stat-mode", label);
|
|
}
|
|
|
|
function applyDriveMode(mode, flash) {
|
|
var changed = mode !== driveMode;
|
|
driveMode = mode;
|
|
document.querySelectorAll(".dbtn[data-mode]").forEach(function (btn) {
|
|
var isActive = btn.dataset.mode === mode;
|
|
btn.classList.toggle("active", isActive);
|
|
if (isActive && changed && flash) {
|
|
btn.classList.remove("flash");
|
|
void btn.offsetWidth;
|
|
btn.classList.add("flash");
|
|
}
|
|
});
|
|
updateModeDisplay();
|
|
updateControlLocks();
|
|
}
|
|
|
|
function mirrorJoyThumb(x, y) {
|
|
if (dragging || inputMode !== "joystick") return;
|
|
var data = joyMetrics();
|
|
var dx = x * data.maxD;
|
|
var dy = -y * data.maxD;
|
|
var thumb = document.getElementById("jthumb");
|
|
thumb.style.transition = "left .05s, top .05s";
|
|
thumb.style.left = "calc(50% + " + dx + "px)";
|
|
thumb.style.top = "calc(50% + " + dy + "px)";
|
|
thumb.style.transform = "translate(-50%,-50%)";
|
|
updateAxesDisplay(x, y);
|
|
}
|
|
|
|
function locomotionModeToUi(modeValue) {
|
|
if (modeValue === 2) {
|
|
return "turn-on-point";
|
|
}
|
|
if (modeValue === 3) {
|
|
return "crab";
|
|
}
|
|
return "ackermann";
|
|
}
|
|
|
|
function updateMotorDisplay() {
|
|
setText("stat-motors", motorsEnabled ? "Aktiv" : "Aus");
|
|
}
|
|
|
|
function updateInputDisplay() {
|
|
var label = inputMode === "joystick" ? "Joystick" : "D-Pad";
|
|
setText("input-state", label);
|
|
setText("stat-input", label);
|
|
}
|
|
|
|
function setRosStatus(text) {
|
|
setText("status", text);
|
|
setText("ros-state", text);
|
|
}
|
|
|
|
function controlsAreBlocked() {
|
|
return pageConnection.state === "offline";
|
|
}
|
|
|
|
function updateControlAvailability() {
|
|
var panel = document.getElementById("joy-panel");
|
|
if (panel) {
|
|
panel.classList.toggle("is-disabled", controlsAreBlocked());
|
|
}
|
|
}
|
|
|
|
function clearSystemStatusDisplay() {
|
|
setText("stat-wifi", "--");
|
|
setText("stat-ips", "--");
|
|
updateUndervoltageDisplay("--");
|
|
setText("stat-uptime", "--");
|
|
setText("stat-disk", "--");
|
|
setText("val-cpu", "--");
|
|
setText("val-temp", "--");
|
|
setText("val-mem", "--");
|
|
document.getElementById("bar-cpu").style.width = "0%";
|
|
document.getElementById("bar-mem").style.width = "0%";
|
|
document.getElementById("bar-temp").style.width = "0%";
|
|
}
|
|
|
|
function clearRosLiveDisplay() {
|
|
setText("gps-state", "Offline");
|
|
setGpsAccuracyState("--", "");
|
|
setText("gps-lat", "--");
|
|
setText("gps-lon", "--");
|
|
setText("overlay-speed", "--");
|
|
setText("overlay-distance", "--");
|
|
}
|
|
|
|
function setPageConnectionState() {
|
|
var now = Date.now();
|
|
var apiFresh = pageConnection.apiOk && (now - pageConnection.apiLastAt) < 15000;
|
|
var rosSocketOpen = !!(ros && ros.socket && ros.socket.readyState === 1);
|
|
var rosConnected = rosSocketOpen || !!pageConnection.rosOk;
|
|
var state = "offline";
|
|
var cunoTitle = "Cuno offline";
|
|
|
|
if (apiFresh && rosConnected) {
|
|
state = "online";
|
|
} else if (apiFresh || rosConnected) {
|
|
state = "degraded";
|
|
}
|
|
|
|
cunoTitle = state === "online" ? "Cuno online" : (state === "degraded" ? "Cuno eingeschränkt" : "Cuno offline");
|
|
pageConnection.state = state;
|
|
document.body.classList.toggle("page-connection-online", state === "online");
|
|
document.body.classList.toggle("page-connection-degraded", state === "degraded");
|
|
document.body.classList.toggle("page-connection-offline", state === "offline");
|
|
setText("cuno-connection-status", cunoTitle);
|
|
updateControlAvailability();
|
|
|
|
if (!apiFresh) {
|
|
clearSystemStatusDisplay();
|
|
}
|
|
if (!rosConnected) {
|
|
clearRosLiveDisplay();
|
|
}
|
|
}
|
|
|
|
function markApiConnection(ok) {
|
|
pageConnection.apiOk = !!ok;
|
|
if (ok) {
|
|
pageConnection.apiLastAt = Date.now();
|
|
}
|
|
setPageConnectionState();
|
|
}
|
|
|
|
function markRosConnection(ok) {
|
|
pageConnection.rosOk = !!ok;
|
|
if (ok) {
|
|
pageConnection.rosLastAt = Date.now();
|
|
}
|
|
setPageConnectionState();
|
|
}
|
|
|
|
function markRosFresh() {
|
|
pageConnection.rosOk = true;
|
|
pageConnection.rosLastAt = Date.now();
|
|
setPageConnectionState();
|
|
}
|
|
|
|
function markGpsFresh() {
|
|
pageConnection.gpsLastAt = Date.now();
|
|
setPageConnectionState();
|
|
}
|
|
|
|
function scheduleRosReconnect() {
|
|
if (rosReconnectTimer) return;
|
|
rosReconnectTimer = window.setTimeout(function () {
|
|
rosReconnectTimer = null;
|
|
if (!ros || typeof ros.connect !== "function") return;
|
|
setRosStatus("Verbinde...");
|
|
try {
|
|
ros.connect("ws://" + hostUrl + ":9090");
|
|
} catch (error) {}
|
|
}, 3000);
|
|
}
|
|
|
|
function clearPendingRosDisconnect() {
|
|
if (!rosDisconnectTimer) return;
|
|
window.clearTimeout(rosDisconnectTimer);
|
|
rosDisconnectTimer = null;
|
|
}
|
|
|
|
function scheduleRosDisconnect() {
|
|
if (rosDisconnectTimer) return;
|
|
rosDisconnectTimer = window.setTimeout(function () {
|
|
rosDisconnectTimer = null;
|
|
var rosSocketOpen = !!(ros && ros.socket && ros.socket.readyState === 1);
|
|
if (rosSocketOpen) {
|
|
markRosConnection(true);
|
|
setRosStatus("Verbunden");
|
|
return;
|
|
}
|
|
markRosConnection(false);
|
|
setRosStatus("Getrennt");
|
|
}, 8000);
|
|
}
|
|
|
|
function refreshRosSocketState() {
|
|
if (!ros || !ros.socket) return;
|
|
if (ros.socket.readyState === 1) {
|
|
clearPendingRosDisconnect();
|
|
markRosConnection(true);
|
|
}
|
|
}
|
|
|
|
function clearCameraReconnect() {
|
|
if (!cameraReconnectTimer) return;
|
|
window.clearTimeout(cameraReconnectTimer);
|
|
cameraReconnectTimer = null;
|
|
}
|
|
|
|
function clearCameraRecoveryTimers() {
|
|
cameraRecoveryTimers.forEach(function (timerId) {
|
|
window.clearTimeout(timerId);
|
|
});
|
|
cameraRecoveryTimers = [];
|
|
}
|
|
|
|
function reloadCameraStream() {
|
|
var video = document.getElementById("video_output");
|
|
if (!video) return;
|
|
video.src = "//" + hostUrl + ":8083/stream.mjpg";
|
|
}
|
|
|
|
function scheduleCameraReconnect(delayMs) {
|
|
if (cameraReconnectTimer) return;
|
|
cameraReconnectTimer = window.setTimeout(function () {
|
|
cameraReconnectTimer = null;
|
|
reloadCameraStream();
|
|
}, delayMs || 2500);
|
|
}
|
|
|
|
function triggerCameraRecovery() {
|
|
clearCameraReconnect();
|
|
clearCameraRecoveryTimers();
|
|
[1200, 2600, 4200, 6500].forEach(function (delayMs) {
|
|
cameraRecoveryTimers.push(window.setTimeout(function () {
|
|
reloadCameraStream();
|
|
}, delayMs));
|
|
});
|
|
}
|
|
|
|
function updateUndervoltageDisplay(text, state) {
|
|
var el = document.getElementById("stat-undervoltage");
|
|
if (!el) {
|
|
return;
|
|
}
|
|
el.textContent = text || "--";
|
|
el.classList.remove("b", "g", "o", "alert", "warntext");
|
|
|
|
if (state === "active") {
|
|
el.classList.add("alert");
|
|
return;
|
|
}
|
|
if (state === "past") {
|
|
el.classList.add("warntext");
|
|
return;
|
|
}
|
|
if (state === "clear") {
|
|
el.classList.add("g");
|
|
return;
|
|
}
|
|
el.classList.add("b");
|
|
}
|
|
|
|
function updateServiceState(payload) {
|
|
var system = payload.system || {};
|
|
var services = payload.services || {};
|
|
var cameraServiceState = String(services.camera || "");
|
|
var cameraSettingsVersion = Number(services.camera_settings_version || 0);
|
|
setText("stat-wifi", system.wifi || "unbekannt");
|
|
setText("stat-ips", system.ips || "unbekannt");
|
|
updateUndervoltageDisplay(system.undervoltage || "unbekannt", system.undervoltage_state || "unknown");
|
|
setText("stat-uptime", system.uptime || "unbekannt");
|
|
setText("stat-disk", system.disk_free || "unbekannt");
|
|
setText("val-cpu", system.cpu_usage || "--");
|
|
setText("val-temp", system.cpu_temperature || "--");
|
|
setText("val-mem", system.memory_usage || "--");
|
|
document.getElementById("bar-cpu").style.width = percentTextToWidth(system.cpu_usage);
|
|
document.getElementById("bar-mem").style.width = percentTextToWidth(system.memory_usage);
|
|
document.getElementById("bar-temp").style.width = temperatureTextToWidth(system.cpu_temperature);
|
|
if (cameraServiceState === "active" && lastCameraServiceState && lastCameraServiceState !== "active") {
|
|
setText("cam-state", "Verbinde...");
|
|
triggerCameraRecovery();
|
|
}
|
|
if (lastCameraSettingsVersion !== null && cameraSettingsVersion !== lastCameraSettingsVersion) {
|
|
setText("cam-state", "Verbinde...");
|
|
triggerCameraRecovery();
|
|
}
|
|
lastCameraServiceState = cameraServiceState;
|
|
lastCameraSettingsVersion = cameraSettingsVersion;
|
|
setText("cam-state", cameraServiceState === "active" ? "Online" : cameraServiceState || "unbekannt");
|
|
}
|
|
|
|
function fetchSystemStatus() {
|
|
fetch(window.location.protocol + "//" + hostUrl + ":8082/api/status", {
|
|
cache: "no-store"
|
|
}).then(function (response) {
|
|
if (!response.ok) {
|
|
throw new Error("status");
|
|
}
|
|
return response.json();
|
|
}).then(function (payload) {
|
|
markApiConnection(true);
|
|
updateServiceState(payload);
|
|
}).catch(function () {
|
|
markApiConnection(false);
|
|
setText("stat-wifi", "nicht erreichbar");
|
|
});
|
|
}
|
|
|
|
function publishJoy() {
|
|
if (!joyListener || controlsAreBlocked()) {
|
|
return;
|
|
}
|
|
joyListener.publish(new ROSLIB.Message({
|
|
header: {
|
|
frame_id: "webgui"
|
|
},
|
|
axes: axes,
|
|
buttons: buttons
|
|
}));
|
|
}
|
|
|
|
function startPublishing() {
|
|
if (publishTimer) {
|
|
return;
|
|
}
|
|
publishJoy();
|
|
publishTimer = window.setInterval(publishJoy, 50);
|
|
}
|
|
|
|
function stopPublishing() {
|
|
if (publishTimer) {
|
|
clearInterval(publishTimer);
|
|
publishTimer = null;
|
|
}
|
|
}
|
|
|
|
function setAxes(x, y) {
|
|
axes = [x, y, 0, 0, 0, 0];
|
|
updateAxesDisplay(x, y);
|
|
}
|
|
|
|
function resetAxes() {
|
|
setAxes(0, 0);
|
|
publishJoy();
|
|
}
|
|
|
|
function pulseButton(buttonIndex) {
|
|
if (controlsAreBlocked()) {
|
|
return;
|
|
}
|
|
axes = [0, 0, 0, 0, 0, 0];
|
|
updateAxesDisplay(0, 0);
|
|
buttons[buttonIndex] = 1;
|
|
publishJoy();
|
|
window.setTimeout(function () {
|
|
buttons[buttonIndex] = 0;
|
|
publishJoy();
|
|
}, 120);
|
|
}
|
|
|
|
function selectDriveMode(mode, buttonIndex) {
|
|
if (controlsAreBlocked()) {
|
|
return;
|
|
}
|
|
applyDriveMode(mode);
|
|
pulseButton(buttonIndex);
|
|
}
|
|
|
|
function toggleMotors() {
|
|
if (controlsAreBlocked()) {
|
|
return;
|
|
}
|
|
motorsEnabled = !motorsEnabled;
|
|
updateMotorDisplay();
|
|
pulseButton(9);
|
|
}
|
|
|
|
function updateControlLocks() {
|
|
var xOnly = driveMode === "turn-on-point";
|
|
setText("joy-x-label", driveMode === "turn-on-point" ? "Rotation" : "X-Achse");
|
|
setText("dpad-x-label", driveMode === "turn-on-point" ? "Rotation" : "X-Achse");
|
|
document.querySelectorAll("#joy-area .joy-ax")[1].style.opacity = xOnly ? "0.25" : "1";
|
|
document.querySelectorAll("#dpad-area .joy-ax")[1].style.opacity = xOnly ? "0.25" : "1";
|
|
document.querySelectorAll(".dpad-btn").forEach(function (btn) {
|
|
var x = parseFloat(btn.dataset.x || "0");
|
|
var y = parseFloat(btn.dataset.y || "0");
|
|
var disable = xOnly && y !== 0;
|
|
btn.disabled = disable;
|
|
btn.style.opacity = disable ? "0.15" : "";
|
|
btn.style.pointerEvents = disable ? "none" : "";
|
|
});
|
|
}
|
|
|
|
function updateClocks() {
|
|
var now = new Date();
|
|
setText("utc", now.toUTCString().split(" ")[4]);
|
|
var s = Math.floor((Date.now() - metBase) / 1000);
|
|
var h = String(Math.floor(s / 3600)).padStart(2, "0");
|
|
var m = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
|
|
var sec = String(s % 60).padStart(2, "0");
|
|
setText("met", h + ":" + m + ":" + sec);
|
|
}
|
|
|
|
function joyMetrics() {
|
|
var rect = document.getElementById("jbase").getBoundingClientRect();
|
|
var baseR = Math.min(rect.width, rect.height) / 2;
|
|
var thumbR = baseR * 0.33;
|
|
return {
|
|
cx: rect.left + rect.width / 2,
|
|
cy: rect.top + rect.height / 2,
|
|
maxD: baseR - thumbR
|
|
};
|
|
}
|
|
|
|
function moveThumb(clientX, clientY) {
|
|
if (controlsAreBlocked()) {
|
|
return;
|
|
}
|
|
var data = joyMetrics();
|
|
var dx = clientX - data.cx;
|
|
var dy = clientY - data.cy;
|
|
if (driveMode === "turn-on-point" || driveMode === "crab") {
|
|
dy = 0;
|
|
}
|
|
var distance = Math.sqrt(dx * dx + dy * dy);
|
|
if (distance > data.maxD && distance > 0) {
|
|
dx = dx / distance * data.maxD;
|
|
dy = dy / distance * data.maxD;
|
|
}
|
|
var thumb = document.getElementById("jthumb");
|
|
thumb.style.left = "calc(50% + " + dx + "px)";
|
|
thumb.style.top = "calc(50% + " + dy + "px)";
|
|
thumb.style.transform = "translate(-50%,-50%)";
|
|
thumb.style.transition = "none";
|
|
var nx = dx / data.maxD;
|
|
var ny = -dy / data.maxD;
|
|
setAxes(nx, ny);
|
|
publishJoy();
|
|
startPublishing();
|
|
}
|
|
|
|
function resetThumb() {
|
|
var thumb = document.getElementById("jthumb");
|
|
thumb.style.transition = "left .22s cubic-bezier(.25,.46,.45,.94), top .22s cubic-bezier(.25,.46,.45,.94)";
|
|
thumb.style.left = "50%";
|
|
thumb.style.top = "50%";
|
|
thumb.style.transform = "translate(-50%,-50%)";
|
|
stopPublishing();
|
|
resetAxes();
|
|
}
|
|
|
|
function setDpadPressed(button, x, y) {
|
|
if (controlsAreBlocked()) {
|
|
return;
|
|
}
|
|
document.querySelectorAll(".dpad-btn").forEach(function (btn) {
|
|
btn.classList.remove("pressed");
|
|
});
|
|
button.classList.add("pressed");
|
|
setAxes(x, y);
|
|
startPublishing();
|
|
}
|
|
|
|
function clearDpad() {
|
|
document.querySelectorAll(".dpad-btn").forEach(function (btn) {
|
|
btn.classList.remove("pressed");
|
|
});
|
|
stopPublishing();
|
|
resetAxes();
|
|
}
|
|
|
|
function updateCameraFrameLayout() {
|
|
var port = document.querySelector(".cam-port");
|
|
var frame = document.getElementById("video_frame");
|
|
var video = document.getElementById("video_output");
|
|
if (!port || !frame) return;
|
|
|
|
var portWidth = port.clientWidth;
|
|
var portHeight = port.clientHeight;
|
|
if (!portWidth || !portHeight) return;
|
|
|
|
var videoWidth = (video && video.naturalWidth) || 1296;
|
|
var videoHeight = (video && video.naturalHeight) || 972;
|
|
var aspectRatio = videoWidth / videoHeight;
|
|
|
|
var frameWidth = portWidth;
|
|
var frameHeight = frameWidth / aspectRatio;
|
|
if (frameHeight > portHeight) {
|
|
frameHeight = portHeight;
|
|
frameWidth = frameHeight * aspectRatio;
|
|
}
|
|
|
|
frame.style.width = frameWidth + "px";
|
|
frame.style.height = frameHeight + "px";
|
|
frame.style.left = ((portWidth - frameWidth) / 2) + "px";
|
|
frame.style.top = ((portHeight - frameHeight) / 2) + "px";
|
|
frame.style.setProperty("--cam-scale", Math.max(0.55, Math.min(1.1, frameWidth / 640)).toFixed(3));
|
|
}
|
|
|
|
function setStatPanelCollapsed(collapsed, immediate) {
|
|
var panel = document.getElementById("stat-panel");
|
|
var toggle = document.getElementById("stat-panel-toggle");
|
|
var body = document.getElementById("stat-panel-body");
|
|
if (!panel || !toggle || !body) return;
|
|
var isCollapsed = panel.classList.contains("collapsed");
|
|
if (isCollapsed === collapsed && !immediate) return;
|
|
|
|
body.style.overflow = "hidden";
|
|
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
|
var indicator = toggle.querySelector(".stat-toggle-indicator");
|
|
if (indicator) {
|
|
indicator.textContent = collapsed ? "+" : "-";
|
|
}
|
|
|
|
if (immediate) {
|
|
panel.classList.toggle("collapsed", collapsed);
|
|
body.style.height = collapsed ? "0px" : "auto";
|
|
return;
|
|
}
|
|
|
|
if (collapsed) {
|
|
body.style.height = body.scrollHeight + "px";
|
|
body.offsetHeight;
|
|
panel.classList.add("collapsed");
|
|
body.style.height = "0px";
|
|
} else {
|
|
panel.classList.remove("collapsed");
|
|
body.style.height = "0px";
|
|
body.offsetHeight;
|
|
body.style.height = body.scrollHeight + "px";
|
|
}
|
|
}
|
|
|
|
function toggleStatPanel() {
|
|
var panel = document.getElementById("stat-panel");
|
|
if (!panel) return;
|
|
setStatPanelCollapsed(!panel.classList.contains("collapsed"));
|
|
}
|
|
|
|
window.addEventListener("load", function () {
|
|
setText("cam-host", hostUrl);
|
|
updateModeDisplay();
|
|
updateMotorDisplay();
|
|
updateInputDisplay();
|
|
updateAxesDisplay(0, 0);
|
|
updateControlLocks();
|
|
updateClocks();
|
|
window.setInterval(updateClocks, 1000);
|
|
fetchSystemStatus();
|
|
refreshRosSocketState();
|
|
setPageConnectionState();
|
|
fetchDriveEstimatorData();
|
|
renderDriveEstimatorOverlay();
|
|
renderTrajectoryOverlay();
|
|
statusTimer = window.setInterval(fetchSystemStatus, 5000);
|
|
window.setInterval(refreshRosSocketState, 1000);
|
|
window.setInterval(setPageConnectionState, 1000);
|
|
window.setInterval(renderDriveEstimatorOverlay, 250);
|
|
window.setInterval(renderTrajectoryOverlay, 250);
|
|
|
|
var video = document.getElementById("video_output");
|
|
reloadCameraStream();
|
|
video.addEventListener("load", function () {
|
|
clearCameraReconnect();
|
|
clearCameraRecoveryTimers();
|
|
setText("cam-state", "Online");
|
|
setText("cam-host", hostUrl);
|
|
setText("cam-live-text", "LIVE");
|
|
var liveIndicator = document.getElementById("cam-live-indicator");
|
|
if (liveIndicator) {
|
|
liveIndicator.classList.remove("is-offline");
|
|
}
|
|
updateCameraFrameLayout();
|
|
});
|
|
video.addEventListener("error", function () {
|
|
setText("cam-state", "Fehler");
|
|
setText("cam-live-text", "OFFLINE");
|
|
var liveIndicator = document.getElementById("cam-live-indicator");
|
|
if (liveIndicator) {
|
|
liveIndicator.classList.add("is-offline");
|
|
}
|
|
scheduleCameraReconnect(2500);
|
|
});
|
|
updateCameraFrameLayout();
|
|
window.addEventListener("resize", updateCameraFrameLayout);
|
|
window.addEventListener("orientationchange", function () {
|
|
window.setTimeout(updateCameraFrameLayout, 80);
|
|
});
|
|
var statBody = document.getElementById("stat-panel-body");
|
|
if (statBody) {
|
|
statBody.addEventListener("transitionend", function (event) {
|
|
if (event.propertyName !== "height") return;
|
|
var panel = document.getElementById("stat-panel");
|
|
if (!panel) return;
|
|
statBody.style.height = panel.classList.contains("collapsed") ? "0px" : "auto";
|
|
});
|
|
}
|
|
setStatPanelCollapsed(true, true);
|
|
var statToggle = document.getElementById("stat-panel-toggle");
|
|
if (statToggle) {
|
|
statToggle.addEventListener("click", toggleStatPanel);
|
|
statToggle.addEventListener("keydown", function (event) {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
toggleStatPanel();
|
|
}
|
|
});
|
|
}
|
|
|
|
ros = new ROSLIB.Ros({
|
|
url: "ws://" + hostUrl + ":9090"
|
|
});
|
|
|
|
ros.on("connection", function () {
|
|
if (rosReconnectTimer) {
|
|
window.clearTimeout(rosReconnectTimer);
|
|
rosReconnectTimer = null;
|
|
}
|
|
clearPendingRosDisconnect();
|
|
markRosConnection(true);
|
|
setRosStatus("Verbunden");
|
|
});
|
|
|
|
ros.on("error", function () {
|
|
setRosStatus("Verbinde...");
|
|
scheduleRosDisconnect();
|
|
scheduleRosReconnect();
|
|
});
|
|
|
|
ros.on("close", function () {
|
|
setRosStatus("Verbinde...");
|
|
scheduleRosDisconnect();
|
|
scheduleRosReconnect();
|
|
});
|
|
|
|
joyListener = new ROSLIB.Topic({
|
|
ros: ros,
|
|
name: "/joy",
|
|
messageType: "sensor_msgs/Joy"
|
|
});
|
|
|
|
roverCommandListener = new ROSLIB.Topic({
|
|
ros: ros,
|
|
name: "/rover_command",
|
|
messageType: "exomy/RoverCommand"
|
|
});
|
|
|
|
roverCommandListener.subscribe(function (message) {
|
|
clearPendingRosDisconnect();
|
|
markRosFresh();
|
|
setRosStatus("Verbunden");
|
|
applyDriveMode(locomotionModeToUi(message.locomotion_mode), true);
|
|
motorsEnabled = !!message.motors_enabled;
|
|
driveEstimator.motorsEnabled = !!message.motors_enabled;
|
|
driveEstimator.locomotionMode = Number(message.locomotion_mode);
|
|
trajectoryOverlay.steering = Number(message.steering || 90);
|
|
trajectoryOverlay.velocity = Number(message.vel || 0);
|
|
updateMotorDisplay();
|
|
renderDriveEstimatorOverlay();
|
|
renderTrajectoryOverlay();
|
|
});
|
|
|
|
motorCommandsListener = new ROSLIB.Topic({
|
|
ros: ros,
|
|
name: "/motor_commands",
|
|
messageType: "exomy/MotorCommands"
|
|
});
|
|
motorCommandsListener.subscribe(function (message) {
|
|
clearPendingRosDisconnect();
|
|
markRosFresh();
|
|
setRosStatus("Verbunden");
|
|
driveEstimator.motorSpeeds = Array.isArray(message.motor_speeds) ? message.motor_speeds.map(Number) : [0, 0, 0, 0, 0, 0];
|
|
renderDriveEstimatorOverlay();
|
|
renderTrajectoryOverlay();
|
|
});
|
|
|
|
gpsFixListener = new ROSLIB.Topic({
|
|
ros: ros,
|
|
name: "/fix",
|
|
messageType: "sensor_msgs/NavSatFix"
|
|
});
|
|
gpsFixListener.subscribe(updateGpsOverlayFromFix);
|
|
|
|
gpsStatusListener = new ROSLIB.Topic({
|
|
ros: ros,
|
|
name: "/gps/status_text",
|
|
messageType: "std_msgs/String"
|
|
});
|
|
gpsStatusListener.subscribe(function (message) {
|
|
updateGpsOverlayFromStatus(message.data);
|
|
});
|
|
|
|
gpsDiagnosticsListener = new ROSLIB.Topic({
|
|
ros: ros,
|
|
name: "/gps/diagnostics_json",
|
|
messageType: "std_msgs/String"
|
|
});
|
|
gpsDiagnosticsListener.subscribe(function (message) {
|
|
updateGpsOverlayFromDiagnostics(message.data);
|
|
});
|
|
|
|
var joySubscriber = new ROSLIB.Topic({
|
|
ros: ros,
|
|
name: "/joy",
|
|
messageType: "sensor_msgs/Joy"
|
|
});
|
|
joySubscriber.subscribe(function (message) {
|
|
clearPendingRosDisconnect();
|
|
markRosConnection(true);
|
|
if (message.header.frame_id === "webgui") return;
|
|
var x = (message.axes[0] || 0);
|
|
var y = -(message.axes[1] || 0);
|
|
mirrorJoyThumb(x, y);
|
|
});
|
|
|
|
document.querySelectorAll(".dbtn[data-mode]").forEach(function (button) {
|
|
button.addEventListener("click", function () {
|
|
selectDriveMode(button.dataset.mode, parseInt(button.dataset.button, 10));
|
|
});
|
|
});
|
|
|
|
document.getElementById("motor-toggle").addEventListener("click", toggleMotors);
|
|
|
|
document.getElementById("mt-joy").addEventListener("click", function () {
|
|
if (controlsAreBlocked()) return;
|
|
inputMode = "joystick";
|
|
updateInputDisplay();
|
|
document.getElementById("mt-joy").classList.add("active");
|
|
document.getElementById("mt-dpad").classList.remove("active");
|
|
document.getElementById("joy-area").style.display = "";
|
|
document.getElementById("dpad-area").classList.remove("visible");
|
|
clearDpad();
|
|
resetThumb();
|
|
});
|
|
|
|
document.getElementById("mt-dpad").addEventListener("click", function () {
|
|
if (controlsAreBlocked()) return;
|
|
inputMode = "dpad";
|
|
updateInputDisplay();
|
|
document.getElementById("mt-dpad").classList.add("active");
|
|
document.getElementById("mt-joy").classList.remove("active");
|
|
document.getElementById("joy-area").style.display = "none";
|
|
document.getElementById("dpad-area").classList.add("visible");
|
|
resetThumb();
|
|
clearDpad();
|
|
});
|
|
|
|
var joyBase = document.getElementById("jbase");
|
|
joyBase.addEventListener("mousedown", function (event) {
|
|
if (controlsAreBlocked()) return;
|
|
dragging = true;
|
|
moveThumb(event.clientX, event.clientY);
|
|
event.preventDefault();
|
|
});
|
|
document.addEventListener("mousemove", function (event) {
|
|
if (dragging) {
|
|
moveThumb(event.clientX, event.clientY);
|
|
}
|
|
});
|
|
document.addEventListener("mouseup", function () {
|
|
if (dragging) {
|
|
dragging = false;
|
|
resetThumb();
|
|
}
|
|
});
|
|
joyBase.addEventListener("touchstart", function (event) {
|
|
if (controlsAreBlocked()) return;
|
|
dragging = true;
|
|
moveThumb(event.touches[0].clientX, event.touches[0].clientY);
|
|
event.preventDefault();
|
|
}, { passive: false });
|
|
document.addEventListener("touchmove", function (event) {
|
|
if (dragging) {
|
|
moveThumb(event.touches[0].clientX, event.touches[0].clientY);
|
|
event.preventDefault();
|
|
}
|
|
}, { passive: false });
|
|
document.addEventListener("touchend", function () {
|
|
if (dragging) {
|
|
dragging = false;
|
|
resetThumb();
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll(".dpad-btn").forEach(function (button) {
|
|
function activate(event) {
|
|
if (controlsAreBlocked()) return;
|
|
var x = parseFloat(button.dataset.x);
|
|
var y = parseFloat(button.dataset.y);
|
|
setDpadPressed(button, x, y);
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
function deactivate() {
|
|
clearDpad();
|
|
}
|
|
button.addEventListener("mousedown", activate);
|
|
button.addEventListener("mouseup", deactivate);
|
|
button.addEventListener("mouseleave", deactivate);
|
|
button.addEventListener("touchstart", activate, { passive: false });
|
|
button.addEventListener("touchend", deactivate);
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|