1598 lines
52 KiB
HTML
1598 lines
52 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" title="CUNO = Celestian Unified Navigation and Observation">CU<em>NO</em> GROUND CTRL</span>
|
|
<div class="hdr-div"></div>
|
|
<div class="hdr-badge">Mission Ops</div>
|
|
<div class="hdr-gap"></div>
|
|
<button class="header-icon-btn" id="save-frame-button" type="button" aria-label="Screenshot speichern" title="Screenshot speichern">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M7 4h7l2 2h3a2 2 0 0 1 2 2v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V8a2 2 0 0 1 2-2h2V4z"></path>
|
|
<path d="M12 9.2a4.3 4.3 0 1 0 0 8.6a4.3 4.3 0 0 0 0-8.6z"></path>
|
|
<circle cx="18" cy="9" r="1.2"></circle>
|
|
</svg>
|
|
</button>
|
|
<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>
|
|
<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>
|
|
</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 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 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() {
|
|
updateUndervoltageDisplay("--");
|
|
}
|
|
|
|
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);
|
|
updateUndervoltageDisplay(system.undervoltage || "unbekannt", system.undervoltage_state || "unknown");
|
|
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);
|
|
});
|
|
}
|
|
|
|
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 loadImageFromBlob(blob) {
|
|
return new Promise(function (resolve, reject) {
|
|
var image = new Image();
|
|
var objectUrl = URL.createObjectURL(blob);
|
|
image.onload = function () {
|
|
URL.revokeObjectURL(objectUrl);
|
|
resolve(image);
|
|
};
|
|
image.onerror = function () {
|
|
URL.revokeObjectURL(objectUrl);
|
|
reject(new Error("image"));
|
|
};
|
|
image.src = objectUrl;
|
|
});
|
|
}
|
|
|
|
function drawOverlayTextBlock(context, lines, x, y, align, lineHeight, color) {
|
|
context.save();
|
|
context.textAlign = align || "left";
|
|
context.textBaseline = "top";
|
|
context.fillStyle = color;
|
|
for (var i = 0; i < lines.length; i += 1) {
|
|
context.fillText(lines[i], x, y + (i * lineHeight));
|
|
}
|
|
context.restore();
|
|
}
|
|
|
|
function drawCrosshairOverlayToCanvas(context, canvasWidth, canvasHeight, size) {
|
|
var cx = canvasWidth / 2;
|
|
var cy = canvasHeight / 2;
|
|
var unit = size / 180;
|
|
|
|
function circle(radius, stroke, lineWidth, dash) {
|
|
context.save();
|
|
context.beginPath();
|
|
context.arc(cx, cy, radius * unit, 0, Math.PI * 2);
|
|
if (dash) context.setLineDash(dash.map(function (value) { return value * unit; }));
|
|
context.strokeStyle = stroke;
|
|
context.lineWidth = lineWidth * unit;
|
|
context.stroke();
|
|
context.restore();
|
|
}
|
|
|
|
function line(x1, y1, x2, y2, stroke, lineWidth) {
|
|
context.save();
|
|
context.beginPath();
|
|
context.moveTo(cx + (x1 * unit), cy + (y1 * unit));
|
|
context.lineTo(cx + (x2 * unit), cy + (y2 * unit));
|
|
context.strokeStyle = stroke;
|
|
context.lineWidth = lineWidth * unit;
|
|
context.stroke();
|
|
context.restore();
|
|
}
|
|
|
|
function path(points, stroke, lineWidth) {
|
|
context.save();
|
|
context.beginPath();
|
|
context.moveTo(cx + (points[0][0] * unit), cy + (points[0][1] * unit));
|
|
for (var i = 1; i < points.length; i += 1) {
|
|
context.lineTo(cx + (points[i][0] * unit), cy + (points[i][1] * unit));
|
|
}
|
|
context.strokeStyle = stroke;
|
|
context.lineWidth = lineWidth * unit;
|
|
context.stroke();
|
|
context.restore();
|
|
}
|
|
|
|
circle(82, "rgba(0,255,145,.2)", 1, [4, 10]);
|
|
circle(60, "rgba(0,255,145,.15)", 0.75, [2, 16]);
|
|
circle(16, "rgba(0,255,145,.3)", 0.75);
|
|
line(-86, 0, -19, 0, "rgba(0,255,145,.6)", 1);
|
|
line(19, 0, 86, 0, "rgba(0,255,145,.6)", 1);
|
|
line(0, -86, 0, -19, "rgba(0,255,145,.6)", 1);
|
|
line(0, 19, 0, 86, "rgba(0,255,145,.6)", 1);
|
|
line(-60, -6, -60, 6, "rgba(0,255,145,.45)", 0.75);
|
|
line(60, -6, 60, 6, "rgba(0,255,145,.45)", 0.75);
|
|
line(-6, -60, 6, -60, "rgba(0,255,145,.45)", 0.75);
|
|
line(-6, 60, 6, 60, "rgba(0,255,145,.45)", 0.75);
|
|
line(-38, -3.5, -38, 3.5, "rgba(0,255,145,.3)", 0.5);
|
|
line(38, -3.5, 38, 3.5, "rgba(0,255,145,.3)", 0.5);
|
|
line(-3.5, -38, 3.5, -38, "rgba(0,255,145,.3)", 0.5);
|
|
line(-3.5, 38, 3.5, 38, "rgba(0,255,145,.3)", 0.5);
|
|
path([[-74, -58], [-74, -74], [-58, -74]], "rgba(0,255,145,.75)", 1.5);
|
|
path([[58, -74], [74, -74], [74, -58]], "rgba(0,255,145,.75)", 1.5);
|
|
path([[-74, 58], [-74, 74], [-58, 74]], "rgba(0,255,145,.75)", 1.5);
|
|
path([[58, 74], [74, 74], [74, 58]], "rgba(0,255,145,.75)", 1.5);
|
|
circle(6, "rgba(0,255,145,.7)", 1);
|
|
|
|
context.save();
|
|
context.beginPath();
|
|
context.arc(cx, cy, 2.5 * unit, 0, Math.PI * 2);
|
|
context.fillStyle = "rgba(0,255,145,.9)";
|
|
context.fill();
|
|
context.beginPath();
|
|
context.arc(cx, cy, 1 * unit, 0, Math.PI * 2);
|
|
context.fillStyle = "rgba(255,255,255,.7)";
|
|
context.fill();
|
|
context.restore();
|
|
}
|
|
|
|
function drawVignetteAndScanlines(context, canvasWidth, canvasHeight) {
|
|
context.save();
|
|
for (var y = 0; y < canvasHeight; y += 4) {
|
|
context.fillStyle = "rgba(0,255,145,.012)";
|
|
context.fillRect(0, y, canvasWidth, 1);
|
|
}
|
|
var vignette = context.createRadialGradient(
|
|
canvasWidth * 0.5, canvasHeight * 0.5, canvasWidth * 0.25,
|
|
canvasWidth * 0.5, canvasHeight * 0.5, canvasWidth * 0.75
|
|
);
|
|
vignette.addColorStop(0, "rgba(0,0,0,0)");
|
|
vignette.addColorStop(1, "rgba(0,0,0,.58)");
|
|
context.fillStyle = vignette;
|
|
context.fillRect(0, 0, canvasWidth, canvasHeight);
|
|
context.restore();
|
|
}
|
|
|
|
function drawHudOverlayToCanvas(context, canvasWidth, canvasHeight) {
|
|
var frame = document.getElementById("video_frame");
|
|
var scaleValue = frame ? parseFloat(getComputedStyle(frame).getPropertyValue("--cam-scale")) : 1;
|
|
var camScale = isFinite(scaleValue) && scaleValue > 0 ? scaleValue : 1;
|
|
var frameWidth = frame ? frame.clientWidth : canvasWidth;
|
|
var scaleToCanvas = frameWidth ? (canvasWidth / frameWidth) : 1;
|
|
var padding = 13 * camScale * scaleToCanvas;
|
|
var fontSize = 9 * camScale * scaleToCanvas;
|
|
var lineHeight = fontSize * 1.9;
|
|
var hudColor = "rgba(0,255,145,.72)";
|
|
|
|
context.save();
|
|
context.font = fontSize + 'px "IBM Plex Mono", "Roboto Mono", monospace';
|
|
context.fillStyle = hudColor;
|
|
|
|
drawOverlayTextBlock(context, [
|
|
"CAM: " + document.getElementById("cam-state").textContent,
|
|
"MODE: " + document.getElementById("overlay-mode").textContent
|
|
], padding, padding, "left", lineHeight, hudColor);
|
|
|
|
drawOverlayTextBlock(context, [
|
|
"ROS: " + document.getElementById("ros-state").textContent,
|
|
"CTRL: " + document.getElementById("input-state").textContent
|
|
], canvasWidth - padding, padding, "right", lineHeight, hudColor);
|
|
|
|
drawOverlayTextBlock(context, [
|
|
"GPS: " + document.getElementById("gps-state").textContent,
|
|
"POS: " + document.getElementById("gps-accuracy").textContent,
|
|
"LAT: " + document.getElementById("gps-lat").textContent,
|
|
"LON: " + document.getElementById("gps-lon").textContent
|
|
], padding, canvasHeight - padding - (lineHeight * 4), "left", lineHeight, hudColor);
|
|
|
|
drawOverlayTextBlock(context, [
|
|
"HOST: " + document.getElementById("cam-host").textContent,
|
|
"STREAM: 8083"
|
|
], canvasWidth - padding, canvasHeight - padding - (lineHeight * 2), "right", lineHeight, hudColor);
|
|
|
|
drawOverlayTextBlock(context, [
|
|
"V: " + document.getElementById("overlay-speed").textContent + " cm/s",
|
|
"S: " + document.getElementById("overlay-distance").textContent + " m"
|
|
], canvasWidth / 2, canvasHeight - padding - (lineHeight * 2), "center", lineHeight, hudColor);
|
|
|
|
var recY = padding + (fontSize * 0.55);
|
|
context.beginPath();
|
|
context.arc((canvasWidth / 2) - (18 * camScale * scaleToCanvas), recY, 3 * camScale * scaleToCanvas, 0, Math.PI * 2);
|
|
context.fillStyle = document.getElementById("cam-live-indicator").classList.contains("is-offline")
|
|
? "rgba(170,255,220,.72)"
|
|
: "#FF3333";
|
|
context.fill();
|
|
context.fillStyle = hudColor;
|
|
context.textAlign = "center";
|
|
context.fillText(document.getElementById("cam-live-text").textContent, canvasWidth / 2 + (6 * camScale * scaleToCanvas), padding);
|
|
context.restore();
|
|
|
|
drawCrosshairOverlayToCanvas(context, canvasWidth, canvasHeight, 180 * camScale * scaleToCanvas);
|
|
drawVignetteAndScanlines(context, canvasWidth, canvasHeight);
|
|
}
|
|
|
|
async function saveCurrentFrame() {
|
|
try {
|
|
var response = await fetch("//" + hostUrl + ":8083/snapshot.jpg?t=" + Date.now(), {
|
|
cache: "no-store"
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error("snapshot");
|
|
}
|
|
var blob = await response.blob();
|
|
var image = await loadImageFromBlob(blob);
|
|
var canvas = document.createElement("canvas");
|
|
canvas.width = image.naturalWidth || image.width;
|
|
canvas.height = image.naturalHeight || image.height;
|
|
var context = canvas.getContext("2d");
|
|
if (!context) {
|
|
throw new Error("canvas");
|
|
}
|
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
drawHudOverlayToCanvas(context, canvas.width, canvas.height);
|
|
var link = document.createElement("a");
|
|
var timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
var objectUrl = canvas.toDataURL("image/jpeg", 0.95);
|
|
link.href = objectUrl;
|
|
link.download = "exomy-" + timestamp + ".jpg";
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
} catch (error) {}
|
|
}
|
|
|
|
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();
|
|
statusTimer = window.setInterval(fetchSystemStatus, 5000);
|
|
window.setInterval(refreshRosSocketState, 1000);
|
|
window.setInterval(setPageConnectionState, 1000);
|
|
window.setInterval(renderDriveEstimatorOverlay, 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);
|
|
updateMotorDisplay();
|
|
renderDriveEstimatorOverlay();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
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("save-frame-button").addEventListener("click", saveCurrentFrame);
|
|
|
|
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>
|
|
|