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 = ''; });