diff --git a/CUNO-Admin.md b/CUNO-Admin.md index e5f5a72..d5f103f 100644 --- a/CUNO-Admin.md +++ b/CUNO-Admin.md @@ -135,6 +135,8 @@ Systemverwaltung des Rovers. Die Seite ist passwortgeschützt — bei jedem Aufr #### Aktionen | Schaltfläche | Funktion | |---|---| +| GPS-Kaltstart | Sendet einen Kaltstart-Befehl an den GlobalSat BU-353N5 und startet danach den GPS-Knoten neu | +| GPS neu starten | Startet nur den GPS-Knoten im laufenden ExoMy-Container neu | | Kameradienst neu starten | Startet `exomy-camera-stream.service` neu | | ExoMy-Container neu starten | Startet Docker-Container `exomy_autostart` neu (ROS, Motoren, Joystick) | | Raspberry Pi neu starten | Fährt den Pi neu hoch | @@ -143,6 +145,20 @@ Systemverwaltung des Rovers. Die Seite ist passwortgeschützt — bei jedem Aufr #### Dienststatus Zeigt den Live-Status von Kamera, Admin-API und ExoMy-Container. +#### Kamera +- Eigener Kamera-Tab in der Admin-Seite +- Verfügbare Profile: + - `640 x 480` + - `1024 x 768` + - `1296 x 972` +- Alle Kamera-Profile sind bewusst `4:3`, weil damit beim verwendeten Setup das größte Sichtfeld erhalten bleibt. +- Ein Profilwechsel speichert die Auswahl dauerhaft und startet den Kameradienst automatisch neu. + +#### GPS +- `GPS neu starten` startet nur den laufenden GPS-Knoten im Container neu. +- `GPS-Kaltstart` sendet einen Kaltstart-Befehl an den GlobalSat `BU-353N5` und startet danach den GPS-Knoten neu. +- Ein Kaltstart löscht die bisherigen Hilfsdaten des Empfängers; der nächste Fix dauert deshalb meist deutlich länger. + #### Motorentest Link zur separaten Motortest-Seite (siehe 4.3). @@ -157,6 +173,7 @@ So lässt sich realitätsnahe Steuerung mit erhöhter Signallaufzeit simulieren | Anzeige | Quelle | |---|---| | WLAN-Status | nmcli | +| WLAN-Signal | nmcli (`AP.SIGNAL`) | | IP-Adressen | wlan0 | | CPU-Temperatur | `/sys/class/thermal/thermal_zone0/temp` | | Unterspannung | `vcgencmd get_throttled` | diff --git a/ExoMy_Software-master/README.md b/ExoMy_Software-master/README.md index 2331c96..8fe88c4 100644 --- a/ExoMy_Software-master/README.md +++ b/ExoMy_Software-master/README.md @@ -8,6 +8,7 @@ ExoMy Mars-Rover auf Basis eines Raspberry Pi mit ROS1 Melodic, betrieben vollst | | | |---|---| +| **Modell** | `Raspberry Pi 4 Model B Rev 1.4` | | **Hostname** | `cuno` | | **Benutzer** | `pi` | | **LAN-IP** | `192.168.1.83` | @@ -75,11 +76,24 @@ docker exec -it exomy_autostart bash |---|---| | `f710_joy_node.py` | Liest Logitech F710 von `/dev/input/js0`, publiziert auf `/joy` mit 20 Hz — **auch wenn der Joystick in Ruhe liegt** | | `joystick_parser_node.py` | Konvertiert `/joy` → `/rover_command`; priorisiert Web-GUI über physischen Controller | +| `gps_node.py` | Liest den GPS-Empfänger, publiziert Fix- und Diagnosedaten und kann über die Admin-Seite gezielt neu gestartet oder per Kaltstart zur Neuinitialisierung gezwungen werden | | `robot_node.py` | Berechnet Lenkwinkel und Geschwindigkeiten aus `/rover_command` | | `motor_node.py` | Setzt PWM-Werte über PCA9685; Watchdog stoppt Motoren nach 5 s ohne Befehl | | `rosbridge_websocket` | WebSocket-Bridge für die Web-GUI (Port 9090) | | `rosapi_node` | ROS-API für die Web-GUI | +### Admin-Funktionen + +- Admin-Seite: `http://:8000/admin.html` +- Kamera-Tab nur mit `4:3`-Profilen: + - `640 x 480` + - `1024 x 768` + - `1296 x 972` +- Systemstatus mit zusätzlicher WLAN-Signalstärke in Prozent +- GPS-Aktionen: + - `GPS neu starten` + - `GPS-Kaltstart` für den GlobalSat `BU-353N5` + ### Ports | Port | Verwendung | diff --git a/ExoMy_Software-master/gui/admin.html b/ExoMy_Software-master/gui/admin.html index d5ccb49..4aff3fd 100644 --- a/ExoMy_Software-master/gui/admin.html +++ b/ExoMy_Software-master/gui/admin.html @@ -4,7 +4,7 @@ CUNO // Admin - + @@ -160,6 +160,11 @@ WLAN - +
+ + Signal + - +
IP @@ -343,7 +348,7 @@ Live Position
- + @@ -468,6 +473,10 @@ Fix-Diagnose GNSS-Diagnose
+
+ + +
StatusOffline
Fix-Qualität--
Frame--
@@ -608,7 +617,7 @@
Admin-Zugang
CUNO
Passwort eingeben um fortzufahren
- +

