diff --git a/ExoMy_Software-master/gui/admin.html b/ExoMy_Software-master/gui/admin.html index 181d321..5011fea 100644 --- a/ExoMy_Software-master/gui/admin.html +++ b/ExoMy_Software-master/gui/admin.html @@ -491,6 +491,7 @@
+
StatusOffline
Fix-Qualität--
@@ -2284,6 +2285,7 @@ var labels = { 'cold-start-gps': 'GPS-Kaltstart jetzt wirklich auslösen? Danach dauert der neue Fix deutlich länger.', 'restart-gps': 'GPS-Node jetzt wirklich neu starten?', + 'set-time-from-gps': 'Pi-Zeit jetzt wirklich aus den aktuellen GPS-Daten setzen?', 'restart-camera': 'Kameradienst jetzt wirklich neu starten?', 'restart-container': 'ExoMy-Container jetzt wirklich neu starten?', 'reboot': 'Raspberry Pi jetzt wirklich neu starten?', diff --git a/ExoMy_Software-master/gui/app.css b/ExoMy_Software-master/gui/app.css index 3f24b4b..fd85f45 100644 --- a/ExoMy_Software-master/gui/app.css +++ b/ExoMy_Software-master/gui/app.css @@ -398,33 +398,6 @@ body.page-connection-degraded .admin-shell{ opacity:.9; } /* ── STAT PANEL ─────────────────────────────────────── */ -.c-track { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - z-index: 9; - pointer-events: none; - opacity: .92; - transition: opacity .18s ease; - filter: drop-shadow(0 0 8px rgba(0,255,145,.18)); -} -.track-edge, -.track-center { - fill: none; - stroke-linecap: round; - stroke-linejoin: round; -} -.track-edge { - stroke: rgba(170,255,225,.88); - stroke-width: 1.35; - stroke-dasharray: 6 5; -} -.track-center { - stroke: rgba(0,255,145,.38); - stroke-width: .9; - stroke-dasharray: 3 6; -} .stat-list { display: flex; flex-direction: column; @@ -757,6 +730,31 @@ body.page-connection-degraded .admin-shell{ background: linear-gradient(180deg, rgba(18,110,76,.96), rgba(6,36,26,.98)); box-shadow: inset 0 0 16px rgba(170,255,220,.1), 0 0 16px rgba(120,255,205,.18); } +.header-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid rgba(120,255,205,.28); + background: linear-gradient(180deg, rgba(10,70,48,.82), rgba(4,22,16,.96)); + box-shadow: inset 0 0 12px rgba(170,255,220,.06), 0 0 10px rgba(120,255,205,.08); + color: rgba(245,255,250,.98); + cursor: pointer; + transition: all .16s ease; +} +.header-icon-btn:hover { + color: #ffffff; + border-color: rgba(180,255,225,.72); + background: linear-gradient(180deg, rgba(18,110,76,.96), rgba(6,36,26,.98)); + box-shadow: inset 0 0 16px rgba(170,255,220,.1), 0 0 16px rgba(120,255,205,.18); +} +.header-icon-btn svg { + width: 16px; + height: 16px; + fill: currentColor; +} @media (max-width: 767px), (hover: none) and (pointer: coarse) and (max-width: 767px) { body { overflow-y: auto; } @@ -788,6 +786,10 @@ body.page-connection-degraded .admin-shell{ .hdr-div { display: none; } + .header-icon-btn { + width: 28px; + height: 28px; + } .main { grid-template-columns: minmax(0, 1.2fr) minmax(260px, 0.8fr); gap: 8px; diff --git a/ExoMy_Software-master/gui/index.html b/ExoMy_Software-master/gui/index.html index 0ddc9ad..df7cae1 100644 --- a/ExoMy_Software-master/gui/index.html +++ b/ExoMy_Software-master/gui/index.html @@ -35,6 +35,13 @@
Mission Ops
+ Admin
Cuno offline
@@ -86,11 +93,6 @@ V: 0,0 cm/s
S: 0,00 m -
@@ -315,10 +317,6 @@ var driveEstimator = { motorSpeeds: [0, 0, 0, 0, 0, 0], calibrationPoints: [] }; -var trajectoryOverlay = { - steering: 90, - velocity: 0 -}; var pageConnection = { apiOk: false, apiLastAt: 0, @@ -477,78 +475,6 @@ function setPath(id, d) { 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" @@ -1096,6 +1022,211 @@ function selectDriveMode(mode, buttonIndex) { 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; @@ -1281,12 +1412,10 @@ window.addEventListener("load", function () { 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(); @@ -1383,11 +1512,8 @@ window.addEventListener("load", function () { 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({ @@ -1401,7 +1527,6 @@ window.addEventListener("load", function () { 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({ @@ -1449,6 +1574,8 @@ window.addEventListener("load", function () { }); }); + document.getElementById("save-frame-button").addEventListener("click", saveCurrentFrame); + document.getElementById("motor-toggle").addEventListener("click", toggleMotors); document.getElementById("mt-joy").addEventListener("click", function () { diff --git a/ExoMy_Software-master/scripts/exomy_admin_api.py b/ExoMy_Software-master/scripts/exomy_admin_api.py index a7c07e4..1cae1a4 100644 --- a/ExoMy_Software-master/scripts/exomy_admin_api.py +++ b/ExoMy_Software-master/scripts/exomy_admin_api.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import datetime import errno import fcntl import glob @@ -12,6 +13,7 @@ import subprocess import termios import threading import time +from zoneinfo import ZoneInfo PORT = 8082 EXOMY_CONTAINER = 'exomy_autostart' @@ -606,6 +608,74 @@ def cold_start_gps_receiver(): return device_path +def read_gps_diagnostics_from_ros(): + command = [ + 'docker', 'exec', EXOMY_CONTAINER, 'bash', '-lc', + "source /opt/ros/melodic/setup.bash && " + "source /root/exomy_ws/devel/setup.bash && " + "timeout 8s rostopic echo -n 1 /gps/diagnostics_json" + ] + stdout, stderr, returncode = run_command(command) + if returncode != 0 or not stdout: + raise RuntimeError('Keine GPS-Diagnosedaten verfügbar') + + raw_text = stdout.strip() + lines = [line.strip() for line in raw_text.splitlines() if line.strip()] + if not lines: + raise RuntimeError('GPS-Diagnosedaten sind leer') + + if lines[0].startswith('data:'): + payload_text = lines[0][5:].strip() + if payload_text.startswith("'") and payload_text.endswith("'"): + payload_text = payload_text[1:-1] + payload_text = payload_text.encode('utf-8').decode('unicode_escape') + else: + payload_text = lines[0] + + try: + return json.loads(payload_text) + except json.JSONDecodeError as exc: + raise RuntimeError(f'GPS-Diagnosedaten konnten nicht gelesen werden: {exc}') + + +def parse_gps_datetime(): + diagnostics = read_gps_diagnostics_from_ros() + utc_date = str(diagnostics.get('utc_date') or '').strip() + utc_time = str(diagnostics.get('utc_time') or '').strip() + if not utc_date or not utc_time: + raise RuntimeError('GPS liefert aktuell keine UTC-Zeit') + + combined = f'{utc_date} {utc_time}' + for pattern in ('%Y-%m-%d %H:%M:%S.%f', '%Y-%m-%d %H:%M:%S'): + try: + return datetime.datetime.strptime(combined, pattern) + except ValueError: + continue + raise RuntimeError(f'GPS-Zeitformat unbekannt: {combined}') + + +def get_local_timezone(): + try: + with open('/etc/timezone', 'r', encoding='utf-8') as handle: + timezone_name = handle.read().strip() + if timezone_name: + return ZoneInfo(timezone_name) + except (FileNotFoundError, OSError, ValueError): + pass + return datetime.datetime.now().astimezone().tzinfo or datetime.timezone.utc + + +def set_system_time_from_gps(): + gps_dt = parse_gps_datetime() + gps_utc = gps_dt.replace(tzinfo=datetime.timezone.utc) + local_time = gps_utc.astimezone(get_local_timezone()) + time_text = local_time.strftime('%Y-%m-%d %H:%M:%S') + stdout, stderr, returncode = run_command(['timedatectl', 'set-time', time_text]) + if returncode != 0: + raise RuntimeError(stderr or stdout or 'Systemzeit konnte nicht gesetzt werden') + return time_text + + def stop_exomy_container(): stdout, _, returncode = run_command(['docker', 'stop', '-t', '2', EXOMY_CONTAINER]) return returncode == 0, stdout @@ -1420,6 +1490,10 @@ class Handler(http.server.BaseHTTPRequestHandler): 'handler': restart_gps_node, 'status': 'GPS-Node wird neu gestartet' }, + '/api/set-time-from-gps': { + 'handler': set_system_time_from_gps, + 'status': 'Pi-Zeit wird aus GPS gesetzt' + }, '/api/restart-camera': { 'handler': restart_camera_service, 'status': 'Kameradienst wird neu gestartet' diff --git a/ExoMy_Software-master/scripts/video_delay_proxy.py b/ExoMy_Software-master/scripts/video_delay_proxy.py index 8f06123..630a702 100644 --- a/ExoMy_Software-master/scripts/video_delay_proxy.py +++ b/ExoMy_Software-master/scripts/video_delay_proxy.py @@ -98,13 +98,30 @@ class Handler(http.server.BaseHTTPRequestHandler): return urllib.parse.urlsplit(self.path).path def do_GET(self): - if self.normalized_path() != '/stream.mjpg': + path = self.normalized_path() + if path == '/snapshot.jpg': + frame = get_delayed_frame() + if frame is None: + self.send_error(503) + return + self.send_response(200) + self.send_header('Cache-Control', 'no-cache, private') + self.send_header('Pragma', 'no-cache') + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Content-Type', 'image/jpeg') + self.send_header('Content-Length', str(len(frame))) + self.end_headers() + self.wfile.write(frame) + return + + if path != '/stream.mjpg': self.send_error(404) return self.send_response(200) self.send_header('Age', '0') self.send_header('Cache-Control', 'no-cache, private') self.send_header('Pragma', 'no-cache') + self.send_header('Access-Control-Allow-Origin', '*') self.send_header('Content-Type', 'multipart/x-mixed-replace; boundary=frame') self.end_headers() fps = 10 diff --git a/index_upload_tmp.html b/index_upload_tmp.html index df16a2f..4820f21 100644 --- a/index_upload_tmp.html +++ b/index_upload_tmp.html @@ -35,6 +35,13 @@
Mission Ops
+ Admin
Cuno offline
@@ -86,11 +93,6 @@ V: 0,0 cm/s
S: 0,00 m
-
@@ -315,10 +317,6 @@ var driveEstimator = { motorSpeeds: [0, 0, 0, 0, 0, 0], calibrationPoints: [] }; -var trajectoryOverlay = { - steering: 90, - velocity: 0 -}; var pageConnection = { apiOk: false, apiLastAt: 0, @@ -477,78 +475,6 @@ function setPath(id, d) { 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" @@ -1096,6 +1022,211 @@ function selectDriveMode(mode, buttonIndex) { 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; @@ -1281,12 +1412,10 @@ window.addEventListener("load", function () { 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(); @@ -1383,11 +1512,8 @@ window.addEventListener("load", function () { 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({ @@ -1401,7 +1527,6 @@ window.addEventListener("load", function () { 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({ @@ -1449,6 +1574,8 @@ window.addEventListener("load", function () { }); }); + document.getElementById("save-frame-button").addEventListener("click", saveCurrentFrame); + document.getElementById("motor-toggle").addEventListener("click", toggleMotors); document.getElementById("mt-joy").addEventListener("click", function () {