Files
ExoMy_Cuno/ExoMy_Software-master/gui/admin.html
T
2026-05-25 15:30:56 +02:00

2814 lines
118 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CUNO // Admin</title>
<link rel="stylesheet" href="./app.css?v=20260525132000">
<link rel="stylesheet" href="./vendor/leaflet/leaflet.css">
<script src="./vendor/roslib.min.js"></script>
<script src="./vendor/leaflet/leaflet.js"></script>
</head>
<body class="page-admin">
<header>
<svg class="hdr-icon" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<ellipse cx="14" cy="51" rx="6" ry="7" fill="#FFB21F"/>
<ellipse cx="28" cy="50" rx="6" ry="8" fill="#FFB21F"/>
<ellipse cx="43" cy="50" rx="6" ry="8" fill="#FFB21F"/>
<ellipse cx="55" cy="49" rx="5" ry="7" fill="#FFB21F"/>
<path d="M11 36L23 30H50L58 32L47 37H14L11 36Z" fill="#1F43C9"/>
<rect x="24" y="14" width="7" height="23" rx="3.5" fill="#1B120F"/>
<path d="M18 38L25 36L41 36L51 40L51 46L42 49L38 59L31 59L29 50L19 47L18 38Z" fill="#1B120F"/>
<rect x="21" y="38" width="5" height="10" rx="2.5" fill="#1B120F"/>
<rect x="43" y="39" width="5" height="11" rx="2.5" fill="#1B120F"/>
<path d="M15 41L9 44L8 49" stroke="#1B120F" stroke-width="4" stroke-linecap="round"/>
<path d="M49 41L57 43L57 47" stroke="#1B120F" stroke-width="4" stroke-linecap="round"/>
<path d="M14 16C14 12 17 9 22 9H32C38 9 41 12 41 16C41 22 36 26 28 26H20C16 26 14 23 14 16Z" fill="#FFB21F"/>
<circle cx="21" cy="15.5" r="3.2" fill="white"/>
<circle cx="30.5" cy="14.5" r="3.6" fill="white"/>
<circle cx="21.5" cy="17" r="1.8" fill="#000"/>
<circle cx="30.8" cy="16" r="2.1" fill="#000"/>
<path d="M25 18C25.8 19.2 27.2 19.2 28 18V20.2C28 21.8 26.8 22.7 25.5 22.7C24.2 22.7 23 21.8 23 20.2V18C23.6 18.8 24.4 19.1 25 18Z" fill="#1B120F"/>
<path d="M12 12C13.5 9.2 17.8 8.2 21 10" stroke="#1B120F" stroke-width="2.8" stroke-linecap="round"/>
<path d="M29 7.5C31.8 6.2 35.6 7.1 37.6 9.7" stroke="#1B120F" stroke-width="2.8" stroke-linecap="round"/>
</svg>
<span class="hdr-title">CUNO&nbsp;<em>ADMIN</em></span>
<div class="hdr-div"></div>
<div class="hdr-badge">System Control</div>
<div class="hdr-gap"></div>
<span id="admin_status" class="hdr-status">Lade...</span>
<div class="hdr-div"></div>
<div class="mission-ok"><div class="pulse-dot"></div><span id="page-connection-label">Prüfe…</span></div>
<div class="hdr-div"></div>
<a class="hdr-back" href="./index.html">← Ground Control</a>
<div class="hdr-div"></div>
<div class="hdr-clk">
<div class="hdr-clk-lbl">UTC</div>
<div class="hdr-clk-val" id="utc-clock">--:--:--</div>
</div>
</header>
<div id="connection-banner" class="connection-banner" hidden>
<strong id="connection-banner-title">Verbindung wird geprüft</strong>
<span id="connection-banner-detail">Warte auf ROS und Admin-API.</span>
</div>
<div class="admin-shell">
<div class="admin-section-tabs" role="tablist" aria-label="Admin-Bereiche">
<button id="admin_tab_system" class="admin-section-tab is_active" role="tab" aria-selected="true" onclick="switchAdminSection('system')">Admin</button>
<button id="admin_tab_camera" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('camera')">Kamera</button>
<button id="admin_tab_drive" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('drive')">Geschwindigkeit / Weg</button>
<button id="admin_tab_map" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('map')">Karte</button>
<button id="admin_tab_gps" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('gps')">GPS</button>
<button id="admin_tab_motor" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('motor')">Motorentest</button>
</div>
<section id="admin_panel_system" class="admin-section-panel is_active" role="tabpanel">
<div class="admin-section-grid">
<div class="admin-col">
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Aktionen</span>
<span class="p-tag">System Control</span>
</div>
<div class="act-grid">
<button class="act-btn warm" onclick="runAdminAction('restart-camera')">Kamera neu starten</button>
<button class="act-btn danger" onclick="runAdminAction('restart-container')">Container neu starten</button>
<button class="act-btn warm" onclick="runAdminAction('reboot')">Pi neu starten</button>
<button class="act-btn danger" onclick="runAdminAction('shutdown')">Pi herunterfahren</button>
<button class="act-btn subtle full" onclick="runRumble()">Controller vibrieren</button>
</div>
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Dienste</span><div class="ss-hdr-line"></div></div>
<div class="sr">
<span class="sr-led led-ok"></span>
<span class="sr-key">Kamera</span>
<strong id="service_camera" class="sr-val service_state state_unknown">-</strong>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Admin API</span>
<strong id="service_admin_api" class="sr-val service_state state_unknown">-</strong>
</div>
<div class="sr">
<span class="sr-led led-ok"></span>
<span class="sr-key">ExoMy</span>
<strong id="service_exomy" class="sr-val service_state state_unknown">-</strong>
</div>
</div>
</div>
<div class="admin-col">
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Latenz-Simulation</span>
<span class="p-tag">Signal Delay</span>
</div>
<div class="dropdown-row">
<label>Delay</label>
<select id="delay_select" class="ctrl-select" onchange="setDelay(parseFloat(this.value))">
<option value="0">0 s – kein Delay</option>
<option value="1">1 s</option>
<option value="2">2 s</option>
<option value="3">3 s</option>
<option value="4">4 s</option>
<option value="5">5 s</option>
<option value="6">6 s</option>
<option value="7">7 s</option>
<option value="8">8 s</option>
<option value="9">9 s</option>
<option value="10">10 s</option>
</select>
<span id="delay_status" class="ctrl-status"></span>
</div>
</div>
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Geschwindigkeit</span>
<span class="p-tag">Speed Limit</span>
</div>
<div class="dropdown-row">
<label>Limit</label>
<select id="speed_limit_select" class="ctrl-select" onchange="setSpeedLimit(parseInt(this.value, 10))">
<option value="10">10 %</option>
<option value="20">20 %</option>
<option value="30">30 %</option>
<option value="40">40 %</option>
<option value="50">50 %</option>
<option value="60">60 %</option>
<option value="70">70 %</option>
<option value="80">80 %</option>
<option value="90">90 %</option>
<option value="100" selected>100 % – kein Limit</option>
</select>
<span id="speed_limit_status" class="ctrl-status"></span>
</div>
</div>
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Systemstatus</span>
<span class="p-tag">Telemetry</span>
</div>
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Netzwerk</span><div class="ss-hdr-line"></div></div>
<div class="sr">
<span class="sr-led led-ok"></span>
<span class="sr-key">WLAN</span>
<span class="sr-val" id="info_wifi">-</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">IP</span>
<span class="sr-val" id="info_ips">-</span>
</div>
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Hardware</span><div class="ss-hdr-line"></div></div>
<div class="sr sr-gauge gauge-temp">
<span class="sr-key">Temp</span>
<div class="gauge"><div class="gauge-fill" id="bar-temp-adm"></div></div>
<span class="gauge-num" id="info_temp">--</span>
</div>
<div class="sr">
<span class="sr-led led-off"></span>
<span class="sr-key">Spannung</span>
<span class="sr-val" id="info_undervoltage">-</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Uptime</span>
<span class="sr-val" id="info_uptime">-</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Disk frei</span>
<span class="sr-val" id="info_disk">-</span>
</div>
</div>
</div>
</div>
</section>
<section id="admin_panel_camera" class="admin-section-panel" role="tabpanel">
<div class="admin-section-grid">
<div class="admin-col">
<div class="panel camera-panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Kamera-Vorschau</span>
<span class="p-tag">Live Stream</span>
</div>
<div class="copy-text camera-copy">
Die Vorschau kommt direkt vom Kameradienst auf Port <code>8081</code>. Nach einem Profilwechsel startet der Dienst kurz neu, das Bild setzt dann automatisch wieder ein.
</div>
<div class="camera-preview-stage">
<img id="camera_preview_image" class="camera-preview-image" alt="Kamera-Vorschau" src="" loading="eager">
</div>
<div class="camera-preview-meta">
<div class="sr">
<span class="sr-led led-ok"></span>
<span class="sr-key">Profil</span>
<span id="camera_profile_label" class="sr-val">--</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Auflösung</span>
<span id="camera_resolution_label" class="sr-val">--</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Bildrate</span>
<span id="camera_fps_label" class="sr-val">--</span>
</div>
</div>
</div>
</div>
<div class="admin-col">
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Stream-Aufl&ouml;sung</span>
<span class="p-tag">Konfiguration</span>
</div>
<div class="dropdown-row">
<label>Profil</label>
<select id="camera_profile_select" class="ctrl-select" onchange="setCameraProfile(this.value)">
<option value="640x480">640 x 480 &middot; 4:3 &middot; gr&ouml;&szlig;tes Sichtfeld</option>
<option value="1024x768">1024 x 768 &middot; 4:3 &middot; mehr Details</option>
<option value="1296x972">1296 x 972 &middot; 4:3 &middot; noch mehr Details</option>
</select>
<span id="camera_profile_status" class="ctrl-status"></span>
</div>
<div class="copy-text camera-copy">
Die neue Aufl&ouml;sung wird gespeichert und der Kameradienst automatisch neu gestartet. Der Videostream ist dabei f&uuml;r einen kurzen Moment unterbrochen.
</div>
<div class="act-grid act-grid_single">
<button class="act-btn subtle full" onclick="refreshCameraPreview()">Vorschau neu laden</button>
</div>
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Dienst</span><div class="ss-hdr-line"></div></div>
<div class="sr">
<span class="sr-led led-ok"></span>
<span class="sr-key">Kamera</span>
<strong id="camera_service_state" class="sr-val service_state state_unknown">-</strong>
</div>
</div>
</div>
</div>
</section>
<section id="admin_panel_drive" class="admin-section-panel" role="tabpanel">
<div class="admin-section-grid">
<div class="admin-col">
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Geschwindigkeit / Weg</span>
<span class="p-tag">Estimated Telemetry</span>
</div>
<div class="copy-text drive-estimator-copy">
Die Werte sind eine Schätzung auf Basis der Servo-Pulsbreite, des Parallax-Datenblatts und des eingestellten Radmaßes. Punkt-Drehen zählt nicht als Fahrweg.
</div>
<div class="drive-estimator-toolbar">
<label class="drive-estimator-input">
<span>Raddurchmesser</span>
<input id="drive_estimator_diameter_mm" type="number" min="20" max="300" step="1" value="100">
<em>mm</em>
</label>
<button class="act-btn warm" onclick="resetDriveEstimator()">Nullen</button>
</div>
<div class="drive-estimator-grid">
<article class="drive-estimator-card">
<div class="drive-estimator-label">Geschwindigkeit</div>
<div id="drive_estimator_speed" class="drive-estimator-value">0,00</div>
<div class="drive-estimator-unit">cm/s</div>
</article>
<article class="drive-estimator-card">
<div class="drive-estimator-label">Weg</div>
<div id="drive_estimator_distance" class="drive-estimator-value">0,00</div>
<div class="drive-estimator-unit">m</div>
</article>
</div>
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Quelle</span><div class="ss-hdr-line"></div></div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">ROSBridge</span>
<strong id="drive_estimator_ros_state" class="sr-val service_state state_unknown">verbinde…</strong>
</div>
<div class="sr">
<span class="sr-led led-ok"></span>
<span class="sr-key">Impulsbreite</span>
<span id="drive_estimator_pulse" class="sr-val">-</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Servo-Drehzahl</span>
<span id="drive_estimator_rpm" class="sr-val">-</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Modus</span>
<span id="drive_estimator_mode" class="sr-val">-</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Richtung</span>
<span id="drive_estimator_direction" class="sr-val">-</span>
</div>
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Kalibrierung</span><div class="ss-hdr-line"></div></div>
<div class="copy-text drive-estimator-copy">
Für jede Stufe drückst du <code>1 m messen</code>. Der Timer startet automatisch, sobald der Rover wirklich anfährt. Wenn genau <code>1 m</code> erreicht ist, drückst du <code>1 m erreicht</code>. Daraus wird die echte Geschwindigkeit in <code>cm/s</code> berechnet.
</div>
<div class="drive-calibration-toolbar">
<span id="drive_calibration_info" class="ctrl-status drive-calibration-info">nicht geladen</span>
<button class="act-btn subtle" onclick="loadDriveEstimatorCalibration()">Neu laden</button>
<button class="act-btn warm" onclick="fillDriveEstimatorCalibrationDefaults()">Datenblatt</button>
<button id="drive_calibration_save_button" class="act-btn" onclick="saveDriveEstimatorCalibration()">Speichern</button>
</div>
<div id="drive_calibration_grid" class="drive-calibration-grid"></div>
</div>
</div>
</div>
</section>
<section id="admin_panel_map" class="admin-section-panel" role="tabpanel">
<div class="admin-section-grid admin-section-grid_map">
<div class="admin-col admin-col_map_main">
<div class="panel map-panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Rover-Karte</span>
<span class="p-tag">Live Position</span>
</div>
<div class="map-toolbar">
<button id="map_plan_toggle" class="act-btn subtle is_active" onclick="toggleWaypointPlanning()">Route planen: an</button>
<button class="act-btn" onclick="addWaypointAtMapCenter()">Wegpunkt an Mitte setzen</button>
<button class="act-btn warm" onclick="centerMapOnRover()">Auf Rover zentrieren</button>
<button class="act-btn subtle" onclick="clearPlannedRoute()">Route löschen</button>
</div>
<div class="copy-text map-copy">
Die Karte zeigt den aktuellen GPS-Standort des Rovers. Ein Klick in die Karte setzt sofort einen Wegpunkt, solange <code>Route planen</code> aktiv ist. Die eigentliche Routenausführung binden wir später darauf auf.
</div>
<div class="map-stage">
<div id="rover_map" class="rover-map"></div>
<div id="rover_map_fallback" class="rover-map-fallback map-hidden" hidden>
Kartenbibliothek nicht geladen. GPS-Daten sind trotzdem unten sichtbar.
</div>
</div>
</div>
</div>
<div class="admin-col admin-col_map_side">
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">GPS</span>
<span class="p-tag">Telemetry</span>
</div>
<div class="sr">
<span class="sr-led led-ok"></span>
<span class="sr-key">Status</span>
<span id="map_gps_status" class="sr-val">Offline</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Sat</span>
<span id="map_gps_satellites" class="sr-val">--</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Breite</span>
<span id="map_gps_lat" class="sr-val">--</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Länge</span>
<span id="map_gps_lon" class="sr-val">--</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Höhe</span>
<span id="map_gps_alt" class="sr-val">--</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Genauigk.</span>
<span id="map_gps_accuracy" class="sr-val">--</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Letztes Fix</span>
<span id="map_gps_last_fix" class="sr-val">--</span>
</div>
</div>
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Route</span>
<span class="p-tag">Planning Scaffold</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Planung</span>
<span id="map_route_mode" class="sr-val">aus</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Wegpunkte</span>
<span id="map_waypoint_count" class="sr-val">0</span>
</div>
<div class="copy-text map-copy">
Diese Wegpunkte sind aktuell nur lokal in der Admin-Seite. Sie dienen als Vorbereitung für die spätere echte Routenprogrammierung.
</div>
<div class="map-route-storage">
<label class="map-route-storage-label" for="map_route_name">Name</label>
<input id="map_route_name" class="map-route-storage-input" type="text" placeholder="z. B. Hofrunde">
<button class="act-btn" onclick="saveCurrentRoute()">Route speichern</button>
</div>
<div class="map-route-storage map-route-storage_secondary">
<label class="map-route-storage-label" for="map_route_select">Gespeichert</label>
<select id="map_route_select" class="ctrl-select" onchange="loadSelectedRoute()">
<option value="">Route wählen</option>
</select>
<button class="act-btn subtle" onclick="refreshRouteLibrary()">Neu laden</button>
<button class="act-btn warm" onclick="deleteSelectedRoute()">Route löschen</button>
</div>
<div id="map_waypoint_list" class="map-waypoint-list"></div>
</div>
</div>
</div>
</section>
<section id="admin_panel_gps" class="admin-section-panel" role="tabpanel">
<div class="admin-section-grid admin-section-grid_gps">
<div class="admin-col admin-col_gps_main">
<div class="panel gps-panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">GNSS-Satelliten</span>
<span class="p-tag">Himmelskarte</span>
</div>
<div class="copy-text gps-copy">
Diese Ansicht zeigt die vom Empfänger gemeldeten Satelliten im Himmelsplot. Außen ist der Horizont, die Mitte ist der Zenit.
</div>
<div class="gps-sky-stage">
<canvas id="gps_sky_canvas" class="gps-sky-canvas" width="560" height="560"></canvas>
<div id="gps_satellite_tooltip" class="gps-satellite-tooltip" hidden></div>
</div>
</div>
</div>
<div class="admin-col admin-col_gps_side">
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Fix-Diagnose</span>
<span class="p-tag">GNSS-Diagnose</span>
</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>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Frame</span><span id="gps_diag_frame" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Breite</span><span id="gps_diag_lat" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Länge</span><span id="gps_diag_lon" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Höhe</span><span id="gps_diag_alt" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Geschwindigkeit</span><span id="gps_diag_speed" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Kurs</span><span id="gps_diag_track" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">UTC-Datum</span><span id="gps_diag_utc_date" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">UTC-Zeit</span><span id="gps_diag_utc_time" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Fix-Alter</span><span id="gps_diag_fix_age" class="sr-val">--</span></div>
</div>
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Genauigkeit</span>
<span class="p-tag">Qualität</span>
</div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Positionsgenauigkeit</span><span id="gps_diag_pdop" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Horizontale Genauigkeit</span><span id="gps_diag_hdop" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Vertikale Genauigkeit</span><span id="gps_diag_vdop" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Für Fix genutzt</span><span id="gps_diag_sat_used" class="sr-val">--</span></div>
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Im Himmelsplot</span><span id="gps_diag_sat_visible" class="sr-val">--</span></div>
<div class="copy-text gps-copy">
Für eine gute Fix-Qualität sind niedrige DOP-Werte und hohe, gleichmäßig verteilte Satelliten sinnvoll. Der Himmelsplot zeigt nur Satelliten, die der Empfänger per GSV mit Richtung und Höhe meldet.
</div>
</div>
</div>
</div>
</section>
<section id="admin_panel_motor" class="admin-section-panel motor-admin-tab" role="tabpanel">
<div class="admin-section-grid">
<div class="admin-col">
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Testmodus</span>
<span class="p-tag">Container Control</span>
</div>
<div class="copy-text">
Stoppe zuerst den ExoMy-Container. Erst wenn der Status eindeutig auf gestoppt steht, solltest du Motoren testen oder die Servo-Mitten ausrichten.
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Container</span>
<span class="sr-val" id="container_name">exomy_autostart</span>
</div>
<div class="sr">
<span class="sr-led led-off"></span>
<span class="sr-key">Status</span>
<strong id="container_state" class="sr-val service_state state_unknown">-</strong>
</div>
<div class="sr">
<span class="sr-led led-off"></span>
<span class="sr-key">Sicher testen</span>
<span class="sr-val" id="container_safe_state">-</span>
</div>
<div class="container-grid">
<button class="button buttonDanger" onclick="setContainerState('stop')">Container stoppen</button>
<button class="button buttonA" onclick="setContainerState('start')">Container starten</button>
</div>
</div>
</div>
<div class="admin-col">
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Motorentest</span>
<span class="p-tag">Motor Diagnostics</span>
</div>
<div class="tab-bar" role="tablist" aria-label="Motorentest Bereiche">
<button id="tab_button_impulse" class="motor_test_tab_button is_active" role="tab" aria-selected="true" onclick="switchMotorTestTab('impulse')">Impulstest</button>
<button id="tab_button_neutral" class="motor_test_tab_button" role="tab" aria-selected="false" onclick="switchMotorTestTab('neutral')">Servo-Mitte</button>
<button id="tab_button_drive_neutral" class="motor_test_tab_button" role="tab" aria-selected="false" onclick="switchMotorTestTab('drive-neutral')">Fahr-Null</button>
</div>
<div class="tab-content-wrap">
<section id="tab_panel_impulse" class="motor_test_tab_panel is_active" role="tabpanel">
<div class="tab-row">
<div class="tab-box">
<div class="tab-box-lbl">Rad auswählen</div>
<div class="wheel_selector" role="radiogroup" aria-label="Rad auswählen">
<label class="wheel_option"><input type="radio" name="wheel" value="fl" checked><span>Vorne links</span></label>
<label class="wheel_option"><input type="radio" name="wheel" value="fr"><span>Vorne rechts</span></label>
<label class="wheel_option"><input type="radio" name="wheel" value="cl"><span>Mitte links</span></label>
<label class="wheel_option"><input type="radio" name="wheel" value="cr"><span>Mitte rechts</span></label>
<label class="wheel_option"><input type="radio" name="wheel" value="rl"><span>Hinten links</span></label>
<label class="wheel_option"><input type="radio" name="wheel" value="rr"><span>Hinten rechts</span></label>
</div>
</div>
<div class="tab-box">
<div class="tab-box-lbl">Impulstest</div>
<div class="motor_test_grid">
<button class="button buttonX" onclick="runMotorTest('steer_left')">Lenkung links</button>
<button class="button buttonX" onclick="runMotorTest('steer_right')">Lenkung rechts</button>
<button class="button buttonA" onclick="runMotorTest('drive_forward')">Vorwärts</button>
<button class="button buttonDanger" onclick="runMotorTest('drive_backward')">Rückwärts</button>
</div>
</div>
</div>
</section>
<section id="tab_panel_neutral" class="motor_test_tab_panel" role="tabpanel" hidden>
<div class="copy-text">
Mit <code>-</code> und <code>+</code> wird die aktuelle Mittelstellung direkt am jeweiligen Lenkservo angefahren. <code>Speichern</code> schreibt alle Werte dauerhaft in die <code>exomy.yaml</code>.
</div>
<div class="neutral-toolbar">
<button class="button buttonWarm" onclick="loadSteeringNeutralValues()">Neu laden</button>
<button id="neutral_save_button" class="button buttonA" onclick="saveSteeringNeutralValues()">Speichern</button>
</div>
<div id="steering_neutral_grid" class="steering_neutral_grid"></div>
</section>
<section id="tab_panel_drive_neutral" class="motor_test_tab_panel" role="tabpanel" hidden>
<div class="copy-text">
Mit <code>-</code> und <code>+</code> stellst du den Stillstands-PWM pro Fahrmotor ein. <code>Speichern</code> schreibt alle sechs Werte dauerhaft in die <code>exomy.yaml</code>.
</div>
<div class="neutral-toolbar">
<button class="button buttonWarm" onclick="loadDriveNeutralValue()">Neu laden</button>
<button id="drive_neutral_save_button" class="button buttonA" onclick="saveDriveNeutralValue()">Speichern</button>
</div>
<div id="drive_neutral_grid" class="steering_neutral_grid"></div>
</section>
</div>
</div>
</div>
</div>
</section>
</div>
<div id="auth_overlay" class="auth-overlay">
<div class="auth-box">
<div class="auth-eyebrow">Admin-Zugang</div>
<div class="auth-title">CUNO</div>
<div class="auth-sub">Passwort eingeben um fortzufahren</div>
<input type="password" id="auth_input" class="auth-input" placeholder="••••••••" autocomplete="current-password">
<p id="auth_error" class="auth-error"></p>
<button class="auth-submit" onclick="submitPassword()">Entsperren</button>
</div>
</div>
<script type="text/javascript">
var adminApiBase = window.location.protocol + '//' + window.location.hostname + ':8082';
var cameraPreviewBase = window.location.protocol + '//' + window.location.hostname + ':8081/stream.mjpg';
var AUTH_TOKEN = 'Y3Vubw==';
var activeAdminSection = 'system';
var ros = null;
var roverCommandTopic = null;
var motorCommandsTopic = null;
var gpsFixTopic = null;
var gpsStatusTopic = null;
var gpsDiagnosticsTopic = null;
var pageConnection = {
apiOk: false,
apiLastAt: 0,
rosOk: false,
gpsLastAt: 0,
lastHealthyAt: 0,
state: 'offline'
};
var isMotorTestRunning = false;
var isContainerActionRunning = false;
var isNeutralLoading = false;
var isNeutralPreviewRunning = false;
var isNeutralSaving = false;
var isDriveNeutralLoading = false;
var isDriveNeutralPreviewRunning = false;
var isDriveNeutralSaving = false;
var lastContainerStatus = null;
var activeMotorTestTab = 'impulse';
var steeringNeutralValues = {};
var steeringNeutralSavedValues = {};
var driveNeutralValues = {};
var driveNeutralSavedValues = {};
var STEERING_NEUTRAL_STEP = 5;
var WHEEL_ORDER = ['fl', 'fr', 'cl', 'cr', 'rl', 'rr'];
var REQUIRED_CALIBRATION_POINTS = [0, 20, 30, 40, 50, 60, 80, 100];
var LOCOMOTION_MODE_NAMES = {
0: 'Fake Ackermann',
1: 'Ackermann',
2: 'Punkt-Drehen',
3: 'Crabbing'
};
var driveEstimator = {
configLoaded: false,
calibrationLoaded: false,
drivePwmNeutralAverage: 307,
drivePwmRange: 100,
pwmFrequencyHz: 50,
servoMaxRpm: 50,
servoFullScalePulseDeltaMs: 0.2,
wheelDiameterM: 0.1,
lastTickMs: Date.now(),
distanceM: 0,
speedMps: 0,
rpm: 0,
pulseWidthMs: 0,
directionLabel: 'Stopp',
modeLabel: '-',
calibrationInfo: '',
motorsEnabled: false,
locomotionMode: null,
motorSpeeds: [0, 0, 0, 0, 0, 0],
calibrationPoints: [],
savedCalibrationPoints: []
};
var routeLibrary = [];
var driveCalibrationMeasurement = {
distanceM: 1.0,
activeIndex: null,
waitingForMotion: false,
startedAtMs: 0,
elapsedMs: 0,
stopCandidateStartedAtMs: 0,
stopDebounceMs: 400,
startThresholdPercent: 2,
stopThresholdPercent: 1
};
var roverMapState = {
map: null,
mapReady: false,
roverMarker: null,
roverAccuracyCircle: null,
roverTrackLine: null,
routeLine: null,
waypointMarkers: [],
waypointPlanning: true,
waypoints: [],
trackPoints: [],
roverLat: null,
roverLon: null,
roverAlt: null,
accuracyMeters: null,
hasFix: false,
gpsStatusText: 'Offline',
satellites: '--',
lastFixAt: null,
hasCenteredOnFix: false,
suppressMapClickUntil: 0
};
var gpsDiagnosticsState = {
connected: false,
frameId: 'gps',
valid: false,
fixQuality: 0,
satellitesUsed: 0,
satellitesVisible: 0,
latitude: null,
longitude: null,
altitudeM: null,
hdop: null,
pdop: null,
vdop: null,
speedMps: null,
speedKmh: null,
trackDeg: null,
utcDate: null,
utcTime: null,
lastFixAgeS: null,
satellites: [],
selectedSatellitePrn: null,
plottedSatellites: []
};
function submitPassword() {
var input = document.getElementById('auth_input');
var error = document.getElementById('auth_error');
if (btoa(input.value) === AUTH_TOKEN) {
document.getElementById('auth_overlay').remove();
} else {
error.textContent = 'Falsches Passwort';
input.value = '';
input.focus();
}
}
function setAdminStatus(text) {
var el = document.getElementById('admin_status');
if (el) el.textContent = text;
}
function clearAdminApiDisplay() {
setText('info_ips', '--');
setText('info_wifi', '--');
setText('info_temp', '--');
setText('info_uptime', '--');
setText('info_disk', '--');
setUndervoltageState('info_undervoltage', '--', 'unknown');
setServiceState('service_camera', '-');
setServiceState('camera_service_state', '-');
setServiceState('service_admin_api', '-');
setServiceState('service_exomy', '-');
var bar = document.getElementById('bar-temp-adm');
if (bar) bar.style.width = '0%';
}
function clearAdminRosDisplay() {
roverMapState.hasFix = false;
roverMapState.roverLat = null;
roverMapState.roverLon = null;
roverMapState.roverAlt = null;
roverMapState.accuracyMeters = null;
roverMapState.lastFixAt = null;
roverMapState.gpsStatusText = 'Offline';
roverMapState.satellites = '--';
updateMapTelemetryPanel();
setDriveEstimatorState('offline', 'state_inactive');
setText('drive_estimator_speed', '--');
setText('drive_estimator_distance', '--');
setText('drive_estimator_pulse', '--');
setText('drive_estimator_rpm', '--');
setText('drive_estimator_mode', '--');
setText('drive_estimator_direction', '--');
gpsDiagnosticsState.connected = false;
gpsDiagnosticsState.valid = false;
gpsDiagnosticsState.fixQuality = 0;
gpsDiagnosticsState.satellitesUsed = 0;
gpsDiagnosticsState.satellitesVisible = 0;
gpsDiagnosticsState.latitude = null;
gpsDiagnosticsState.longitude = null;
gpsDiagnosticsState.altitudeM = null;
gpsDiagnosticsState.hdop = null;
gpsDiagnosticsState.pdop = null;
gpsDiagnosticsState.vdop = null;
gpsDiagnosticsState.speedMps = null;
gpsDiagnosticsState.speedKmh = null;
gpsDiagnosticsState.trackDeg = null;
gpsDiagnosticsState.utcDate = null;
gpsDiagnosticsState.utcTime = null;
gpsDiagnosticsState.lastFixAgeS = null;
gpsDiagnosticsState.satellites = [];
updateGpsDiagnosticsPanel();
}
function formatConnectionTime(timestamp) {
if (!timestamp) return '--:--:--';
return new Date(timestamp).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function updatePageConnectionState() {
var now = Date.now();
var apiFresh = pageConnection.apiOk && (now - pageConnection.apiLastAt) < 20000;
var rosFresh = !!pageConnection.rosOk;
var gpsFresh = pageConnection.gpsLastAt && (now - pageConnection.gpsLastAt) < 15000;
var state = 'offline';
var title = 'Offline';
var details = [];
if (apiFresh && rosFresh) {
state = gpsFresh || !pageConnection.gpsLastAt ? 'online' : 'degraded';
title = state === 'online' ? 'Online' : 'Eingeschränkt';
} else if (apiFresh || rosFresh) {
state = 'degraded';
title = 'Eingeschränkt';
}
if (!apiFresh) details.push('Admin-API nicht erreichbar');
if (!rosFresh) details.push('ROSBridge getrennt');
if (rosFresh && pageConnection.gpsLastAt && !gpsFresh) details.push('GPS-Daten veraltet');
if (!details.length) details.push('Alle Live-Dienste antworten');
if (state !== 'online' && pageConnection.lastHealthyAt) {
details.push('letzter Kontakt ' + formatConnectionTime(pageConnection.lastHealthyAt));
}
pageConnection.state = state;
document.body.classList.toggle('page-connection-online', state === 'online');
document.body.classList.toggle('page-connection-degraded', state === 'degraded');
document.body.classList.toggle('page-connection-offline', state === 'offline');
setText('page-connection-label', title);
setText('connection-banner-title', title);
setText('connection-banner-detail', details.join(' | '));
if (!apiFresh) clearAdminApiDisplay();
if (!rosFresh) clearAdminRosDisplay();
var banner = document.getElementById('connection-banner');
if (banner) banner.hidden = state === 'online';
}
function markAdminApiConnection(ok) {
pageConnection.apiOk = !!ok;
if (ok) {
pageConnection.apiLastAt = Date.now();
pageConnection.lastHealthyAt = pageConnection.apiLastAt;
}
updatePageConnectionState();
}
function markAdminRosConnection(ok) {
pageConnection.rosOk = !!ok;
if (ok) pageConnection.lastHealthyAt = Date.now();
updatePageConnectionState();
}
function markAdminGpsFresh() {
pageConnection.gpsLastAt = Date.now();
if (pageConnection.rosOk) pageConnection.lastHealthyAt = pageConnection.gpsLastAt;
updatePageConnectionState();
}
function setMotorTestStatus(text) {
setAdminStatus(text);
}
function switchAdminSection(sectionName) {
activeAdminSection = sectionName;
var systemTab = document.getElementById('admin_tab_system');
var cameraTab = document.getElementById('admin_tab_camera');
var driveTab = document.getElementById('admin_tab_drive');
var mapTab = document.getElementById('admin_tab_map');
var gpsTab = document.getElementById('admin_tab_gps');
var motorTab = document.getElementById('admin_tab_motor');
var systemPanel = document.getElementById('admin_panel_system');
var cameraPanel = document.getElementById('admin_panel_camera');
var drivePanel = document.getElementById('admin_panel_drive');
var mapPanel = document.getElementById('admin_panel_map');
var gpsPanel = document.getElementById('admin_panel_gps');
var motorPanel = document.getElementById('admin_panel_motor');
var isSystem = sectionName === 'system';
var isCamera = sectionName === 'camera';
var isDrive = sectionName === 'drive';
var isMap = sectionName === 'map';
var isGps = sectionName === 'gps';
var isMotor = sectionName === 'motor';
systemTab.classList.toggle('is_active', isSystem);
cameraTab.classList.toggle('is_active', isCamera);
driveTab.classList.toggle('is_active', isDrive);
mapTab.classList.toggle('is_active', isMap);
gpsTab.classList.toggle('is_active', isGps);
motorTab.classList.toggle('is_active', isMotor);
systemTab.setAttribute('aria-selected', isSystem ? 'true' : 'false');
cameraTab.setAttribute('aria-selected', isCamera ? 'true' : 'false');
driveTab.setAttribute('aria-selected', isDrive ? 'true' : 'false');
mapTab.setAttribute('aria-selected', isMap ? 'true' : 'false');
gpsTab.setAttribute('aria-selected', isGps ? 'true' : 'false');
motorTab.setAttribute('aria-selected', isMotor ? 'true' : 'false');
systemPanel.classList.toggle('is_active', isSystem);
cameraPanel.classList.toggle('is_active', isCamera);
drivePanel.classList.toggle('is_active', isDrive);
mapPanel.classList.toggle('is_active', isMap);
gpsPanel.classList.toggle('is_active', isGps);
motorPanel.classList.toggle('is_active', isMotor);
window.location.hash = isSystem ? '' : ('#' + sectionName);
if (isMap) {
initRoverMap();
window.setTimeout(function() {
if (roverMapState.map) roverMapState.map.invalidateSize();
}, 80);
}
if (isCamera) {
refreshCameraPreview();
}
if (isGps) {
window.setTimeout(drawGpsSkyPlot, 80);
}
if (isMotor) fetchMotorTestStatus();
}
function setText(id, value) {
var node = document.getElementById(id);
if (node) node.textContent = (value === undefined || value === null || value === '') ? '-' : value;
}
function setServiceState(id, value) {
var node = document.getElementById(id);
if (!node) return;
node.textContent = value || '-';
node.classList.remove('state_active', 'state_inactive', 'state_unknown');
if (value === 'active' || value === 'running') { node.classList.add('state_active'); return; }
if (value === 'inactive' || value === 'failed' || value === 'exited') { node.classList.add('state_inactive'); return; }
node.classList.add('state_unknown');
}
function setMotorStateNode(node, value) {
if (!node) return;
node.textContent = value || '-';
node.classList.remove('state_active', 'state_inactive', 'state_unknown');
if (value === 'running' || value === 'active') { node.classList.add('state_active'); return; }
if (value === 'exited' || value === 'stopped' || value === 'inactive') { node.classList.add('state_inactive'); return; }
node.classList.add('state_unknown');
}
function setUndervoltageState(id, text, state) {
var node = document.getElementById(id);
if (!node) return;
node.textContent = text || '-';
node.classList.remove('state_active', 'state_inactive', 'state_unknown');
if (state === 'active') {
node.classList.add('state_inactive');
node.style.animation = 'statusBlink 0.9s step-end infinite';
return;
}
node.style.animation = '';
if (state === 'past') { node.classList.add('state_unknown'); return; }
if (state === 'clear') { node.classList.add('state_active'); return; }
node.classList.add('state_unknown');
}
function formatNumber(value, digits) {
return Number(value || 0).toLocaleString('de-DE', {
minimumFractionDigits: digits,
maximumFractionDigits: digits
});
}
function formatCoordinate(value, digits) {
if (typeof value !== 'number' || isNaN(value)) return '--';
return formatNumber(value, typeof digits === 'number' ? digits : 5);
}
function formatAltitude(value, digits) {
if (typeof value !== 'number' || isNaN(value)) return '--';
return formatNumber(value, typeof digits === 'number' ? digits : 1) + ' m';
}
function formatAccuracy(value) {
if (typeof value !== 'number' || isNaN(value) || value <= 0) return '--';
return formatNumber(value, 1) + ' m';
}
function formatDop(value) {
if (typeof value !== 'number' || isNaN(value) || value <= 0) return '--';
return formatNumber(value, 2);
}
function formatDegrees(value) {
if (typeof value !== 'number' || isNaN(value)) return '--';
return formatNumber(value, 1) + '°';
}
function formatSpeedKmh(value) {
if (typeof value !== 'number' || isNaN(value)) return '--';
return formatNumber(value, 1) + ' km/h';
}
function formatFixAge(value) {
if (typeof value !== 'number' || isNaN(value)) return '--';
return formatNumber(value, 1) + ' s';
}
function formatFixTime(date) {
if (!date) return '--';
return date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function fixQualityLabel(value) {
var labels = {
0: 'Kein Fix',
1: 'GPS Fix',
2: 'DGPS Fix',
4: 'RTK Fixed',
5: 'RTK Float'
};
return labels[value] || String(value || 0);
}
function describeDopQuality(value) {
if (typeof value !== 'number' || isNaN(value) || value <= 0) {
return { text: '--', stateClass: 'state_unknown' };
}
if (value < 1) {
return { text: formatNumber(value, 2) + ' sehr gut', stateClass: 'state_active' };
}
if (value < 2) {
return { text: formatNumber(value, 2) + ' gut', stateClass: 'state_active' };
}
if (value < 5) {
return { text: formatNumber(value, 2) + ' brauchbar', stateClass: 'state_unknown' };
}
if (value < 10) {
return { text: formatNumber(value, 2) + ' kritisch', stateClass: 'state_inactive' };
}
return { text: formatNumber(value, 2) + ' schlecht', stateClass: 'state_inactive' };
}
function setGpsQualityState(id, value) {
var node = document.getElementById(id);
if (!node) return;
var quality = describeDopQuality(value);
node.textContent = quality.text;
node.classList.remove('state_active', 'state_inactive', 'state_unknown');
node.classList.add(quality.stateClass);
}
function updateGpsDiagnosticsPanel() {
setText('gps_diag_status', gpsDiagnosticsState.connected ? (gpsDiagnosticsState.valid ? 'Fix aktiv' : 'Empfänger online') : 'Offline');
setText('gps_diag_fix_quality', fixQualityLabel(gpsDiagnosticsState.fixQuality));
setText('gps_diag_frame', gpsDiagnosticsState.frameId || '--');
setText('gps_diag_lat', formatCoordinate(gpsDiagnosticsState.latitude, 8));
setText('gps_diag_lon', formatCoordinate(gpsDiagnosticsState.longitude, 8));
setText('gps_diag_alt', formatAltitude(gpsDiagnosticsState.altitudeM, 4));
setText('gps_diag_speed', formatSpeedKmh(gpsDiagnosticsState.speedKmh));
setText('gps_diag_track', formatDegrees(gpsDiagnosticsState.trackDeg));
setText('gps_diag_utc_date', gpsDiagnosticsState.utcDate || '--');
setText('gps_diag_utc_time', gpsDiagnosticsState.utcTime || '--');
setText('gps_diag_fix_age', formatFixAge(gpsDiagnosticsState.lastFixAgeS));
setGpsQualityState('gps_diag_pdop', gpsDiagnosticsState.pdop);
setGpsQualityState('gps_diag_hdop', gpsDiagnosticsState.hdop);
setGpsQualityState('gps_diag_vdop', gpsDiagnosticsState.vdop);
setText('gps_diag_sat_used', gpsDiagnosticsState.satellitesUsed);
setText('gps_diag_sat_visible', gpsDiagnosticsState.satellitesVisible);
drawGpsSkyPlot();
}
function getSelectedSatellite() {
if (!gpsDiagnosticsState.satellites.length) return null;
var selected = gpsDiagnosticsState.satellites.find(function(item) {
return item.prn === gpsDiagnosticsState.selectedSatellitePrn;
});
if (selected) return selected;
gpsDiagnosticsState.selectedSatellitePrn = gpsDiagnosticsState.satellites[0].prn || null;
return gpsDiagnosticsState.satellites[0];
}
function hideGpsSatelliteTooltip() {
var node = document.getElementById('gps_satellite_tooltip');
if (!node) return;
node.hidden = true;
}
function renderGpsSatelliteTooltip(leftPx, topPx) {
var node = document.getElementById('gps_satellite_tooltip');
if (!node) return;
var selected = getSelectedSatellite();
if (!selected) {
node.hidden = true;
return;
}
node.innerHTML =
'<div class="gps-satellite-tooltip-title">' + (selected.prn || '--') + '</div>' +
'<div class="gps-satellite-tooltip-row"><span>System</span><strong>' + (selected.constellation || '--') + '</strong></div>' +
'<div class="gps-satellite-tooltip-row"><span>Richtung</span><strong>' + (typeof selected.azimuth === 'number' ? selected.azimuth + '°' : '--') + '</strong></div>' +
'<div class="gps-satellite-tooltip-row"><span>Höhe</span><strong>' + (typeof selected.elevation === 'number' ? selected.elevation + '°' : '--') + '</strong></div>' +
'<div class="gps-satellite-tooltip-row"><span>Signal</span><strong>' + (typeof selected.snr === 'number' ? selected.snr + ' dB' : '--') + '</strong></div>' +
'<div class="gps-satellite-tooltip-row"><span>Qualität</span><strong>' + (typeof selected.snr === 'number' ? (selected.snr >= 35 ? 'sehr gut' : (selected.snr >= 20 ? 'gut' : 'schwach')) : 'unbekannt') + '</strong></div>';
node.style.left = leftPx + 'px';
node.style.top = topPx + 'px';
node.hidden = false;
}
function drawGpsSkyPlot() {
var canvas = document.getElementById('gps_sky_canvas');
if (!canvas || !canvas.getContext) return;
var ctx = canvas.getContext('2d');
var width = canvas.width;
var height = canvas.height;
var cx = width / 2;
var cy = height / 2;
var radius = Math.min(width, height) * 0.42;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#07111c';
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(0,255,145,.18)';
ctx.lineWidth = 1;
[radius, radius * 0.66, radius * 0.33].forEach(function(ring) {
ctx.beginPath();
ctx.arc(cx, cy, ring, 0, Math.PI * 2);
ctx.stroke();
});
ctx.beginPath();
ctx.moveTo(cx, cy - radius);
ctx.lineTo(cx, cy + radius);
ctx.moveTo(cx - radius, cy);
ctx.lineTo(cx + radius, cy);
ctx.stroke();
ctx.fillStyle = 'rgba(190,255,225,.84)';
ctx.font = '16px Consolas';
ctx.textAlign = 'center';
ctx.fillText('N', cx, cy - radius - 12);
ctx.fillText('S', cx, cy + radius + 24);
ctx.fillText('W', cx - radius - 18, cy + 6);
ctx.fillText('E', cx + radius + 18, cy + 6);
ctx.fillStyle = 'rgba(150,255,210,.5)';
ctx.font = '12px Consolas';
ctx.fillText('30°', cx, cy - radius * 0.33 + 4);
ctx.fillText('60°', cx, cy - radius * 0.66 + 4);
ctx.fillText('90°', cx, cy + 4);
gpsDiagnosticsState.plottedSatellites = [];
gpsDiagnosticsState.satellites.forEach(function(item) {
if (typeof item.azimuth !== 'number' || typeof item.elevation !== 'number') return;
var satRadius = radius * (1 - Math.max(0, Math.min(90, item.elevation)) / 90);
var angle = (item.azimuth - 90) * Math.PI / 180.0;
var x = cx + Math.cos(angle) * satRadius;
var y = cy + Math.sin(angle) * satRadius;
var snr = typeof item.snr === 'number' ? item.snr : 0;
var isSelected = item.prn === gpsDiagnosticsState.selectedSatellitePrn;
ctx.beginPath();
ctx.fillStyle = snr >= 35 ? '#00ff91' : (snr >= 20 ? '#ffb300' : '#ff6b6b');
ctx.arc(x, y, isSelected ? 13 : 10, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = isSelected ? '#ffffff' : 'rgba(5,17,29,.9)';
ctx.lineWidth = isSelected ? 3 : 2;
ctx.stroke();
ctx.fillStyle = '#041017';
ctx.font = 'bold 10px Consolas';
ctx.textAlign = 'center';
ctx.fillText(String(item.prn || '?'), x, y + 3);
gpsDiagnosticsState.plottedSatellites.push({
prn: item.prn,
x: x,
y: y,
radius: isSelected ? 13 : 10
});
});
}
function onGpsSkyCanvasClick(event) {
var canvas = document.getElementById('gps_sky_canvas');
if (!canvas) return;
var rect = canvas.getBoundingClientRect();
var scaleX = canvas.width / rect.width;
var scaleY = canvas.height / rect.height;
var x = (event.clientX - rect.left) * scaleX;
var y = (event.clientY - rect.top) * scaleY;
var selectedPrn = null;
gpsDiagnosticsState.plottedSatellites.forEach(function(item) {
var dx = x - item.x;
var dy = y - item.y;
if ((dx * dx + dy * dy) <= (item.radius + 6) * (item.radius + 6)) {
selectedPrn = item.prn;
}
});
if (!selectedPrn) {
hideGpsSatelliteTooltip();
return;
}
gpsDiagnosticsState.selectedSatellitePrn = selectedPrn;
drawGpsSkyPlot();
var tooltipLeft = Math.min(rect.width - 18, (event.clientX - rect.left) + 18);
var tooltipTop = Math.max(18, (event.clientY - rect.top) - 18);
renderGpsSatelliteTooltip(tooltipLeft, tooltipTop);
}
function handleGpsDiagnostics(message) {
if (!message || !message.data) return;
try {
var data = JSON.parse(message.data);
gpsDiagnosticsState.connected = !!data.connected;
gpsDiagnosticsState.frameId = data.frame_id || 'gps';
gpsDiagnosticsState.valid = !!data.valid;
gpsDiagnosticsState.fixQuality = Number(data.fix_quality || 0);
gpsDiagnosticsState.satellitesUsed = Number(data.satellites_used || 0);
gpsDiagnosticsState.satellitesVisible = Number(data.satellites_visible || 0);
gpsDiagnosticsState.latitude = typeof data.latitude === 'number' ? data.latitude : null;
gpsDiagnosticsState.longitude = typeof data.longitude === 'number' ? data.longitude : null;
gpsDiagnosticsState.altitudeM = typeof data.altitude_m === 'number' ? data.altitude_m : null;
gpsDiagnosticsState.hdop = typeof data.hdop === 'number' ? data.hdop : null;
gpsDiagnosticsState.pdop = typeof data.pdop === 'number' ? data.pdop : null;
gpsDiagnosticsState.vdop = typeof data.vdop === 'number' ? data.vdop : null;
gpsDiagnosticsState.speedMps = typeof data.speed_mps === 'number' ? data.speed_mps : null;
gpsDiagnosticsState.speedKmh = typeof data.speed_kmh === 'number' ? data.speed_kmh : null;
gpsDiagnosticsState.trackDeg = typeof data.track_deg === 'number' ? data.track_deg : null;
gpsDiagnosticsState.utcDate = data.utc_date || null;
gpsDiagnosticsState.utcTime = data.utc_time || null;
gpsDiagnosticsState.lastFixAgeS = typeof data.last_fix_age_s === 'number' ? data.last_fix_age_s : null;
gpsDiagnosticsState.satellites = Array.isArray(data.satellites) ? data.satellites : [];
if (gpsDiagnosticsState.selectedSatellitePrn) {
var stillExists = gpsDiagnosticsState.satellites.some(function(item) {
return item.prn === gpsDiagnosticsState.selectedSatellitePrn;
});
if (!stillExists) gpsDiagnosticsState.selectedSatellitePrn = null;
}
updateGpsDiagnosticsPanel();
} catch (error) {}
}
function updateMapTelemetryPanel() {
setText('map_gps_status', roverMapState.gpsStatusText || 'Offline');
setText('map_gps_satellites', roverMapState.satellites);
setText('map_gps_lat', formatCoordinate(roverMapState.roverLat));
setText('map_gps_lon', formatCoordinate(roverMapState.roverLon));
setText('map_gps_alt', formatAltitude(roverMapState.roverAlt));
setText('map_gps_accuracy', formatAccuracy(roverMapState.accuracyMeters));
setText('map_gps_last_fix', formatFixTime(roverMapState.lastFixAt));
setText('map_route_mode', roverMapState.waypointPlanning ? 'aktiv' : 'aus');
setText('map_waypoint_count', roverMapState.waypoints.length);
}
function renderRouteLibrary() {
var select = document.getElementById('map_route_select');
if (!select) return;
var current = select.value;
var options = ['<option value="">Route wählen</option>'];
routeLibrary.forEach(function(route) {
options.push('<option value="' + route.name.replace(/"/g, '&quot;') + '">' + route.name + '</option>');
});
select.innerHTML = options.join('');
var stillExists = routeLibrary.some(function(route) {
return route.name === current;
});
select.value = stillExists ? current : '';
}
function applyWaypointPlanningState() {
var button = document.getElementById('map_plan_toggle');
if (button) {
button.textContent = 'Route planen: ' + (roverMapState.waypointPlanning ? 'an' : 'aus');
button.classList.toggle('is_active', roverMapState.waypointPlanning);
}
var mapNode = document.getElementById('rover_map');
if (mapNode) {
mapNode.classList.toggle('is_planning', roverMapState.waypointPlanning);
}
updateMapTelemetryPanel();
}
function renderWaypointList() {
var list = document.getElementById('map_waypoint_list');
if (!list) return;
if (!roverMapState.waypoints.length) {
list.innerHTML = '<div class="map-waypoint-empty">Noch keine Wegpunkte gesetzt.</div>';
updateMapTelemetryPanel();
return;
}
var html = roverMapState.waypoints.map(function(point, index) {
return (
'<div class="map-waypoint-item">' +
'<div class="map-waypoint-index">WP ' + (index + 1) + '</div>' +
'<div class="map-waypoint-coords">' +
'<strong>' + formatCoordinate(point.lat) + '</strong>' +
'<span>' + formatCoordinate(point.lon) + '</span>' +
'</div>' +
'</div>'
);
});
list.innerHTML = html.join('');
updateMapTelemetryPanel();
}
function createWaypointIcon(index) {
return L.divIcon({
className: 'map-waypoint-icon',
html:
'<div class="map-waypoint-stack">' +
'<div class="map-waypoint-label">WP ' + (index + 1) + '</div>' +
'<div class="map-waypoint-dot"></div>' +
'</div>',
iconSize: [58, 42],
iconAnchor: [29, 34]
});
}
function buildRouteCoordinates() {
var coords = [];
if (roverMapState.hasFix && typeof roverMapState.roverLat === 'number' && typeof roverMapState.roverLon === 'number') {
coords.push([roverMapState.roverLat, roverMapState.roverLon]);
}
roverMapState.waypoints.forEach(function(point) {
coords.push([point.lat, point.lon]);
});
return coords;
}
function renderPlannedRoute() {
if (!roverMapState.mapReady || !roverMapState.routeLine) return;
roverMapState.routeLine.setLatLngs(buildRouteCoordinates());
renderWaypointList();
}
function getWaypointInsertIndexFromRouteClick(latlng) {
if (!roverMapState.waypoints.length) return 0;
var clickLat = Number(latlng.lat);
var clickLon = Number(latlng.lng);
var bestIndex = roverMapState.waypoints.length;
var bestDistance = Number.POSITIVE_INFINITY;
for (var i = 0; i < roverMapState.waypoints.length - 1; i += 1) {
var start = roverMapState.waypoints[i];
var end = roverMapState.waypoints[i + 1];
var ax = Number(start.lon);
var ay = Number(start.lat);
var bx = Number(end.lon);
var by = Number(end.lat);
var px = clickLon;
var py = clickLat;
var abx = bx - ax;
var aby = by - ay;
var abLen2 = abx * abx + aby * aby;
var t = 0;
if (abLen2 > 0) {
t = ((px - ax) * abx + (py - ay) * aby) / abLen2;
t = Math.max(0, Math.min(1, t));
}
var projX = ax + abx * t;
var projY = ay + aby * t;
var dx = px - projX;
var dy = py - projY;
var distance = dx * dx + dy * dy;
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = i + 1;
}
}
return bestIndex;
}
function clearWaypointMarkers() {
roverMapState.waypointMarkers.forEach(function(marker) {
roverMapState.map.removeLayer(marker);
});
roverMapState.waypointMarkers = [];
}
function buildWaypointPopupContent(index, lat, lon) {
return (
'<div class="map-waypoint-popup">' +
'<div class="map-waypoint-popup-title">Wegpunkt ' + (index + 1) + '</div>' +
'<div class="map-waypoint-popup-coords">' + formatCoordinate(lat) + ', ' + formatCoordinate(lon) + '</div>' +
'<button class="map-waypoint-delete" onclick="removeWaypoint(' + index + ')">Wegpunkt löschen</button>' +
'</div>'
);
}
function renderWaypointMarkers() {
if (!roverMapState.mapReady) return;
clearWaypointMarkers();
roverMapState.waypoints.forEach(function(point, index) {
var marker = L.marker([point.lat, point.lon], {
title: 'Wegpunkt ' + (index + 1),
icon: createWaypointIcon(index),
draggable: true
}).addTo(roverMapState.map);
marker.bindPopup(buildWaypointPopupContent(index, point.lat, point.lon));
marker.on('drag', function(event) {
var latlng = event.target.getLatLng();
roverMapState.waypoints[index].lat = Number(latlng.lat);
roverMapState.waypoints[index].lon = Number(latlng.lng);
renderPlannedRoute();
});
marker.on('dragend', function(event) {
var latlng = event.target.getLatLng();
roverMapState.waypoints[index].lat = Number(latlng.lat);
roverMapState.waypoints[index].lon = Number(latlng.lng);
event.target.setPopupContent(buildWaypointPopupContent(index, latlng.lat, latlng.lng));
renderWaypointList();
renderPlannedRoute();
});
roverMapState.waypointMarkers.push(marker);
});
renderPlannedRoute();
}
function addWaypointFromMap(latlng) {
roverMapState.waypoints.push({
lat: Number(latlng.lat),
lon: Number(latlng.lng)
});
renderWaypointMarkers();
}
function insertWaypointAtIndex(index, latlng) {
var insertIndex = Math.max(0, Math.min(roverMapState.waypoints.length, Number(index)));
roverMapState.waypoints.splice(insertIndex, 0, {
lat: Number(latlng.lat),
lon: Number(latlng.lng)
});
renderWaypointMarkers();
}
function addWaypointAtMapCenter() {
if (!roverMapState.mapReady || !roverMapState.map) return;
addWaypointFromMap(roverMapState.map.getCenter());
}
function removeWaypoint(index) {
if (index < 0 || index >= roverMapState.waypoints.length) return;
roverMapState.waypoints.splice(index, 1);
renderWaypointMarkers();
}
async function refreshRouteLibrary() {
try {
var response = await fetch(adminApiBase + '/api/routes');
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
routeLibrary = Array.isArray(data.library && data.library.routes) ? data.library.routes : [];
renderRouteLibrary();
} catch (error) {
setAdminStatus(error.message || 'Routen konnten nicht geladen werden');
}
}
function loadSelectedRoute() {
var select = document.getElementById('map_route_select');
if (!select || !select.value) return;
var route = routeLibrary.find(function(item) {
return item.name === select.value;
});
if (!route) return;
roverMapState.waypoints = (route.waypoints || []).map(function(point) {
return {
lat: Number(point.lat),
lon: Number(point.lon)
};
});
var nameInput = document.getElementById('map_route_name');
if (nameInput) nameInput.value = route.name;
renderWaypointMarkers();
setAdminStatus('Route geladen');
}
async function saveCurrentRoute() {
var nameInput = document.getElementById('map_route_name');
var routeName = nameInput ? String(nameInput.value || '').trim() : '';
if (!routeName) {
alert('Bitte einen Routennamen eingeben.');
return;
}
if (!roverMapState.waypoints.length) {
alert('Die Route hat noch keine Wegpunkte.');
return;
}
try {
var response = await fetch(adminApiBase + '/api/routes/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: routeName,
waypoints: roverMapState.waypoints
})
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
routeLibrary = Array.isArray(data.library && data.library.routes) ? data.library.routes : routeLibrary;
renderRouteLibrary();
var select = document.getElementById('map_route_select');
if (select) select.value = routeName;
setAdminStatus(data.status || 'Route gespeichert');
} catch (error) {
alert(error.message || 'Die Route konnte nicht gespeichert werden.');
}
}
async function deleteSelectedRoute() {
var select = document.getElementById('map_route_select');
if (!select || !select.value) {
alert('Bitte zuerst eine gespeicherte Route auswählen.');
return;
}
if (!window.confirm('Diese gespeicherte Route wirklich löschen?')) return;
try {
var response = await fetch(adminApiBase + '/api/routes/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: select.value})
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
routeLibrary = Array.isArray(data.library && data.library.routes) ? data.library.routes : [];
renderRouteLibrary();
setAdminStatus(data.status || 'Route gelöscht');
} catch (error) {
alert(error.message || 'Die Route konnte nicht gelöscht werden.');
}
}
function toggleWaypointPlanning() {
roverMapState.waypointPlanning = !roverMapState.waypointPlanning;
applyWaypointPlanningState();
}
function clearPlannedRoute() {
roverMapState.waypoints = [];
renderWaypointMarkers();
}
function centerMapOnRover() {
if (!roverMapState.mapReady || !roverMapState.hasFix) return;
roverMapState.map.setView([roverMapState.roverLat, roverMapState.roverLon], Math.max(roverMapState.map.getZoom(), 17));
}
function initRoverMap() {
if (roverMapState.mapReady) return;
var fallback = document.getElementById('rover_map_fallback');
var mapNode = document.getElementById('rover_map');
if (!mapNode) return;
if (typeof L === 'undefined') {
return;
}
roverMapState.map = L.map('rover_map', {
zoomControl: true,
attributionControl: true,
maxZoom: 22
}).setView([50.2984, 11.0480], 16);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 22,
maxNativeZoom: 19,
attribution: '&copy; OpenStreetMap'
}).addTo(roverMapState.map);
roverMapState.roverTrackLine = L.polyline([], {
color: '#00ff91',
weight: 3,
opacity: 0.7,
interactive: false
}).addTo(roverMapState.map);
roverMapState.routeLine = L.polyline([], {
color: '#0088FF',
weight: 3,
opacity: 0.85,
interactive: true
}).addTo(roverMapState.map);
roverMapState.routeLine.on('click', function(event) {
roverMapState.suppressMapClickUntil = Date.now() + 250;
if (event.originalEvent) {
if (typeof event.originalEvent.preventDefault === 'function') event.originalEvent.preventDefault();
if (typeof event.originalEvent.stopPropagation === 'function') event.originalEvent.stopPropagation();
}
if (!roverMapState.waypointPlanning) return;
if (roverMapState.waypoints.length < 2) {
addWaypointFromMap(event.latlng);
return;
}
insertWaypointAtIndex(getWaypointInsertIndexFromRouteClick(event.latlng), event.latlng);
});
roverMapState.roverAccuracyCircle = L.circle([50.2984, 11.0480], {
radius: 0,
color: '#00ff91',
weight: 1,
opacity: 0.7,
fillColor: '#00ff91',
fillOpacity: 0.1,
interactive: false
}).addTo(roverMapState.map);
roverMapState.roverMarker = L.circleMarker([50.2984, 11.0480], {
radius: 7,
color: '#05111d',
weight: 2,
fillColor: '#FF8C00',
fillOpacity: 1,
interactive: false
}).addTo(roverMapState.map);
roverMapState.map.on('click', function(event) {
if (Date.now() < roverMapState.suppressMapClickUntil) return;
if (!roverMapState.waypointPlanning) return;
addWaypointFromMap(event.latlng);
});
roverMapState.mapReady = true;
applyWaypointPlanningState();
renderWaypointMarkers();
updateMapTelemetryPanel();
}
function updateRoverPositionOnMap() {
if (!roverMapState.mapReady || !roverMapState.hasFix) return;
var latlng = [roverMapState.roverLat, roverMapState.roverLon];
roverMapState.roverMarker.setLatLng(latlng);
roverMapState.roverMarker.bindPopup(
'Rover<br>' +
formatCoordinate(roverMapState.roverLat) + ', ' + formatCoordinate(roverMapState.roverLon)
);
var accuracy = roverMapState.accuracyMeters || 0;
roverMapState.roverAccuracyCircle.setLatLng(latlng);
roverMapState.roverAccuracyCircle.setRadius(accuracy);
roverMapState.trackPoints.push(latlng);
if (roverMapState.trackPoints.length > 60) roverMapState.trackPoints.shift();
roverMapState.roverTrackLine.setLatLngs(roverMapState.trackPoints);
if (!roverMapState.hasCenteredOnFix) {
roverMapState.map.setView(latlng, 17);
roverMapState.hasCenteredOnFix = true;
}
renderPlannedRoute();
}
function updateGpsStatusSummary(text) {
markAdminGpsFresh();
roverMapState.gpsStatusText = text || 'Offline';
var satMatch = String(roverMapState.gpsStatusText).match(/(\d+)\s*Sat/i);
roverMapState.satellites = satMatch ? satMatch[1] : '--';
updateMapTelemetryPanel();
}
function handleGpsFix(message) {
if (!message || !message.status) return;
markAdminGpsFresh();
roverMapState.hasFix = Number(message.status.status) >= 0;
if (!roverMapState.hasFix) {
roverMapState.roverLat = null;
roverMapState.roverLon = null;
roverMapState.roverAlt = null;
roverMapState.accuracyMeters = null;
updateMapTelemetryPanel();
renderPlannedRoute();
return;
}
roverMapState.roverLat = Number(message.latitude);
roverMapState.roverLon = Number(message.longitude);
roverMapState.roverAlt = Number(message.altitude);
roverMapState.lastFixAt = new Date();
var covariance = Array.isArray(message.position_covariance) ? Number(message.position_covariance[0]) : 0;
roverMapState.accuracyMeters = covariance > 0 ? Math.sqrt(covariance) : null;
updateMapTelemetryPanel();
initRoverMap();
updateRoverPositionOnMap();
}
function formatSpeedCms(speedMps, digits) {
return formatNumber(Number(speedMps || 0) * 100, digits);
}
function setDriveEstimatorState(text, stateClass) {
var node = document.getElementById('drive_estimator_ros_state');
if (!node) return;
node.textContent = text || '-';
node.classList.remove('state_active', 'state_inactive', 'state_unknown');
node.classList.add(stateClass || 'state_unknown');
}
function cloneCalibrationPoints(points) {
return (points || []).map(function(item) {
return {
percent: Number(item.percent),
speed_mps: Number(item.speed_mps)
};
});
}
function normalizeCalibrationPoints(points) {
var source = cloneCalibrationPoints(points).sort(function(a, b) {
return a.percent - b.percent;
});
if (!source.length) source = [{percent: 0, speed_mps: 0}];
return REQUIRED_CALIBRATION_POINTS.map(function(targetPercent) {
var exact = source.find(function(point) {
return Number(point.percent) === Number(targetPercent);
});
if (exact) {
return {
percent: targetPercent,
speed_mps: Number(exact.speed_mps || 0)
};
}
var lower = null;
var upper = null;
source.forEach(function(point) {
if (point.percent < targetPercent) lower = point;
if (!upper && point.percent > targetPercent) upper = point;
});
if (!lower && upper) {
return {
percent: targetPercent,
speed_mps: Number(upper.speed_mps || 0)
};
}
if (lower && !upper) {
return {
percent: targetPercent,
speed_mps: Number(lower.speed_mps || 0)
};
}
if (lower && upper) {
var span = Number(upper.percent) - Number(lower.percent);
var ratio = span <= 0 ? 0 : (targetPercent - Number(lower.percent)) / span;
return {
percent: targetPercent,
speed_mps: Number(lower.speed_mps || 0) + (Number(upper.speed_mps || 0) - Number(lower.speed_mps || 0)) * ratio
};
}
return {
percent: targetPercent,
speed_mps: 0
};
});
}
function getDriveEstimatorDefaultCalibration() {
var wheelDiameterM = getDriveEstimatorDiameterM();
return REQUIRED_CALIBRATION_POINTS.map(function(percent) {
var rpm = driveEstimator.servoMaxRpm * (percent / 100.0);
return {
percent: percent,
speed_mps: (rpm / 60.0) * Math.PI * wheelDiameterM
};
});
}
function hasUnsavedDriveCalibrationChanges() {
if (driveEstimator.calibrationPoints.length !== driveEstimator.savedCalibrationPoints.length) return true;
for (var i = 0; i < driveEstimator.calibrationPoints.length; i += 1) {
var current = driveEstimator.calibrationPoints[i];
var saved = driveEstimator.savedCalibrationPoints[i];
if (!saved) return true;
if (Number(current.percent) !== Number(saved.percent)) return true;
if (Math.abs(Number(current.speed_mps) - Number(saved.speed_mps)) > 0.0005) return true;
}
return false;
}
function updateDriveCalibrationSaveButton() {
var button = document.getElementById('drive_calibration_save_button');
if (button) button.disabled = !driveEstimator.calibrationLoaded || !hasUnsavedDriveCalibrationChanges();
}
function renderDriveEstimatorCalibration() {
var grid = document.getElementById('drive_calibration_grid');
if (!grid) return;
var html = [];
driveEstimator.calibrationPoints.forEach(function(point, index) {
var saved = driveEstimator.savedCalibrationPoints[index];
var isDirty = !saved || Math.abs(Number(point.speed_mps) - Number(saved.speed_mps)) > 0.0005;
var isActive = driveCalibrationMeasurement.activeIndex === index;
var measuredSeconds = point.speed_mps > 0 ? (driveCalibrationMeasurement.distanceM / point.speed_mps) : 0;
var statusText = 'Bereit';
if (isActive && driveCalibrationMeasurement.waitingForMotion) statusText = 'Warte auf Anfahrt';
else if (isActive) statusText = 'Misst: ' + formatNumber(driveCalibrationMeasurement.elapsedMs / 1000.0, 2) + ' s';
html.push(
'<article class="drive-calibration-card' + (isDirty ? ' is_dirty' : '') + (isActive ? ' is_active' : '') + '">' +
'<div class="drive-calibration-percent">' + point.percent + ' %</div>' +
'<div class="drive-calibration-value">' + formatSpeedCms(point.speed_mps, 1) + ' cm/s</div>' +
'<div class="drive-calibration-meta">1 m in ' + (point.speed_mps > 0 ? (formatNumber(measuredSeconds, 2) + ' s') : '-') + '</div>' +
'<div class="drive-calibration-status">' + statusText + '</div>' +
'<div class="drive-calibration-actions">' +
'<button class="act-btn subtle drive-calibration-button" onclick="startDriveCalibrationMeasurement(' + index + ')"' + (driveCalibrationMeasurement.activeIndex !== null && !isActive ? ' disabled' : '') + '>' + (isActive ? 'Aktiv' : '1 m messen') + '</button>' +
'<button class="act-btn drive-calibration-button" onclick="finishDriveCalibrationMeasurement(' + index + ')"' + (isActive ? '' : ' disabled') + '>1 m erreicht</button>' +
'<button class="act-btn warm drive-calibration-button" onclick="cancelDriveCalibrationMeasurement(' + index + ')"' + (isActive ? '' : ' disabled') + '>Abbrechen</button>' +
'</div>' +
'</article>'
);
});
grid.innerHTML = html.join('');
var infoNode = document.getElementById('drive_calibration_info');
if (infoNode) infoNode.textContent = driveEstimator.calibrationInfo || '-';
updateDriveCalibrationSaveButton();
}
function isDriveMeasurementMoving() {
return isTranslationMode() && Math.abs(getAverageMotorSpeedPercent()) >= driveCalibrationMeasurement.startThresholdPercent;
}
function isDriveMeasurementStopped() {
return Math.abs(getAverageMotorSpeedPercent()) <= driveCalibrationMeasurement.stopThresholdPercent;
}
function startDriveCalibrationMeasurement(index) {
if (!driveEstimator.calibrationPoints[index]) return;
driveCalibrationMeasurement.activeIndex = index;
driveCalibrationMeasurement.waitingForMotion = true;
driveCalibrationMeasurement.startedAtMs = 0;
driveCalibrationMeasurement.elapsedMs = 0;
driveCalibrationMeasurement.stopCandidateStartedAtMs = 0;
driveEstimator.calibrationInfo = driveEstimator.calibrationPoints[index].percent + ' %: warte auf Anfahrt';
renderDriveEstimatorCalibration();
}
function finishDriveCalibrationMeasurement(index, automatic) {
if (driveCalibrationMeasurement.activeIndex !== index) return;
if (driveCalibrationMeasurement.waitingForMotion || driveCalibrationMeasurement.elapsedMs <= 0) {
alert('Die Messung hat noch nicht begonnen. Erst losfahren, dann bei 1 m erneut drücken.');
return;
}
var elapsedSeconds = driveCalibrationMeasurement.elapsedMs / 1000.0;
driveEstimator.calibrationPoints[index].speed_mps = driveCalibrationMeasurement.distanceM / elapsedSeconds;
driveEstimator.calibrationInfo = driveEstimator.calibrationPoints[index].percent + ' %: 1 m in ' + formatNumber(elapsedSeconds, 2) + ' s' + (automatic ? ' (auto)' : '');
driveCalibrationMeasurement.activeIndex = null;
driveCalibrationMeasurement.waitingForMotion = false;
driveCalibrationMeasurement.startedAtMs = 0;
driveCalibrationMeasurement.elapsedMs = 0;
driveCalibrationMeasurement.stopCandidateStartedAtMs = 0;
renderDriveEstimatorCalibration();
renderDriveEstimator();
}
function cancelDriveCalibrationMeasurement(index) {
if (driveCalibrationMeasurement.activeIndex !== index) return;
driveCalibrationMeasurement.activeIndex = null;
driveCalibrationMeasurement.waitingForMotion = false;
driveCalibrationMeasurement.startedAtMs = 0;
driveCalibrationMeasurement.elapsedMs = 0;
driveCalibrationMeasurement.stopCandidateStartedAtMs = 0;
driveEstimator.calibrationInfo = 'Messung abgebrochen';
renderDriveEstimatorCalibration();
}
function updateDriveCalibrationMeasurementState() {
if (driveCalibrationMeasurement.activeIndex === null) return;
if (driveCalibrationMeasurement.waitingForMotion) {
if (isDriveMeasurementMoving()) {
driveCalibrationMeasurement.waitingForMotion = false;
driveCalibrationMeasurement.startedAtMs = Date.now();
driveCalibrationMeasurement.elapsedMs = 0;
driveCalibrationMeasurement.stopCandidateStartedAtMs = 0;
driveEstimator.calibrationInfo = driveEstimator.calibrationPoints[driveCalibrationMeasurement.activeIndex].percent + ' %: Messung läuft';
renderDriveEstimatorCalibration();
}
return;
}
if (driveCalibrationMeasurement.startedAtMs > 0) {
var nowMs = Date.now();
var previousTenths = Math.floor(driveCalibrationMeasurement.elapsedMs / 100);
driveCalibrationMeasurement.elapsedMs = Math.max(0, nowMs - driveCalibrationMeasurement.startedAtMs);
var nextTenths = Math.floor(driveCalibrationMeasurement.elapsedMs / 100);
if (nextTenths !== previousTenths) renderDriveEstimatorCalibration();
if (!isDriveMeasurementStopped()) {
driveCalibrationMeasurement.stopCandidateStartedAtMs = 0;
} else if (driveCalibrationMeasurement.stopCandidateStartedAtMs === 0) {
driveCalibrationMeasurement.stopCandidateStartedAtMs = nowMs;
} else if (nowMs - driveCalibrationMeasurement.stopCandidateStartedAtMs >= driveCalibrationMeasurement.stopDebounceMs) {
finishDriveCalibrationMeasurement(driveCalibrationMeasurement.activeIndex, true);
}
}
}
function interpolateCalibrationSpeed(percentMagnitude) {
var points = driveEstimator.calibrationPoints;
if (!points || !points.length) return 0;
var p = Math.max(0, Math.min(100, Math.abs(percentMagnitude)));
if (p <= points[0].percent) return Number(points[0].speed_mps || 0);
for (var i = 1; i < points.length; i += 1) {
var left = points[i - 1];
var right = points[i];
if (p <= right.percent) {
var span = right.percent - left.percent;
if (span <= 0) return Number(right.speed_mps || 0);
var ratio = (p - left.percent) / span;
return Number(left.speed_mps || 0) + (Number(right.speed_mps || 0) - Number(left.speed_mps || 0)) * ratio;
}
}
return Number(points[points.length - 1].speed_mps || 0);
}
function fillDriveEstimatorCalibrationDefaults() {
if (driveCalibrationMeasurement.activeIndex !== null) return;
driveEstimator.calibrationPoints = normalizeCalibrationPoints(getDriveEstimatorDefaultCalibration());
driveEstimator.calibrationInfo = 'Datenblatt-Kurve geladen';
renderDriveEstimatorCalibration();
renderDriveEstimator();
}
function getDriveEstimatorDiameterM() {
var input = document.getElementById('drive_estimator_diameter_mm');
if (!input) return driveEstimator.wheelDiameterM;
var mm = parseFloat(input.value);
if (isNaN(mm)) return driveEstimator.wheelDiameterM;
mm = Math.max(20, Math.min(300, mm));
input.value = String(mm);
driveEstimator.wheelDiameterM = mm / 1000.0;
return driveEstimator.wheelDiameterM;
}
function getAverageMotorSpeedPercent() {
if (!driveEstimator.motorSpeeds || !driveEstimator.motorSpeeds.length) return 0;
var sum = 0;
var count = 0;
driveEstimator.motorSpeeds.forEach(function(value) {
if (typeof value === 'number' && !isNaN(value)) {
sum += value;
count += 1;
}
});
return count ? (sum / count) : 0;
}
function isTranslationMode() {
return driveEstimator.locomotionMode !== 2;
}
function updateDriveEstimatorModel() {
updateDriveCalibrationMeasurementState();
var nowMs = Date.now();
var dt = Math.max(0, (nowMs - driveEstimator.lastTickMs) / 1000.0);
driveEstimator.lastTickMs = nowMs;
var avgSpeedPercent = getAverageMotorSpeedPercent();
var direction = avgSpeedPercent > 0 ? 1 : (avgSpeedPercent < 0 ? -1 : 0);
var pulsePeriodMs = 1000.0 / driveEstimator.pwmFrequencyHz;
var pulseStepMs = pulsePeriodMs / 4096.0;
var pwmValue = driveEstimator.drivePwmNeutralAverage + (avgSpeedPercent / 100.0 * driveEstimator.drivePwmRange);
var pulseWidthMs = pwmValue * pulseStepMs;
var neutralPulseMs = driveEstimator.drivePwmNeutralAverage * pulseStepMs;
var deltaMs = pulseWidthMs - neutralPulseMs;
var rpm = 0;
if (isTranslationMode() && driveEstimator.motorsEnabled) {
rpm = (deltaMs / driveEstimator.servoFullScalePulseDeltaMs) * driveEstimator.servoMaxRpm;
rpm = Math.max(-driveEstimator.servoMaxRpm, Math.min(driveEstimator.servoMaxRpm, rpm));
}
var speedMagnitude = isTranslationMode() && driveEstimator.motorsEnabled
? interpolateCalibrationSpeed(avgSpeedPercent)
: 0;
var speedMps = direction === 0 ? 0 : speedMagnitude * direction;
if (Math.abs(speedMps) < 0.0005) speedMps = 0;
if (dt > 0 && dt < 2.5) driveEstimator.distanceM += Math.abs(speedMps) * dt;
driveEstimator.rpm = rpm;
driveEstimator.speedMps = speedMps;
driveEstimator.pulseWidthMs = pulseWidthMs;
driveEstimator.modeLabel = LOCOMOTION_MODE_NAMES[driveEstimator.locomotionMode] || '-';
driveEstimator.directionLabel = !driveEstimator.motorsEnabled ? 'Motoren aus'
: !isTranslationMode() ? 'Drehen auf der Stelle'
: direction > 0 ? 'Vorwärts'
: direction < 0 ? 'Rückwärts'
: 'Stopp';
}
function renderDriveEstimator() {
if (!pageConnection.rosOk) {
setText('drive_estimator_speed', '--');
setText('drive_estimator_distance', '--');
setText('drive_estimator_pulse', '--');
setText('drive_estimator_rpm', '--');
setText('drive_estimator_mode', '--');
setText('drive_estimator_direction', '--');
return;
}
updateDriveEstimatorModel();
setText('drive_estimator_speed', formatSpeedCms(driveEstimator.speedMps, 1));
setText('drive_estimator_distance', formatNumber(driveEstimator.distanceM, 2));
setText('drive_estimator_pulse', formatNumber(driveEstimator.pulseWidthMs, 3) + ' ms');
setText('drive_estimator_rpm', formatNumber(driveEstimator.rpm, 1) + ' RPM');
setText('drive_estimator_mode', driveEstimator.modeLabel);
setText('drive_estimator_direction', driveEstimator.directionLabel);
}
function resetDriveEstimator() {
driveEstimator.distanceM = 0;
driveEstimator.lastTickMs = Date.now();
renderDriveEstimator();
}
async function fetchDriveEstimatorConfig() {
try {
var response = await fetch(adminApiBase + '/api/drive-estimator/config');
if (!response.ok) throw new Error('status ' + response.status);
var data = await response.json();
var estimator = data.estimator || {};
driveEstimator.drivePwmNeutralAverage = Number(estimator.drive_pwm_neutral_average || driveEstimator.drivePwmNeutralAverage);
driveEstimator.drivePwmRange = Number(estimator.drive_pwm_range || driveEstimator.drivePwmRange);
driveEstimator.pwmFrequencyHz = Number(estimator.pwm_frequency_hz || driveEstimator.pwmFrequencyHz);
driveEstimator.servoMaxRpm = Number(estimator.servo_max_rpm || driveEstimator.servoMaxRpm);
driveEstimator.servoFullScalePulseDeltaMs = Number(estimator.servo_full_scale_pulse_delta_ms || driveEstimator.servoFullScalePulseDeltaMs);
driveEstimator.wheelDiameterM = Number(estimator.wheel_diameter_m_default || driveEstimator.wheelDiameterM);
var input = document.getElementById('drive_estimator_diameter_mm');
if (input) input.value = String(Math.round(driveEstimator.wheelDiameterM * 1000));
driveEstimator.configLoaded = true;
if (!driveEstimator.calibrationLoaded) {
driveEstimator.calibrationPoints = normalizeCalibrationPoints(getDriveEstimatorDefaultCalibration());
driveEstimator.savedCalibrationPoints = normalizeCalibrationPoints(driveEstimator.calibrationPoints);
driveEstimator.calibrationInfo = 'Datenblatt-Kurve';
renderDriveEstimatorCalibration();
}
renderDriveEstimator();
} catch (e) {
setDriveEstimatorState('Config fehlt', 'state_unknown');
}
}
async function loadDriveEstimatorCalibration() {
try {
var response = await fetch(adminApiBase + '/api/drive-estimator/calibration');
if (!response.ok) throw new Error('status ' + response.status);
var data = await response.json();
var calibration = data.calibration || {};
var points = Array.isArray(calibration.points) && calibration.points.length
? calibration.points
: getDriveEstimatorDefaultCalibration();
var useDefaults = calibration.source !== 'saved' && points.every(function(point) {
return Math.abs(Number(point.speed_mps || 0)) < 0.0005;
});
if (useDefaults) points = getDriveEstimatorDefaultCalibration();
driveEstimator.calibrationPoints = normalizeCalibrationPoints(points);
driveEstimator.savedCalibrationPoints = normalizeCalibrationPoints(points);
driveEstimator.calibrationLoaded = true;
driveCalibrationMeasurement.activeIndex = null;
driveCalibrationMeasurement.waitingForMotion = false;
driveCalibrationMeasurement.startedAtMs = 0;
driveCalibrationMeasurement.elapsedMs = 0;
driveEstimator.calibrationInfo = calibration.source === 'saved'
? ('gespeichert: ' + (calibration.updated_at || 'unbekannt'))
: 'Datenblatt-Kurve';
renderDriveEstimatorCalibration();
renderDriveEstimator();
} catch (e) {
driveEstimator.calibrationPoints = normalizeCalibrationPoints(getDriveEstimatorDefaultCalibration());
driveEstimator.savedCalibrationPoints = normalizeCalibrationPoints(driveEstimator.calibrationPoints);
driveEstimator.calibrationLoaded = true;
driveCalibrationMeasurement.activeIndex = null;
driveCalibrationMeasurement.waitingForMotion = false;
driveCalibrationMeasurement.startedAtMs = 0;
driveCalibrationMeasurement.elapsedMs = 0;
driveEstimator.calibrationInfo = 'Fallback: Datenblatt-Kurve';
renderDriveEstimatorCalibration();
renderDriveEstimator();
}
}
async function saveDriveEstimatorCalibration() {
if (!driveEstimator.calibrationLoaded) return;
if (driveCalibrationMeasurement.activeIndex !== null) {
alert('Bitte die laufende Messung erst abschließen oder abbrechen.');
return;
}
try {
var response = await fetch(adminApiBase + '/api/drive-estimator/calibration', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({points: driveEstimator.calibrationPoints})
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
var calibration = data.calibration || {};
var points = Array.isArray(calibration.points) ? calibration.points : driveEstimator.calibrationPoints;
driveEstimator.calibrationPoints = normalizeCalibrationPoints(points);
driveEstimator.savedCalibrationPoints = normalizeCalibrationPoints(points);
driveEstimator.calibrationInfo = calibration.updated_at
? ('gespeichert: ' + calibration.updated_at)
: 'gespeichert';
renderDriveEstimatorCalibration();
setAdminStatus(data.status || 'Kalibrierung gespeichert');
window.setTimeout(fetchStatus, 1500);
} catch (e) {
alert(e.message || 'Die Kalibrierung konnte nicht gespeichert werden.');
}
}
function connectDriveEstimatorRos() {
if (typeof ROSLIB === 'undefined') {
setDriveEstimatorState('ROSLIB fehlt', 'state_inactive');
return;
}
if (ros) return;
ros = new ROSLIB.Ros({
url: 'ws://' + window.location.hostname + ':9090'
});
ros.on('connection', function() {
markAdminRosConnection(true);
setDriveEstimatorState('verbunden', 'state_active');
});
ros.on('close', function() {
markAdminRosConnection(false);
setDriveEstimatorState('getrennt', 'state_inactive');
ros = null;
roverCommandTopic = null;
motorCommandsTopic = null;
window.setTimeout(connectDriveEstimatorRos, 3000);
});
ros.on('error', function() {
markAdminRosConnection(false);
setDriveEstimatorState('Fehler', 'state_unknown');
});
roverCommandTopic = new ROSLIB.Topic({
ros: ros,
name: '/rover_command',
messageType: 'exomy/RoverCommand'
});
roverCommandTopic.subscribe(function(message) {
driveEstimator.motorsEnabled = !!message.motors_enabled;
driveEstimator.locomotionMode = Number(message.locomotion_mode);
driveEstimator.modeLabel = LOCOMOTION_MODE_NAMES[driveEstimator.locomotionMode] || '-';
});
motorCommandsTopic = new ROSLIB.Topic({
ros: ros,
name: '/motor_commands',
messageType: 'exomy/MotorCommands'
});
motorCommandsTopic.subscribe(function(message) {
driveEstimator.motorSpeeds = Array.isArray(message.motor_speeds) ? message.motor_speeds.map(Number) : [0, 0, 0, 0, 0, 0];
renderDriveEstimator();
});
gpsFixTopic = new ROSLIB.Topic({
ros: ros,
name: '/fix',
messageType: 'sensor_msgs/NavSatFix'
});
gpsFixTopic.subscribe(handleGpsFix);
gpsStatusTopic = new ROSLIB.Topic({
ros: ros,
name: '/gps/status_text',
messageType: 'std_msgs/String'
});
gpsStatusTopic.subscribe(function(message) {
updateGpsStatusSummary(message.data);
});
gpsDiagnosticsTopic = new ROSLIB.Topic({
ros: ros,
name: '/gps/diagnostics_json',
messageType: 'std_msgs/String'
});
gpsDiagnosticsTopic.subscribe(handleGpsDiagnostics);
}
function temperatureToWidth(value) {
if (!value || typeof value !== 'string') return '0%';
var n = parseFloat(value.replace('°C', '').replace('°C', '').replace(',', '.'));
if (isNaN(n)) return '0%';
return Math.max(0, Math.min(100, ((n - 20) / 60) * 100)).toFixed(0) + '%';
}
function renderStatus(data) {
setAdminStatus(data.status || 'Bereit');
setText('info_ips', data.system && data.system.ips);
setText('info_wifi', data.system && data.system.wifi);
setText('info_temp', data.system && data.system.cpu_temperature);
var bar = document.getElementById('bar-temp-adm');
if (bar && data.system) bar.style.width = temperatureToWidth(data.system.cpu_temperature);
setUndervoltageState('info_undervoltage', data.system && data.system.undervoltage, data.system && data.system.undervoltage_state);
setText('info_uptime', data.system && data.system.uptime);
setText('info_disk', data.system && data.system.disk_free);
setServiceState('service_camera', data.services && data.services.camera);
setServiceState('camera_service_state', data.services && data.services.camera);
setServiceState('service_admin_api', data.services && data.services.admin_api);
setServiceState('service_exomy', data.services && data.services.exomy);
}
async function fetchStatus() {
try {
var response = await fetch(adminApiBase + '/api/status');
if (!response.ok) throw new Error('status ' + response.status);
markAdminApiConnection(true);
renderStatus(await response.json());
} catch (e) {
markAdminApiConnection(false);
setAdminStatus('API nicht erreichbar');
}
}
async function runAdminAction(action) {
var labels = {
'restart-camera': 'Kameradienst jetzt wirklich neu starten?',
'restart-container': 'ExoMy-Container jetzt wirklich neu starten?',
'reboot': 'Raspberry Pi jetzt wirklich neu starten?',
'shutdown': 'Raspberry Pi jetzt wirklich herunterfahren?'
};
if (!window.confirm(labels[action] || 'Befehl ausführen?')) return;
setAdminStatus('Sende Befehl...');
try {
var response = await fetch(adminApiBase + '/api/' + action, { method: 'POST' });
if (!response.ok) throw new Error('status ' + response.status);
var data = await response.json();
setAdminStatus(data.status || 'Befehl gesendet');
window.setTimeout(fetchStatus, 2000);
} catch (e) {
setAdminStatus('Fehler');
alert('Der Admin-Befehl konnte nicht ausgeführt werden.');
}
}
async function fetchDelay() {
try {
var response = await fetch(adminApiBase + '/api/delay');
if (!response.ok) return;
var data = await response.json();
var sel = document.getElementById('delay_select');
if (sel) sel.value = String(data.delay_seconds);
} catch (e) {}
}
async function setDelay(seconds) {
var statusEl = document.getElementById('delay_status');
if (statusEl) statusEl.textContent = 'Setze…';
try {
var response = await fetch(adminApiBase + '/api/delay', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({delay_seconds: seconds})
});
var data = await response.json();
var sel = document.getElementById('delay_select');
if (sel) sel.value = String(seconds);
if (statusEl) statusEl.textContent = data.status || 'Gesetzt';
window.setTimeout(function() { if (statusEl) statusEl.textContent = ''; }, 2000);
} catch (e) {
if (statusEl) statusEl.textContent = 'Fehler';
}
}
async function fetchSpeedLimit() {
try {
var response = await fetch(adminApiBase + '/api/speed-limit');
if (!response.ok) return;
var data = await response.json();
var sel = document.getElementById('speed_limit_select');
if (sel) sel.value = String(data.speed_limit_percent);
} catch (e) {}
}
async function setSpeedLimit(percent) {
var statusEl = document.getElementById('speed_limit_status');
if (statusEl) statusEl.textContent = 'Setze…';
try {
var response = await fetch(adminApiBase + '/api/speed-limit', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({speed_limit_percent: percent})
});
var data = await response.json();
if (statusEl) statusEl.textContent = data.status || 'Gesetzt';
window.setTimeout(function() { if (statusEl) statusEl.textContent = ''; }, 2000);
} catch (e) {
if (statusEl) statusEl.textContent = 'Fehler';
}
}
function refreshCameraPreview() {
var image = document.getElementById('camera_preview_image');
if (!image) return;
image.src = cameraPreviewBase + '?t=' + Date.now();
}
function renderCameraConfig(data) {
var camera = data && data.camera ? data.camera : {};
var selectedProfile = camera.selected_profile || '';
var select = document.getElementById('camera_profile_select');
if (select && selectedProfile) select.value = selectedProfile;
setText('camera_profile_label', selectedProfile || '--');
setText('camera_resolution_label', (camera.width && camera.height) ? (camera.width + ' x ' + camera.height) : '--');
setText('camera_fps_label', camera.fps ? (camera.fps + ' fps') : '--');
}
async function fetchCameraConfig() {
try {
var response = await fetch(adminApiBase + '/api/camera-config');
if (!response.ok) return;
renderCameraConfig(await response.json());
} catch (e) {}
}
async function setCameraProfile(profileId) {
var statusEl = document.getElementById('camera_profile_status');
if (statusEl) statusEl.textContent = 'Speichere…';
try {
var response = await fetch(adminApiBase + '/api/camera-config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({profile: profileId})
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || 'Fehler');
renderCameraConfig(data);
if (statusEl) statusEl.textContent = data.status || 'Gespeichert';
window.setTimeout(fetchStatus, 1500);
window.setTimeout(refreshCameraPreview, 2200);
window.setTimeout(fetchCameraConfig, 2500);
window.setTimeout(function() { if (statusEl) statusEl.textContent = ''; }, 3500);
} catch (e) {
if (statusEl) statusEl.textContent = 'Fehler';
}
}
async function runRumble() {
try {
var response = await fetch(adminApiBase + '/api/rumble', { method: 'POST' });
var data = await response.json();
setAdminStatus(data.status || 'Vibration ausgelöst');
window.setTimeout(function() { setAdminStatus('Bereit'); }, 2000);
} catch (e) {
setAdminStatus('Fehler');
}
}
function getSelectedWheel() {
var selected = document.querySelector('input[name="wheel"]:checked');
return selected ? selected.value : 'fl';
}
function renderContainerStatus(data) {
var containerStatus = data && data.container_status;
var containerName = document.getElementById('container_name');
var containerState = document.getElementById('container_state');
var containerSafeState = document.getElementById('container_safe_state');
lastContainerStatus = containerStatus || null;
if (containerName && containerStatus && containerStatus.container) containerName.textContent = containerStatus.container;
setMotorStateNode(containerState, containerStatus && containerStatus.state);
if (containerSafeState) containerSafeState.textContent = containerStatus && containerStatus.is_safe_for_motor_test ? 'Ja' : 'Nein';
}
function shouldWarnBeforeLeaving() {
return lastContainerStatus && lastContainerStatus.is_safe_for_motor_test && !isContainerActionRunning;
}
function confirmLeaveIfNeeded() {
if (!shouldWarnBeforeLeaving()) return true;
return window.confirm('Der ExoMy-Container ist noch gestoppt. Wirklich die Admin-Seite verlassen?');
}
function switchMotorTestTab(tabName) {
activeMotorTestTab = tabName;
var impulseButton = document.getElementById('tab_button_impulse');
var neutralButton = document.getElementById('tab_button_neutral');
var driveNeutralButton = document.getElementById('tab_button_drive_neutral');
var impulsePanel = document.getElementById('tab_panel_impulse');
var neutralPanel = document.getElementById('tab_panel_neutral');
var driveNeutralPanel = document.getElementById('tab_panel_drive_neutral');
var isImpulse = tabName === 'impulse';
var isNeutral = tabName === 'neutral';
var isDriveNeutral = tabName === 'drive-neutral';
impulseButton.classList.toggle('is_active', isImpulse);
neutralButton.classList.toggle('is_active', isNeutral);
driveNeutralButton.classList.toggle('is_active', isDriveNeutral);
impulseButton.setAttribute('aria-selected', isImpulse ? 'true' : 'false');
neutralButton.setAttribute('aria-selected', isNeutral ? 'true' : 'false');
driveNeutralButton.setAttribute('aria-selected', isDriveNeutral ? 'true' : 'false');
impulsePanel.classList.toggle('is_active', isImpulse);
neutralPanel.classList.toggle('is_active', isNeutral);
driveNeutralPanel.classList.toggle('is_active', isDriveNeutral);
impulsePanel.hidden = !isImpulse;
neutralPanel.hidden = !isNeutral;
driveNeutralPanel.hidden = !isDriveNeutral;
if (isNeutral && !Object.keys(steeringNeutralValues).length) loadSteeringNeutralValues();
if (isDriveNeutral && !Object.keys(driveNeutralValues).length) loadDriveNeutralValue();
}
function renderSteeringNeutralCards() {
var grid = document.getElementById('steering_neutral_grid');
if (!grid) return;
var html = [];
WHEEL_ORDER.forEach(function(wheel) {
var entry = steeringNeutralValues[wheel];
if (!entry) return;
var savedEntry = steeringNeutralSavedValues[wheel];
var isDirty = !savedEntry || Number(savedEntry.value) !== Number(entry.value);
html.push(
'<article class="steering_neutral_card steering_neutral_card_compact' + (isDirty ? ' is_dirty' : '') + '">' +
'<div class="steering_neutral_compact_row">' +
'<div class="steering_neutral_card_header steering_neutral_card_header_compact">' +
'<strong class="steering_neutral_title">' + entry.wheel_label + '</strong>' +
'</div>' +
'<div class="steering_neutral_value_row steering_neutral_value_row_compact">' +
'<button class="button buttonWarm steering_neutral_adjust" onclick="adjustSteeringNeutralValue(\'' + wheel + '\', -' + STEERING_NEUTRAL_STEP + ')">-</button>' +
'<strong class="steering_neutral_value">' + entry.value + '</strong>' +
'<button class="button buttonA steering_neutral_adjust" onclick="adjustSteeringNeutralValue(\'' + wheel + '\', ' + STEERING_NEUTRAL_STEP + ')">+</button>' +
'</div>' +
'</div>' +
'</article>'
);
});
grid.innerHTML = html.join('');
updateNeutralSaveButton();
}
function hasUnsavedNeutralChanges() {
return WHEEL_ORDER.some(function(wheel) {
var current = steeringNeutralValues[wheel];
var saved = steeringNeutralSavedValues[wheel];
if (!current || !saved) return false;
return Number(current.value) !== Number(saved.value);
});
}
function updateNeutralSaveButton() {
var button = document.getElementById('neutral_save_button');
if (button) button.disabled = isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || !hasUnsavedNeutralChanges();
}
function renderDriveNeutralCards() {
var grid = document.getElementById('drive_neutral_grid');
if (!grid) return;
var html = [];
WHEEL_ORDER.forEach(function(wheel) {
var entry = driveNeutralValues[wheel];
if (!entry) return;
var savedEntry = driveNeutralSavedValues[wheel];
var isDirty = !savedEntry || Number(savedEntry.value) !== Number(entry.value);
html.push(
'<article class="steering_neutral_card steering_neutral_card_compact' + (isDirty ? ' is_dirty' : '') + '">' +
'<div class="steering_neutral_compact_row">' +
'<div class="steering_neutral_card_header steering_neutral_card_header_compact">' +
'<strong class="steering_neutral_title">' + entry.wheel_label + '</strong>' +
'</div>' +
'<div class="steering_neutral_value_row steering_neutral_value_row_compact">' +
'<button class="button buttonWarm steering_neutral_adjust" onclick="adjustDriveNeutralValue(\'' + wheel + '\', -' + STEERING_NEUTRAL_STEP + ')">-</button>' +
'<strong class="steering_neutral_value">' + entry.value + '</strong>' +
'<button class="button buttonA steering_neutral_adjust" onclick="adjustDriveNeutralValue(\'' + wheel + '\', ' + STEERING_NEUTRAL_STEP + ')">+</button>' +
'</div>' +
'</div>' +
'</article>'
);
});
grid.innerHTML = html.join('');
updateDriveNeutralSaveButton();
}
function hasUnsavedDriveNeutralChanges() {
return WHEEL_ORDER.some(function(wheel) {
var current = driveNeutralValues[wheel];
var saved = driveNeutralSavedValues[wheel];
if (!current || !saved) return false;
return Number(current.value) !== Number(saved.value);
});
}
function updateDriveNeutralSaveButton() {
var button = document.getElementById('drive_neutral_save_button');
if (button) button.disabled = isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving || !hasUnsavedDriveNeutralChanges();
}
async function fetchMotorTestStatus() {
try {
var response = await fetch(adminApiBase + '/api/motor-test/status');
if (!response.ok) throw new Error('status ' + response.status);
renderContainerStatus(await response.json());
} catch (error) {
setMotorTestStatus('API nicht erreichbar');
}
}
async function setContainerState(action) {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) return;
isContainerActionRunning = true;
updateNeutralSaveButton();
updateDriveNeutralSaveButton();
setMotorTestStatus(action === 'stop' ? 'Stoppe Container...' : 'Starte Container...');
try {
var response = await fetch(adminApiBase + '/api/motor-test/' + action + '-container', { method: 'POST' });
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
setMotorTestStatus(data.status || 'Fertig');
renderContainerStatus(data);
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Container-Aktion konnte nicht ausgeführt werden.');
} finally {
isContainerActionRunning = false;
updateNeutralSaveButton();
updateDriveNeutralSaveButton();
}
}
async function runMotorTest(action) {
if (isMotorTestRunning || isContainerActionRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) return;
isMotorTestRunning = true;
updateNeutralSaveButton();
updateDriveNeutralSaveButton();
setMotorTestStatus('Teste...');
try {
var response = await fetch(adminApiBase + '/api/motor-test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ wheel: getSelectedWheel(), action: action })
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
setMotorTestStatus(data.status || 'Test beendet');
renderContainerStatus(data);
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Der Motortest konnte nicht ausgeführt werden.');
} finally {
isMotorTestRunning = false;
updateNeutralSaveButton();
updateDriveNeutralSaveButton();
}
}
async function loadSteeringNeutralValues() {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) return;
isNeutralLoading = true;
updateNeutralSaveButton();
setMotorTestStatus('Lade Servo-Mitten...');
try {
var response = await fetch(adminApiBase + '/api/motor-test/steering-neutral');
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
steeringNeutralValues = data.neutral_values && data.neutral_values.values ? data.neutral_values.values : {};
steeringNeutralSavedValues = JSON.parse(JSON.stringify(steeringNeutralValues));
renderSteeringNeutralCards();
renderContainerStatus(data);
setMotorTestStatus(data.status || 'Servo-Mitten geladen');
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Servo-Mitten konnten nicht geladen werden.');
} finally {
isNeutralLoading = false;
updateNeutralSaveButton();
}
}
async function adjustSteeringNeutralValue(wheel, delta) {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) return;
var entry = steeringNeutralValues[wheel];
if (!entry) return;
steeringNeutralValues[wheel].value = Math.max(0, Math.min(4095, Number(entry.value) + Number(delta)));
renderSteeringNeutralCards();
isNeutralPreviewRunning = true;
updateNeutralSaveButton();
setMotorTestStatus('Fahre Servo-Mitte an...');
try {
var response = await fetch(adminApiBase + '/api/motor-test/steering-neutral/preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ wheel: wheel, value: steeringNeutralValues[wheel].value })
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
renderContainerStatus(data);
setMotorTestStatus(data.status || 'Servo angefahren');
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Servo-Vorschau konnte nicht ausgeführt werden.');
} finally {
isNeutralPreviewRunning = false;
updateNeutralSaveButton();
}
}
async function saveSteeringNeutralValues() {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving || !hasUnsavedNeutralChanges()) return;
var payload = {};
WHEEL_ORDER.forEach(function(wheel) {
var entry = steeringNeutralValues[wheel];
if (entry) payload[entry.key] = Number(entry.value);
});
isNeutralSaving = true;
updateNeutralSaveButton();
setMotorTestStatus('Speichere Servo-Mitten...');
try {
var response = await fetch(adminApiBase + '/api/motor-test/steering-neutral/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ values: payload })
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
steeringNeutralValues = data.neutral_values && data.neutral_values.values ? data.neutral_values.values : steeringNeutralValues;
steeringNeutralSavedValues = JSON.parse(JSON.stringify(steeringNeutralValues));
renderSteeringNeutralCards();
renderContainerStatus(data);
setMotorTestStatus(data.status || 'Servo-Mitten gespeichert');
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Servo-Mitten konnten nicht gespeichert werden.');
} finally {
isNeutralSaving = false;
updateNeutralSaveButton();
}
}
async function loadDriveNeutralValue() {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) return;
isDriveNeutralLoading = true;
updateDriveNeutralSaveButton();
setMotorTestStatus('Lade Fahr-Neutralwerte...');
try {
var response = await fetch(adminApiBase + '/api/motor-test/drive-neutral');
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
driveNeutralValues = data.drive_neutral && data.drive_neutral.values ? data.drive_neutral.values : {};
driveNeutralSavedValues = JSON.parse(JSON.stringify(driveNeutralValues));
renderDriveNeutralCards();
renderContainerStatus(data);
setMotorTestStatus(data.status || 'Fahr-Neutralwerte geladen');
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Fahr-Neutralwerte konnten nicht geladen werden.');
} finally {
isDriveNeutralLoading = false;
updateDriveNeutralSaveButton();
}
}
async function adjustDriveNeutralValue(wheel, delta) {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) return;
var entry = driveNeutralValues[wheel];
if (!entry) return;
entry.value = Math.max(0, Math.min(4095, Number(entry.value) + Number(delta)));
renderDriveNeutralCards();
isDriveNeutralPreviewRunning = true;
updateDriveNeutralSaveButton();
setMotorTestStatus('Fahre Fahr-Neutralwerte an...');
var values = {};
WHEEL_ORDER.forEach(function(key) {
if (driveNeutralValues[key]) values[key] = Number(driveNeutralValues[key].value);
});
try {
var response = await fetch(adminApiBase + '/api/motor-test/drive-neutral/preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ values: values })
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
driveNeutralValues = data.drive_neutral && data.drive_neutral.values ? data.drive_neutral.values : driveNeutralValues;
renderDriveNeutralCards();
renderContainerStatus(data);
setMotorTestStatus(data.status || 'Fahr-Neutralwerte angefahren');
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Fahr-Vorschau konnte nicht ausgeführt werden.');
} finally {
isDriveNeutralPreviewRunning = false;
updateDriveNeutralSaveButton();
}
}
async function saveDriveNeutralValue() {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving || !hasUnsavedDriveNeutralChanges()) return;
isDriveNeutralSaving = true;
updateDriveNeutralSaveButton();
setMotorTestStatus('Speichere Fahr-Neutralwerte...');
var values = {};
WHEEL_ORDER.forEach(function(wheel) {
var entry = driveNeutralValues[wheel];
if (entry) values[entry.key] = Number(entry.value);
});
try {
var response = await fetch(adminApiBase + '/api/motor-test/drive-neutral/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ values: values })
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
driveNeutralValues = data.drive_neutral && data.drive_neutral.values ? data.drive_neutral.values : driveNeutralValues;
driveNeutralSavedValues = JSON.parse(JSON.stringify(driveNeutralValues));
renderDriveNeutralCards();
renderContainerStatus(data);
setMotorTestStatus(data.status || 'Fahr-Neutralwerte gespeichert');
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Fahr-Neutralwerte konnten nicht gespeichert werden.');
} finally {
isDriveNeutralSaving = false;
updateDriveNeutralSaveButton();
}
}
function updateClock() {
var el = document.getElementById('utc-clock');
if (el) el.textContent = new Date().toUTCString().split(' ')[4];
}
window.addEventListener('load', function() {
var input = document.getElementById('auth_input');
var gpsCanvas = document.getElementById('gps_sky_canvas');
input.focus();
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') submitPassword();
});
if (gpsCanvas) gpsCanvas.addEventListener('click', onGpsSkyCanvasClick);
if (window.location.hash === '#motor') {
switchAdminSection('motor');
} else if (window.location.hash === '#camera') {
switchAdminSection('camera');
} else if (window.location.hash === '#drive') {
switchAdminSection('drive');
} else if (window.location.hash === '#gps') {
switchAdminSection('gps');
} else if (window.location.hash === '#map') {
switchAdminSection('map');
}
updateClock();
updatePageConnectionState();
window.setInterval(updateClock, 1000);
fetchStatus();
fetchCameraConfig();
fetchDelay();
fetchSpeedLimit();
fetchMotorTestStatus();
fetchDriveEstimatorConfig();
loadDriveEstimatorCalibration();
refreshRouteLibrary();
connectDriveEstimatorRos();
renderWaypointList();
applyWaypointPlanningState();
renderDriveEstimator();
updateGpsDiagnosticsPanel();
refreshCameraPreview();
window.setInterval(fetchStatus, 10000);
window.setInterval(fetchCameraConfig, 10000);
window.setInterval(fetchDelay, 5000);
window.setInterval(fetchSpeedLimit, 5000);
window.setInterval(fetchMotorTestStatus, 4000);
window.setInterval(renderDriveEstimator, 250);
window.setInterval(updatePageConnectionState, 1000);
});
window.addEventListener('beforeunload', function(event) {
if (!shouldWarnBeforeLeaving()) return;
event.preventDefault();
event.returnValue = '';
});
</script>
</body>
</html>