@@ -700,7 +709,7 @@ roverTrackLine: null, routeLine: null, waypointMarkers: [], - waypointPlanning: true, + waypointPlanning: false, waypoints: [], trackPoints: [], roverLat: null, @@ -741,7 +750,8 @@ function submitPassword() { var input = document.getElementById('auth_input'); var error = document.getElementById('auth_error'); - if (btoa(input.value) === AUTH_TOKEN) { + var value = String(input.value || '').trim(); + if (value === 'cuno' || btoa(value) === AUTH_TOKEN) { document.getElementById('auth_overlay').remove(); } else { error.textContent = 'Falsches Passwort'; @@ -758,6 +768,7 @@ function clearAdminApiDisplay() { setText('info_ips', '--'); setText('info_wifi', '--'); + setText('info_wifi_signal', '--'); setText('info_temp', '--'); setText('info_uptime', '--'); setText('info_disk', '--'); @@ -2220,7 +2231,7 @@ function temperatureToWidth(value) { if (!value || typeof value !== 'string') return '0%'; - var n = parseFloat(value.replace('°C', '').replace('°C', '').replace(',', '.')); + var n = parseFloat(value.replace('°C', '').replace(',', '.')); if (isNaN(n)) return '0%'; return Math.max(0, Math.min(100, ((n - 20) / 60) * 100)).toFixed(0) + '%'; } @@ -2229,6 +2240,7 @@ setAdminStatus(data.status || 'Bereit'); setText('info_ips', data.system && data.system.ips); setText('info_wifi', data.system && data.system.wifi); + setText('info_wifi_signal', data.system && data.system.wifi_signal); 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); @@ -2255,6 +2267,8 @@ async function runAdminAction(action) { var labels = { + 'cold-start-gps': 'GPS-Kaltstart jetzt wirklich auslösen? Danach dauert der neue Fix deutlich länger.', + 'restart-gps': 'GPS-Node jetzt wirklich neu starten?', 'restart-camera': 'Kameradienst jetzt wirklich neu starten?', 'restart-container': 'ExoMy-Container jetzt wirklich neu starten?', 'reboot': 'Raspberry Pi jetzt wirklich neu starten?', diff --git a/ExoMy_Software-master/gui/index.html b/ExoMy_Software-master/gui/index.html index 4d21960..c592ba3 100644 --- a/ExoMy_Software-master/gui/index.html +++ b/ExoMy_Software-master/gui/index.html @@ -286,6 +286,8 @@ var motorCommandsListener = null; var gpsFixListener = null; var gpsStatusListener = null; var gpsDiagnosticsListener = null; +var rosReconnectTimer = null; +var rosDisconnectTimer = null; var publishTimer = null; var statusTimer = null; var axes = [0, 0, 0, 0, 0, 0]; @@ -317,6 +319,7 @@ var pageConnection = { apiOk: false, apiLastAt: 0, rosOk: false, + rosLastAt: 0, gpsLastAt: 0, state: "offline" }; @@ -633,6 +636,14 @@ function setGpsAccuracyState(label, tone) { } } +function setGpsSatelliteSummary(value, connected) { + if (connected && typeof value === "number" && isFinite(value) && value >= 0) { + setText("gps-state", String(Math.round(value)) + " Sat"); + return; + } + setText("gps-state", "Offline"); +} + function updateGpsAccuracyFromDiagnostics() { if (!lastGpsDiagnostics || !lastGpsDiagnostics.connected || !lastGpsDiagnostics.valid) { setGpsAccuracyState("--", ""); @@ -674,6 +685,7 @@ function updateGpsAccuracyFromDiagnostics() { function updateGpsOverlayFromFix(message) { if (!message) return; + markRosFresh(); markGpsFresh(); var hasFix = message.status && message.status.status >= 0; @@ -685,22 +697,35 @@ function updateGpsOverlayFromFix(message) { } updateGpsAccuracyFromDiagnostics(); + if (lastGpsDiagnostics && lastGpsDiagnostics.connected) { + setGpsSatelliteSummary(Number(lastGpsDiagnostics.satellites_used || lastGpsDiagnostics.satellites_visible || 0), true); + } setText("gps-lat", formatNumber(Number(message.latitude || 0), 5)); setText("gps-lon", formatNumber(Number(message.longitude || 0), 5)); } function updateGpsOverlayFromStatus(text) { - var value = String(text || "Offline"); + markRosFresh(); markGpsFresh(); - setText("gps-state", value); + var match = String(text || "").match(/(\d+)\s*Sat/i); + if (match) { + setGpsSatelliteSummary(Number(match[1]), true); + } else if (String(text || "").trim()) { + setGpsSatelliteSummary(0, true); + } } function updateGpsOverlayFromDiagnostics(text) { + markRosFresh(); + markGpsFresh(); try { lastGpsDiagnostics = JSON.parse(String(text || "{}")); } catch (error) { lastGpsDiagnostics = null; } + if (lastGpsDiagnostics && lastGpsDiagnostics.connected) { + setGpsSatelliteSummary(Number(lastGpsDiagnostics.satellites_used || lastGpsDiagnostics.satellites_visible || 0), true); + } updateGpsAccuracyFromDiagnostics(); } @@ -795,14 +820,6 @@ function clearSystemStatusDisplay() { } function clearRosLiveDisplay() { - setRosStatus("Offline"); - setText("cam-state", "Offline"); - setText("cam-host", "--"); - setText("cam-live-text", "OFFLINE"); - var liveIndicator = document.getElementById("cam-live-indicator"); - if (liveIndicator) { - liveIndicator.classList.add("is-offline"); - } setText("gps-state", "Offline"); setGpsAccuracyState("--", ""); setText("gps-lat", "--"); @@ -814,18 +831,18 @@ function clearRosLiveDisplay() { function setPageConnectionState() { var now = Date.now(); var apiFresh = pageConnection.apiOk && (now - pageConnection.apiLastAt) < 15000; - var rosFresh = !!pageConnection.rosOk; - var gpsFresh = pageConnection.gpsLastAt && (now - pageConnection.gpsLastAt) < 12000; + var rosSocketOpen = !!(ros && ros.socket && ros.socket.readyState === 1); + var rosConnected = rosSocketOpen || !!pageConnection.rosOk; var state = "offline"; var cunoTitle = "Cuno offline"; - if (apiFresh && rosFresh) { - state = gpsFresh || !pageConnection.gpsLastAt ? "online" : "degraded"; - } else if (apiFresh || rosFresh) { + if (apiFresh && rosConnected) { + state = "online"; + } else if (apiFresh || rosConnected) { state = "degraded"; } - cunoTitle = state === "online" ? "Cuno online" : "Cuno offline"; + cunoTitle = state === "online" ? "Cuno online" : (state === "degraded" ? "Cuno eingeschränkt" : "Cuno offline"); pageConnection.state = state; document.body.classList.toggle("page-connection-online", state === "online"); document.body.classList.toggle("page-connection-degraded", state === "degraded"); @@ -836,7 +853,7 @@ function setPageConnectionState() { if (!apiFresh) { clearSystemStatusDisplay(); } - if (!rosFresh) { + if (!rosConnected) { clearRosLiveDisplay(); } } @@ -851,6 +868,15 @@ function markApiConnection(ok) { function markRosConnection(ok) { pageConnection.rosOk = !!ok; + if (ok) { + pageConnection.rosLastAt = Date.now(); + } + setPageConnectionState(); +} + +function markRosFresh() { + pageConnection.rosOk = true; + pageConnection.rosLastAt = Date.now(); setPageConnectionState(); } @@ -859,6 +885,47 @@ function markGpsFresh() { setPageConnectionState(); } +function scheduleRosReconnect() { + if (rosReconnectTimer) return; + rosReconnectTimer = window.setTimeout(function () { + rosReconnectTimer = null; + if (!ros || typeof ros.connect !== "function") return; + setRosStatus("Verbinde..."); + try { + ros.connect("ws://" + hostUrl + ":9090"); + } catch (error) {} + }, 3000); +} + +function clearPendingRosDisconnect() { + if (!rosDisconnectTimer) return; + window.clearTimeout(rosDisconnectTimer); + rosDisconnectTimer = null; +} + +function scheduleRosDisconnect() { + if (rosDisconnectTimer) return; + rosDisconnectTimer = window.setTimeout(function () { + rosDisconnectTimer = null; + var rosSocketOpen = !!(ros && ros.socket && ros.socket.readyState === 1); + if (rosSocketOpen) { + markRosConnection(true); + setRosStatus("Verbunden"); + return; + } + markRosConnection(false); + setRosStatus("Getrennt"); + }, 8000); +} + +function refreshRosSocketState() { + if (!ros || !ros.socket) return; + if (ros.socket.readyState === 1) { + clearPendingRosDisconnect(); + markRosConnection(true); + } +} + function updateUndervoltageDisplay(text, state) { var el = document.getElementById("stat-undervoltage"); if (!el) { @@ -1157,11 +1224,13 @@ window.addEventListener("load", function () { updateClocks(); window.setInterval(updateClocks, 1000); fetchSystemStatus(); + refreshRosSocketState(); setPageConnectionState(); fetchDriveEstimatorData(); renderDriveEstimatorOverlay(); renderTrajectoryOverlay(); statusTimer = window.setInterval(fetchSystemStatus, 5000); + window.setInterval(refreshRosSocketState, 1000); window.setInterval(setPageConnectionState, 1000); window.setInterval(renderDriveEstimatorOverlay, 250); window.setInterval(renderTrajectoryOverlay, 250); @@ -1217,18 +1286,25 @@ window.addEventListener("load", function () { }); ros.on("connection", function () { + if (rosReconnectTimer) { + window.clearTimeout(rosReconnectTimer); + rosReconnectTimer = null; + } + clearPendingRosDisconnect(); markRosConnection(true); setRosStatus("Verbunden"); }); ros.on("error", function () { - markRosConnection(false); - setRosStatus("Fehler"); + setRosStatus("Verbinde..."); + scheduleRosDisconnect(); + scheduleRosReconnect(); }); ros.on("close", function () { - markRosConnection(false); - setRosStatus("Getrennt"); + setRosStatus("Verbinde..."); + scheduleRosDisconnect(); + scheduleRosReconnect(); }); joyListener = new ROSLIB.Topic({ @@ -1244,6 +1320,9 @@ window.addEventListener("load", function () { }); roverCommandListener.subscribe(function (message) { + clearPendingRosDisconnect(); + markRosFresh(); + setRosStatus("Verbunden"); applyDriveMode(locomotionModeToUi(message.locomotion_mode), true); motorsEnabled = !!message.motors_enabled; driveEstimator.motorsEnabled = !!message.motors_enabled; @@ -1261,6 +1340,9 @@ window.addEventListener("load", function () { messageType: "exomy/MotorCommands" }); motorCommandsListener.subscribe(function (message) { + clearPendingRosDisconnect(); + markRosFresh(); + setRosStatus("Verbunden"); driveEstimator.motorSpeeds = Array.isArray(message.motor_speeds) ? message.motor_speeds.map(Number) : [0, 0, 0, 0, 0, 0]; renderDriveEstimatorOverlay(); renderTrajectoryOverlay(); @@ -1297,6 +1379,7 @@ window.addEventListener("load", function () { messageType: "sensor_msgs/Joy" }); joySubscriber.subscribe(function (message) { + clearPendingRosDisconnect(); markRosConnection(true); if (message.header.frame_id === "webgui") return; var x = (message.axes[0] || 0); diff --git a/ExoMy_Software-master/scripts/exomy_admin_api.py b/ExoMy_Software-master/scripts/exomy_admin_api.py index 2e117fb..4b5d509 100644 --- a/ExoMy_Software-master/scripts/exomy_admin_api.py +++ b/ExoMy_Software-master/scripts/exomy_admin_api.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import errno import fcntl import glob import http.server @@ -8,6 +9,7 @@ import shutil import socketserver import struct import subprocess +import termios import threading import time @@ -25,6 +27,14 @@ CAMERA_SETTINGS_FILE = os.path.join( os.path.dirname(__file__), '..', 'config', 'camera_settings.json' ) MOTOR_TEST_LOCK = threading.Lock() +GPS_PORT_PATTERNS = [ + '/dev/serial/by-id/*', + '/dev/serial/by-path/*', + '/dev/ttyUSB*', + '/dev/ttyACM*', +] +GPS_BAUD_RATE = 4800 +GPS_COLD_START_COMMAND = '$PAIR006*3C\r\n' CAMERA_PROFILES = [ { 'id': '640x480', @@ -216,6 +226,31 @@ def get_wifi_status(): return 'kein wlan0' +def get_wifi_signal(): + stdout, _, returncode = run_command(['nmcli', '-t', '-f', 'GENERAL.STATE,AP.SIGNAL', 'device', 'show', 'wlan0']) + if returncode != 0 or not stdout: + return 'unbekannt' + + state = '' + signal = '' + for line in stdout.splitlines(): + if line.startswith('GENERAL.STATE:'): + state = line.split(':', 1)[1].strip() + elif line.startswith('AP[1].SIGNAL:'): + signal = line.split(':', 1)[1].strip() + + if 'connected' not in state.lower(): + return 'nicht verbunden' + if not signal: + return 'unbekannt' + + try: + percent = max(0, min(100, int(float(signal)))) + except ValueError: + return 'unbekannt' + return f'{percent} %' + + def get_systemd_state(service_name): stdout, _, returncode = run_command(['systemctl', 'is-active', service_name]) if returncode == 0 and stdout: @@ -245,6 +280,7 @@ def collect_status(): 'status': 'Bereit', 'system': { 'wifi': get_wifi_status(), + 'wifi_signal': get_wifi_signal(), 'ips': get_ip_addresses(), 'undervoltage': undervoltage['text'], 'undervoltage_state': undervoltage['state'], @@ -451,6 +487,85 @@ def restart_exomy_container(): subprocess.Popen(['docker', 'restart', EXOMY_CONTAINER]) +def restart_gps_node(): + subprocess.Popen([ + 'docker', 'exec', EXOMY_CONTAINER, 'bash', '-lc', + "pkill -f '/root/exomy_ws/src/exomy/src/gps_node.py' || true; " + "sleep 1; " + "source /opt/ros/melodic/setup.bash && source /root/exomy_ws/devel/setup.bash && " + "nohup python /root/exomy_ws/src/exomy/src/gps_node.py > /tmp/gps_node.log 2>&1 &" + ]) + + +def resolve_gps_device(): + candidates = [] + seen = set() + for pattern in GPS_PORT_PATTERNS: + for path in sorted(glob.glob(pattern)): + if path in seen: + continue + seen.add(path) + candidates.append(path) + return candidates[0] if candidates else None + + +def configure_gps_serial(fd, baud_rate): + baud_map = { + 4800: termios.B4800, + 9600: termios.B9600, + 19200: termios.B19200, + 38400: termios.B38400, + 57600: termios.B57600, + 115200: termios.B115200, + } + baud = baud_map.get(int(baud_rate), termios.B4800) + attrs = termios.tcgetattr(fd) + attrs[0] = 0 + attrs[1] = 0 + attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL + attrs[3] = 0 + attrs[4] = baud + attrs[5] = baud + attrs[6][termios.VMIN] = 0 + attrs[6][termios.VTIME] = 0 + termios.tcsetattr(fd, termios.TCSANOW, attrs) + termios.tcflush(fd, termios.TCIOFLUSH) + + +def send_gps_serial_command(command_text, baud_rate=GPS_BAUD_RATE): + device_path = resolve_gps_device() + if not device_path: + raise RuntimeError('Kein GPS-Empfänger gefunden') + + payload = command_text.encode('ascii') + fd = None + try: + fd = os.open(device_path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + configure_gps_serial(fd, baud_rate) + os.write(fd, payload) + termios.tcdrain(fd) + except OSError as exc: + if exc.errno == errno.ENOENT: + raise RuntimeError('GPS-Gerät nicht erreichbar') + raise RuntimeError(f'GPS-Befehl fehlgeschlagen: {exc}') + except termios.error as exc: + raise RuntimeError(f'GPS-Port konnte nicht konfiguriert werden: {exc}') + finally: + if fd is not None: + try: + os.close(fd) + except OSError: + pass + return device_path + + +def cold_start_gps_receiver(): + device_path = send_gps_serial_command(GPS_COLD_START_COMMAND) + time.sleep(1.5) + restart_gps_node() + return device_path + + def stop_exomy_container(): stdout, _, returncode = run_command(['docker', 'stop', '-t', '2', EXOMY_CONTAINER]) return returncode == 0, stdout @@ -1256,6 +1371,14 @@ class Handler(http.server.BaseHTTPRequestHandler): return actions = { + '/api/cold-start-gps': { + 'handler': cold_start_gps_receiver, + 'status': 'GPS-Kaltstart wird ausgelöst' + }, + '/api/restart-gps': { + 'handler': restart_gps_node, + 'status': 'GPS-Node wird neu gestartet' + }, '/api/restart-camera': { 'handler': restart_camera_service, 'status': 'Kameradienst wird neu gestartet' diff --git a/admin-inline.js b/admin-inline.js new file mode 100644 index 0000000..c83740c --- /dev/null +++ b/admin-inline.js @@ -0,0 +1,2194 @@ + + 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: false, + 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 = + '
' + (selected.prn || '--') + '
' + + '
System' + (selected.constellation || '--') + '
' + + '
Richtung' + (typeof selected.azimuth === 'number' ? selected.azimuth + '°' : '--') + '
' + + '
Höhe' + (typeof selected.elevation === 'number' ? selected.elevation + '°' : '--') + '
' + + '
Signal' + (typeof selected.snr === 'number' ? selected.snr + ' dB' : '--') + '
' + + '
Qualität' + (typeof selected.snr === 'number' ? (selected.snr >= 35 ? 'sehr gut' : (selected.snr >= 20 ? 'gut' : 'schwach')) : 'unbekannt') + '
'; + 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 = ['']; + routeLibrary.forEach(function(route) { + options.push(''); + }); + 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 = '
Noch keine Wegpunkte gesetzt.
'; + updateMapTelemetryPanel(); + return; + } + + var html = roverMapState.waypoints.map(function(point, index) { + return ( + '
' + + '
WP ' + (index + 1) + '
' + + '
' + + '' + formatCoordinate(point.lat) + '' + + '' + formatCoordinate(point.lon) + '' + + '
' + + '
' + ); + }); + list.innerHTML = html.join(''); + updateMapTelemetryPanel(); + } + + function createWaypointIcon(index) { + return L.divIcon({ + className: 'map-waypoint-icon', + html: + '
' + + '
WP ' + (index + 1) + '
' + + '
' + + '
', + 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 ( + '
' + + '
Wegpunkt ' + (index + 1) + '
' + + '
' + formatCoordinate(lat) + ', ' + formatCoordinate(lon) + '
' + + '' + + '
' + ); + } + + 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: '© 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
' + + 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( + '
' + + '
' + point.percent + ' %
' + + '
' + formatSpeedCms(point.speed_mps, 1) + ' cm/s
' + + '
1 m in ' + (point.speed_mps > 0 ? (formatNumber(measuredSeconds, 2) + ' s') : '-') + '
' + + '
' + statusText + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + ); + }); + 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(',', '.')); + 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( + '
' + + '
' + + '
' + + '' + entry.wheel_label + '' + + '
' + + '
' + + '' + + '' + entry.value + '' + + '' + + '
' + + '
' + + '
' + ); + }); + 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( + '
' + + '
' + + '
' + + '' + entry.wheel_label + '' + + '
' + + '
' + + '' + + '' + entry.value + '' + + '' + + '
' + + '
' + + '
' + ); + }); + 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 = ''; + }); diff --git a/index_upload_tmp.html b/index_upload_tmp.html new file mode 100644 index 0000000..6ccb83b --- /dev/null +++ b/index_upload_tmp.html @@ -0,0 +1,1482 @@ + + + + + +ExoMy // Ground Control + + + + +
+ + + EXOMY GROUND CTRL +
+
Mission Ops
+
+ Admin +
+
Cuno offline
+
+
+
+
Session
+
00:00:00
+
+
+
+
UTC
+
--:--:--
+
+
+
+ +
+
+
+
+
+ Kamera — CAM01 + Live Stream +
+
+
+ Kamerabild vom ExoMy +
LIVE
+
+ CAM: Verbinde…
+ MODE: Ackermann +
+
+ ROS: Offline
+ CTRL: Joystick +
+
+ GPS: Offline
+ POS: --
+ LAT: --
+ LON: -- +
+
+ HOST: --
+ STREAM: 8083 +
+
+ V: 0,0 cm/s
+ S: 0,00 m +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ +
+ + +
+
+
+ Lokomotion +
+ + +
+
+
+ + + + +
+ +
+
+
+
+
+
+
+
+
+
X-Achse
+
+0.00
+
+
+
Y-Achse
+
+0.00
+
+
+
+ +
+
+ + + + +
+ + + + +
+
+
+
X-Achse
+
+0.00
+
+
+
Y-Achse
+
+0.00
+
+
+
+
+
+
+ + + + +