Screenshot rein und Uhrzeit aus GPS

This commit is contained in:
2026-05-25 18:13:47 +02:00
parent 11d1a16281
commit 92c51b81ce
6 changed files with 551 additions and 202 deletions
+2
View File
@@ -491,6 +491,7 @@
<div class="act-grid act-grid_single">
<button class="act-btn danger full" onclick="runAdminAction('cold-start-gps')">GPS-Kaltstart</button>
<button class="act-btn warm full" onclick="runAdminAction('restart-gps')">GPS neu starten</button>
<button class="act-btn subtle full" onclick="runAdminAction('set-time-from-gps')">Pi-Zeit aus GPS setzen</button>
</div>
<div class="sr"><span class="sr-led led-ok"></span><span class="sr-key">Status</span><span id="gps_diag_status" class="sr-val">Offline</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Fix-Qualität</span><span id="gps_diag_fix_quality" class="sr-val">--</span></div>
@@ -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?',
+29 -27
View File
@@ -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;
+214 -87
View File
@@ -35,6 +35,13 @@
<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>
@@ -86,11 +93,6 @@
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 -->
@@ -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 () {
@@ -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'
@@ -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
+214 -87
View File
@@ -35,6 +35,13 @@
<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>
@@ -86,11 +93,6 @@
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 -->
@@ -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 () {