Screenshot rein und Uhrzeit aus GPS
This commit is contained in:
@@ -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?',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user