Files
ExoMy_Cuno/admin-inline.js
T
2026-05-25 16:34:07 +02:00

2195 lines
86 KiB
JavaScript

var adminApiBase = window.location.protocol + '//' + window.location.hostname + ':8082';
var cameraPreviewBase = window.location.protocol + '//' + window.location.hostname + ':8081/stream.mjpg';
var AUTH_TOKEN = 'Y3Vubw==';
var activeAdminSection = 'system';
var ros = null;
var roverCommandTopic = null;
var motorCommandsTopic = null;
var gpsFixTopic = null;
var gpsStatusTopic = null;
var gpsDiagnosticsTopic = null;
var pageConnection = {
apiOk: false,
apiLastAt: 0,
rosOk: false,
gpsLastAt: 0,
lastHealthyAt: 0,
state: 'offline'
};
var isMotorTestRunning = false;
var isContainerActionRunning = false;
var isNeutralLoading = false;
var isNeutralPreviewRunning = false;
var isNeutralSaving = false;
var isDriveNeutralLoading = false;
var isDriveNeutralPreviewRunning = false;
var isDriveNeutralSaving = false;
var lastContainerStatus = null;
var activeMotorTestTab = 'impulse';
var steeringNeutralValues = {};
var steeringNeutralSavedValues = {};
var driveNeutralValues = {};
var driveNeutralSavedValues = {};
var STEERING_NEUTRAL_STEP = 5;
var WHEEL_ORDER = ['fl', 'fr', 'cl', 'cr', 'rl', 'rr'];
var REQUIRED_CALIBRATION_POINTS = [0, 20, 30, 40, 50, 60, 80, 100];
var LOCOMOTION_MODE_NAMES = {
0: 'Fake Ackermann',
1: 'Ackermann',
2: 'Punkt-Drehen',
3: 'Crabbing'
};
var driveEstimator = {
configLoaded: false,
calibrationLoaded: false,
drivePwmNeutralAverage: 307,
drivePwmRange: 100,
pwmFrequencyHz: 50,
servoMaxRpm: 50,
servoFullScalePulseDeltaMs: 0.2,
wheelDiameterM: 0.1,
lastTickMs: Date.now(),
distanceM: 0,
speedMps: 0,
rpm: 0,
pulseWidthMs: 0,
directionLabel: 'Stopp',
modeLabel: '-',
calibrationInfo: '',
motorsEnabled: false,
locomotionMode: null,
motorSpeeds: [0, 0, 0, 0, 0, 0],
calibrationPoints: [],
savedCalibrationPoints: []
};
var routeLibrary = [];
var driveCalibrationMeasurement = {
distanceM: 1.0,
activeIndex: null,
waitingForMotion: false,
startedAtMs: 0,
elapsedMs: 0,
stopCandidateStartedAtMs: 0,
stopDebounceMs: 400,
startThresholdPercent: 2,
stopThresholdPercent: 1
};
var roverMapState = {
map: null,
mapReady: false,
roverMarker: null,
roverAccuracyCircle: null,
roverTrackLine: null,
routeLine: null,
waypointMarkers: [],
waypointPlanning: 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 =
'<div class="gps-satellite-tooltip-title">' + (selected.prn || '--') + '</div>' +
'<div class="gps-satellite-tooltip-row"><span>System</span><strong>' + (selected.constellation || '--') + '</strong></div>' +
'<div class="gps-satellite-tooltip-row"><span>Richtung</span><strong>' + (typeof selected.azimuth === 'number' ? selected.azimuth + '°' : '--') + '</strong></div>' +
'<div class="gps-satellite-tooltip-row"><span>Höhe</span><strong>' + (typeof selected.elevation === 'number' ? selected.elevation + '°' : '--') + '</strong></div>' +
'<div class="gps-satellite-tooltip-row"><span>Signal</span><strong>' + (typeof selected.snr === 'number' ? selected.snr + ' dB' : '--') + '</strong></div>' +
'<div class="gps-satellite-tooltip-row"><span>Qualität</span><strong>' + (typeof selected.snr === 'number' ? (selected.snr >= 35 ? 'sehr gut' : (selected.snr >= 20 ? 'gut' : 'schwach')) : 'unbekannt') + '</strong></div>';
node.style.left = leftPx + 'px';
node.style.top = topPx + 'px';
node.hidden = false;
}
function drawGpsSkyPlot() {
var canvas = document.getElementById('gps_sky_canvas');
if (!canvas || !canvas.getContext) return;
var ctx = canvas.getContext('2d');
var width = canvas.width;
var height = canvas.height;
var cx = width / 2;
var cy = height / 2;
var radius = Math.min(width, height) * 0.42;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#07111c';
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(0,255,145,.18)';
ctx.lineWidth = 1;
[radius, radius * 0.66, radius * 0.33].forEach(function(ring) {
ctx.beginPath();
ctx.arc(cx, cy, ring, 0, Math.PI * 2);
ctx.stroke();
});
ctx.beginPath();
ctx.moveTo(cx, cy - radius);
ctx.lineTo(cx, cy + radius);
ctx.moveTo(cx - radius, cy);
ctx.lineTo(cx + radius, cy);
ctx.stroke();
ctx.fillStyle = 'rgba(190,255,225,.84)';
ctx.font = '16px Consolas';
ctx.textAlign = 'center';
ctx.fillText('N', cx, cy - radius - 12);
ctx.fillText('S', cx, cy + radius + 24);
ctx.fillText('W', cx - radius - 18, cy + 6);
ctx.fillText('E', cx + radius + 18, cy + 6);
ctx.fillStyle = 'rgba(150,255,210,.5)';
ctx.font = '12px Consolas';
ctx.fillText('30°', cx, cy - radius * 0.33 + 4);
ctx.fillText('60°', cx, cy - radius * 0.66 + 4);
ctx.fillText('90°', cx, cy + 4);
gpsDiagnosticsState.plottedSatellites = [];
gpsDiagnosticsState.satellites.forEach(function(item) {
if (typeof item.azimuth !== 'number' || typeof item.elevation !== 'number') return;
var satRadius = radius * (1 - Math.max(0, Math.min(90, item.elevation)) / 90);
var angle = (item.azimuth - 90) * Math.PI / 180.0;
var x = cx + Math.cos(angle) * satRadius;
var y = cy + Math.sin(angle) * satRadius;
var snr = typeof item.snr === 'number' ? item.snr : 0;
var isSelected = item.prn === gpsDiagnosticsState.selectedSatellitePrn;
ctx.beginPath();
ctx.fillStyle = snr >= 35 ? '#00ff91' : (snr >= 20 ? '#ffb300' : '#ff6b6b');
ctx.arc(x, y, isSelected ? 13 : 10, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = isSelected ? '#ffffff' : 'rgba(5,17,29,.9)';
ctx.lineWidth = isSelected ? 3 : 2;
ctx.stroke();
ctx.fillStyle = '#041017';
ctx.font = 'bold 10px Consolas';
ctx.textAlign = 'center';
ctx.fillText(String(item.prn || '?'), x, y + 3);
gpsDiagnosticsState.plottedSatellites.push({
prn: item.prn,
x: x,
y: y,
radius: isSelected ? 13 : 10
});
});
}
function onGpsSkyCanvasClick(event) {
var canvas = document.getElementById('gps_sky_canvas');
if (!canvas) return;
var rect = canvas.getBoundingClientRect();
var scaleX = canvas.width / rect.width;
var scaleY = canvas.height / rect.height;
var x = (event.clientX - rect.left) * scaleX;
var y = (event.clientY - rect.top) * scaleY;
var selectedPrn = null;
gpsDiagnosticsState.plottedSatellites.forEach(function(item) {
var dx = x - item.x;
var dy = y - item.y;
if ((dx * dx + dy * dy) <= (item.radius + 6) * (item.radius + 6)) {
selectedPrn = item.prn;
}
});
if (!selectedPrn) {
hideGpsSatelliteTooltip();
return;
}
gpsDiagnosticsState.selectedSatellitePrn = selectedPrn;
drawGpsSkyPlot();
var tooltipLeft = Math.min(rect.width - 18, (event.clientX - rect.left) + 18);
var tooltipTop = Math.max(18, (event.clientY - rect.top) - 18);
renderGpsSatelliteTooltip(tooltipLeft, tooltipTop);
}
function handleGpsDiagnostics(message) {
if (!message || !message.data) return;
try {
var data = JSON.parse(message.data);
gpsDiagnosticsState.connected = !!data.connected;
gpsDiagnosticsState.frameId = data.frame_id || 'gps';
gpsDiagnosticsState.valid = !!data.valid;
gpsDiagnosticsState.fixQuality = Number(data.fix_quality || 0);
gpsDiagnosticsState.satellitesUsed = Number(data.satellites_used || 0);
gpsDiagnosticsState.satellitesVisible = Number(data.satellites_visible || 0);
gpsDiagnosticsState.latitude = typeof data.latitude === 'number' ? data.latitude : null;
gpsDiagnosticsState.longitude = typeof data.longitude === 'number' ? data.longitude : null;
gpsDiagnosticsState.altitudeM = typeof data.altitude_m === 'number' ? data.altitude_m : null;
gpsDiagnosticsState.hdop = typeof data.hdop === 'number' ? data.hdop : null;
gpsDiagnosticsState.pdop = typeof data.pdop === 'number' ? data.pdop : null;
gpsDiagnosticsState.vdop = typeof data.vdop === 'number' ? data.vdop : null;
gpsDiagnosticsState.speedMps = typeof data.speed_mps === 'number' ? data.speed_mps : null;
gpsDiagnosticsState.speedKmh = typeof data.speed_kmh === 'number' ? data.speed_kmh : null;
gpsDiagnosticsState.trackDeg = typeof data.track_deg === 'number' ? data.track_deg : null;
gpsDiagnosticsState.utcDate = data.utc_date || null;
gpsDiagnosticsState.utcTime = data.utc_time || null;
gpsDiagnosticsState.lastFixAgeS = typeof data.last_fix_age_s === 'number' ? data.last_fix_age_s : null;
gpsDiagnosticsState.satellites = Array.isArray(data.satellites) ? data.satellites : [];
if (gpsDiagnosticsState.selectedSatellitePrn) {
var stillExists = gpsDiagnosticsState.satellites.some(function(item) {
return item.prn === gpsDiagnosticsState.selectedSatellitePrn;
});
if (!stillExists) gpsDiagnosticsState.selectedSatellitePrn = null;
}
updateGpsDiagnosticsPanel();
} catch (error) {}
}
function updateMapTelemetryPanel() {
setText('map_gps_status', roverMapState.gpsStatusText || 'Offline');
setText('map_gps_satellites', roverMapState.satellites);
setText('map_gps_lat', formatCoordinate(roverMapState.roverLat));
setText('map_gps_lon', formatCoordinate(roverMapState.roverLon));
setText('map_gps_alt', formatAltitude(roverMapState.roverAlt));
setText('map_gps_accuracy', formatAccuracy(roverMapState.accuracyMeters));
setText('map_gps_last_fix', formatFixTime(roverMapState.lastFixAt));
setText('map_route_mode', roverMapState.waypointPlanning ? 'aktiv' : 'aus');
setText('map_waypoint_count', roverMapState.waypoints.length);
}
function renderRouteLibrary() {
var select = document.getElementById('map_route_select');
if (!select) return;
var current = select.value;
var options = ['<option value="">Route wählen</option>'];
routeLibrary.forEach(function(route) {
options.push('<option value="' + route.name.replace(/"/g, '&quot;') + '">' + route.name + '</option>');
});
select.innerHTML = options.join('');
var stillExists = routeLibrary.some(function(route) {
return route.name === current;
});
select.value = stillExists ? current : '';
}
function applyWaypointPlanningState() {
var button = document.getElementById('map_plan_toggle');
if (button) {
button.textContent = 'Route planen: ' + (roverMapState.waypointPlanning ? 'an' : 'aus');
button.classList.toggle('is_active', roverMapState.waypointPlanning);
}
var mapNode = document.getElementById('rover_map');
if (mapNode) {
mapNode.classList.toggle('is_planning', roverMapState.waypointPlanning);
}
updateMapTelemetryPanel();
}
function renderWaypointList() {
var list = document.getElementById('map_waypoint_list');
if (!list) return;
if (!roverMapState.waypoints.length) {
list.innerHTML = '<div class="map-waypoint-empty">Noch keine Wegpunkte gesetzt.</div>';
updateMapTelemetryPanel();
return;
}
var html = roverMapState.waypoints.map(function(point, index) {
return (
'<div class="map-waypoint-item">' +
'<div class="map-waypoint-index">WP ' + (index + 1) + '</div>' +
'<div class="map-waypoint-coords">' +
'<strong>' + formatCoordinate(point.lat) + '</strong>' +
'<span>' + formatCoordinate(point.lon) + '</span>' +
'</div>' +
'</div>'
);
});
list.innerHTML = html.join('');
updateMapTelemetryPanel();
}
function createWaypointIcon(index) {
return L.divIcon({
className: 'map-waypoint-icon',
html:
'<div class="map-waypoint-stack">' +
'<div class="map-waypoint-label">WP ' + (index + 1) + '</div>' +
'<div class="map-waypoint-dot"></div>' +
'</div>',
iconSize: [58, 42],
iconAnchor: [29, 34]
});
}
function buildRouteCoordinates() {
var coords = [];
if (roverMapState.hasFix && typeof roverMapState.roverLat === 'number' && typeof roverMapState.roverLon === 'number') {
coords.push([roverMapState.roverLat, roverMapState.roverLon]);
}
roverMapState.waypoints.forEach(function(point) {
coords.push([point.lat, point.lon]);
});
return coords;
}
function renderPlannedRoute() {
if (!roverMapState.mapReady || !roverMapState.routeLine) return;
roverMapState.routeLine.setLatLngs(buildRouteCoordinates());
renderWaypointList();
}
function getWaypointInsertIndexFromRouteClick(latlng) {
if (!roverMapState.waypoints.length) return 0;
var clickLat = Number(latlng.lat);
var clickLon = Number(latlng.lng);
var bestIndex = roverMapState.waypoints.length;
var bestDistance = Number.POSITIVE_INFINITY;
for (var i = 0; i < roverMapState.waypoints.length - 1; i += 1) {
var start = roverMapState.waypoints[i];
var end = roverMapState.waypoints[i + 1];
var ax = Number(start.lon);
var ay = Number(start.lat);
var bx = Number(end.lon);
var by = Number(end.lat);
var px = clickLon;
var py = clickLat;
var abx = bx - ax;
var aby = by - ay;
var abLen2 = abx * abx + aby * aby;
var t = 0;
if (abLen2 > 0) {
t = ((px - ax) * abx + (py - ay) * aby) / abLen2;
t = Math.max(0, Math.min(1, t));
}
var projX = ax + abx * t;
var projY = ay + aby * t;
var dx = px - projX;
var dy = py - projY;
var distance = dx * dx + dy * dy;
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = i + 1;
}
}
return bestIndex;
}
function clearWaypointMarkers() {
roverMapState.waypointMarkers.forEach(function(marker) {
roverMapState.map.removeLayer(marker);
});
roverMapState.waypointMarkers = [];
}
function buildWaypointPopupContent(index, lat, lon) {
return (
'<div class="map-waypoint-popup">' +
'<div class="map-waypoint-popup-title">Wegpunkt ' + (index + 1) + '</div>' +
'<div class="map-waypoint-popup-coords">' + formatCoordinate(lat) + ', ' + formatCoordinate(lon) + '</div>' +
'<button class="map-waypoint-delete" onclick="removeWaypoint(' + index + ')">Wegpunkt löschen</button>' +
'</div>'
);
}
function renderWaypointMarkers() {
if (!roverMapState.mapReady) return;
clearWaypointMarkers();
roverMapState.waypoints.forEach(function(point, index) {
var marker = L.marker([point.lat, point.lon], {
title: 'Wegpunkt ' + (index + 1),
icon: createWaypointIcon(index),
draggable: true
}).addTo(roverMapState.map);
marker.bindPopup(buildWaypointPopupContent(index, point.lat, point.lon));
marker.on('drag', function(event) {
var latlng = event.target.getLatLng();
roverMapState.waypoints[index].lat = Number(latlng.lat);
roverMapState.waypoints[index].lon = Number(latlng.lng);
renderPlannedRoute();
});
marker.on('dragend', function(event) {
var latlng = event.target.getLatLng();
roverMapState.waypoints[index].lat = Number(latlng.lat);
roverMapState.waypoints[index].lon = Number(latlng.lng);
event.target.setPopupContent(buildWaypointPopupContent(index, latlng.lat, latlng.lng));
renderWaypointList();
renderPlannedRoute();
});
roverMapState.waypointMarkers.push(marker);
});
renderPlannedRoute();
}
function addWaypointFromMap(latlng) {
roverMapState.waypoints.push({
lat: Number(latlng.lat),
lon: Number(latlng.lng)
});
renderWaypointMarkers();
}
function insertWaypointAtIndex(index, latlng) {
var insertIndex = Math.max(0, Math.min(roverMapState.waypoints.length, Number(index)));
roverMapState.waypoints.splice(insertIndex, 0, {
lat: Number(latlng.lat),
lon: Number(latlng.lng)
});
renderWaypointMarkers();
}
function addWaypointAtMapCenter() {
if (!roverMapState.mapReady || !roverMapState.map) return;
addWaypointFromMap(roverMapState.map.getCenter());
}
function removeWaypoint(index) {
if (index < 0 || index >= roverMapState.waypoints.length) return;
roverMapState.waypoints.splice(index, 1);
renderWaypointMarkers();
}
async function refreshRouteLibrary() {
try {
var response = await fetch(adminApiBase + '/api/routes');
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
routeLibrary = Array.isArray(data.library && data.library.routes) ? data.library.routes : [];
renderRouteLibrary();
} catch (error) {
setAdminStatus(error.message || 'Routen konnten nicht geladen werden');
}
}
function loadSelectedRoute() {
var select = document.getElementById('map_route_select');
if (!select || !select.value) return;
var route = routeLibrary.find(function(item) {
return item.name === select.value;
});
if (!route) return;
roverMapState.waypoints = (route.waypoints || []).map(function(point) {
return {
lat: Number(point.lat),
lon: Number(point.lon)
};
});
var nameInput = document.getElementById('map_route_name');
if (nameInput) nameInput.value = route.name;
renderWaypointMarkers();
setAdminStatus('Route geladen');
}
async function saveCurrentRoute() {
var nameInput = document.getElementById('map_route_name');
var routeName = nameInput ? String(nameInput.value || '').trim() : '';
if (!routeName) {
alert('Bitte einen Routennamen eingeben.');
return;
}
if (!roverMapState.waypoints.length) {
alert('Die Route hat noch keine Wegpunkte.');
return;
}
try {
var response = await fetch(adminApiBase + '/api/routes/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: routeName,
waypoints: roverMapState.waypoints
})
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
routeLibrary = Array.isArray(data.library && data.library.routes) ? data.library.routes : routeLibrary;
renderRouteLibrary();
var select = document.getElementById('map_route_select');
if (select) select.value = routeName;
setAdminStatus(data.status || 'Route gespeichert');
} catch (error) {
alert(error.message || 'Die Route konnte nicht gespeichert werden.');
}
}
async function deleteSelectedRoute() {
var select = document.getElementById('map_route_select');
if (!select || !select.value) {
alert('Bitte zuerst eine gespeicherte Route auswählen.');
return;
}
if (!window.confirm('Diese gespeicherte Route wirklich löschen?')) return;
try {
var response = await fetch(adminApiBase + '/api/routes/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: select.value})
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
routeLibrary = Array.isArray(data.library && data.library.routes) ? data.library.routes : [];
renderRouteLibrary();
setAdminStatus(data.status || 'Route gelöscht');
} catch (error) {
alert(error.message || 'Die Route konnte nicht gelöscht werden.');
}
}
function toggleWaypointPlanning() {
roverMapState.waypointPlanning = !roverMapState.waypointPlanning;
applyWaypointPlanningState();
}
function clearPlannedRoute() {
roverMapState.waypoints = [];
renderWaypointMarkers();
}
function centerMapOnRover() {
if (!roverMapState.mapReady || !roverMapState.hasFix) return;
roverMapState.map.setView([roverMapState.roverLat, roverMapState.roverLon], Math.max(roverMapState.map.getZoom(), 17));
}
function initRoverMap() {
if (roverMapState.mapReady) return;
var fallback = document.getElementById('rover_map_fallback');
var mapNode = document.getElementById('rover_map');
if (!mapNode) return;
if (typeof L === 'undefined') {
return;
}
roverMapState.map = L.map('rover_map', {
zoomControl: true,
attributionControl: true,
maxZoom: 22
}).setView([50.2984, 11.0480], 16);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 22,
maxNativeZoom: 19,
attribution: '&copy; OpenStreetMap'
}).addTo(roverMapState.map);
roverMapState.roverTrackLine = L.polyline([], {
color: '#00ff91',
weight: 3,
opacity: 0.7,
interactive: false
}).addTo(roverMapState.map);
roverMapState.routeLine = L.polyline([], {
color: '#0088FF',
weight: 3,
opacity: 0.85,
interactive: true
}).addTo(roverMapState.map);
roverMapState.routeLine.on('click', function(event) {
roverMapState.suppressMapClickUntil = Date.now() + 250;
if (event.originalEvent) {
if (typeof event.originalEvent.preventDefault === 'function') event.originalEvent.preventDefault();
if (typeof event.originalEvent.stopPropagation === 'function') event.originalEvent.stopPropagation();
}
if (!roverMapState.waypointPlanning) return;
if (roverMapState.waypoints.length < 2) {
addWaypointFromMap(event.latlng);
return;
}
insertWaypointAtIndex(getWaypointInsertIndexFromRouteClick(event.latlng), event.latlng);
});
roverMapState.roverAccuracyCircle = L.circle([50.2984, 11.0480], {
radius: 0,
color: '#00ff91',
weight: 1,
opacity: 0.7,
fillColor: '#00ff91',
fillOpacity: 0.1,
interactive: false
}).addTo(roverMapState.map);
roverMapState.roverMarker = L.circleMarker([50.2984, 11.0480], {
radius: 7,
color: '#05111d',
weight: 2,
fillColor: '#FF8C00',
fillOpacity: 1,
interactive: false
}).addTo(roverMapState.map);
roverMapState.map.on('click', function(event) {
if (Date.now() < roverMapState.suppressMapClickUntil) return;
if (!roverMapState.waypointPlanning) return;
addWaypointFromMap(event.latlng);
});
roverMapState.mapReady = true;
applyWaypointPlanningState();
renderWaypointMarkers();
updateMapTelemetryPanel();
}
function updateRoverPositionOnMap() {
if (!roverMapState.mapReady || !roverMapState.hasFix) return;
var latlng = [roverMapState.roverLat, roverMapState.roverLon];
roverMapState.roverMarker.setLatLng(latlng);
roverMapState.roverMarker.bindPopup(
'Rover<br>' +
formatCoordinate(roverMapState.roverLat) + ', ' + formatCoordinate(roverMapState.roverLon)
);
var accuracy = roverMapState.accuracyMeters || 0;
roverMapState.roverAccuracyCircle.setLatLng(latlng);
roverMapState.roverAccuracyCircle.setRadius(accuracy);
roverMapState.trackPoints.push(latlng);
if (roverMapState.trackPoints.length > 60) roverMapState.trackPoints.shift();
roverMapState.roverTrackLine.setLatLngs(roverMapState.trackPoints);
if (!roverMapState.hasCenteredOnFix) {
roverMapState.map.setView(latlng, 17);
roverMapState.hasCenteredOnFix = true;
}
renderPlannedRoute();
}
function updateGpsStatusSummary(text) {
markAdminGpsFresh();
roverMapState.gpsStatusText = text || 'Offline';
var satMatch = String(roverMapState.gpsStatusText).match(/(\d+)\s*Sat/i);
roverMapState.satellites = satMatch ? satMatch[1] : '--';
updateMapTelemetryPanel();
}
function handleGpsFix(message) {
if (!message || !message.status) return;
markAdminGpsFresh();
roverMapState.hasFix = Number(message.status.status) >= 0;
if (!roverMapState.hasFix) {
roverMapState.roverLat = null;
roverMapState.roverLon = null;
roverMapState.roverAlt = null;
roverMapState.accuracyMeters = null;
updateMapTelemetryPanel();
renderPlannedRoute();
return;
}
roverMapState.roverLat = Number(message.latitude);
roverMapState.roverLon = Number(message.longitude);
roverMapState.roverAlt = Number(message.altitude);
roverMapState.lastFixAt = new Date();
var covariance = Array.isArray(message.position_covariance) ? Number(message.position_covariance[0]) : 0;
roverMapState.accuracyMeters = covariance > 0 ? Math.sqrt(covariance) : null;
updateMapTelemetryPanel();
initRoverMap();
updateRoverPositionOnMap();
}
function formatSpeedCms(speedMps, digits) {
return formatNumber(Number(speedMps || 0) * 100, digits);
}
function setDriveEstimatorState(text, stateClass) {
var node = document.getElementById('drive_estimator_ros_state');
if (!node) return;
node.textContent = text || '-';
node.classList.remove('state_active', 'state_inactive', 'state_unknown');
node.classList.add(stateClass || 'state_unknown');
}
function cloneCalibrationPoints(points) {
return (points || []).map(function(item) {
return {
percent: Number(item.percent),
speed_mps: Number(item.speed_mps)
};
});
}
function normalizeCalibrationPoints(points) {
var source = cloneCalibrationPoints(points).sort(function(a, b) {
return a.percent - b.percent;
});
if (!source.length) source = [{percent: 0, speed_mps: 0}];
return REQUIRED_CALIBRATION_POINTS.map(function(targetPercent) {
var exact = source.find(function(point) {
return Number(point.percent) === Number(targetPercent);
});
if (exact) {
return {
percent: targetPercent,
speed_mps: Number(exact.speed_mps || 0)
};
}
var lower = null;
var upper = null;
source.forEach(function(point) {
if (point.percent < targetPercent) lower = point;
if (!upper && point.percent > targetPercent) upper = point;
});
if (!lower && upper) {
return {
percent: targetPercent,
speed_mps: Number(upper.speed_mps || 0)
};
}
if (lower && !upper) {
return {
percent: targetPercent,
speed_mps: Number(lower.speed_mps || 0)
};
}
if (lower && upper) {
var span = Number(upper.percent) - Number(lower.percent);
var ratio = span <= 0 ? 0 : (targetPercent - Number(lower.percent)) / span;
return {
percent: targetPercent,
speed_mps: Number(lower.speed_mps || 0) + (Number(upper.speed_mps || 0) - Number(lower.speed_mps || 0)) * ratio
};
}
return {
percent: targetPercent,
speed_mps: 0
};
});
}
function getDriveEstimatorDefaultCalibration() {
var wheelDiameterM = getDriveEstimatorDiameterM();
return REQUIRED_CALIBRATION_POINTS.map(function(percent) {
var rpm = driveEstimator.servoMaxRpm * (percent / 100.0);
return {
percent: percent,
speed_mps: (rpm / 60.0) * Math.PI * wheelDiameterM
};
});
}
function hasUnsavedDriveCalibrationChanges() {
if (driveEstimator.calibrationPoints.length !== driveEstimator.savedCalibrationPoints.length) return true;
for (var i = 0; i < driveEstimator.calibrationPoints.length; i += 1) {
var current = driveEstimator.calibrationPoints[i];
var saved = driveEstimator.savedCalibrationPoints[i];
if (!saved) return true;
if (Number(current.percent) !== Number(saved.percent)) return true;
if (Math.abs(Number(current.speed_mps) - Number(saved.speed_mps)) > 0.0005) return true;
}
return false;
}
function updateDriveCalibrationSaveButton() {
var button = document.getElementById('drive_calibration_save_button');
if (button) button.disabled = !driveEstimator.calibrationLoaded || !hasUnsavedDriveCalibrationChanges();
}
function renderDriveEstimatorCalibration() {
var grid = document.getElementById('drive_calibration_grid');
if (!grid) return;
var html = [];
driveEstimator.calibrationPoints.forEach(function(point, index) {
var saved = driveEstimator.savedCalibrationPoints[index];
var isDirty = !saved || Math.abs(Number(point.speed_mps) - Number(saved.speed_mps)) > 0.0005;
var isActive = driveCalibrationMeasurement.activeIndex === index;
var measuredSeconds = point.speed_mps > 0 ? (driveCalibrationMeasurement.distanceM / point.speed_mps) : 0;
var statusText = 'Bereit';
if (isActive && driveCalibrationMeasurement.waitingForMotion) statusText = 'Warte auf Anfahrt';
else if (isActive) statusText = 'Misst: ' + formatNumber(driveCalibrationMeasurement.elapsedMs / 1000.0, 2) + ' s';
html.push(
'<article class="drive-calibration-card' + (isDirty ? ' is_dirty' : '') + (isActive ? ' is_active' : '') + '">' +
'<div class="drive-calibration-percent">' + point.percent + ' %</div>' +
'<div class="drive-calibration-value">' + formatSpeedCms(point.speed_mps, 1) + ' cm/s</div>' +
'<div class="drive-calibration-meta">1 m in ' + (point.speed_mps > 0 ? (formatNumber(measuredSeconds, 2) + ' s') : '-') + '</div>' +
'<div class="drive-calibration-status">' + statusText + '</div>' +
'<div class="drive-calibration-actions">' +
'<button class="act-btn subtle drive-calibration-button" onclick="startDriveCalibrationMeasurement(' + index + ')"' + (driveCalibrationMeasurement.activeIndex !== null && !isActive ? ' disabled' : '') + '>' + (isActive ? 'Aktiv' : '1 m messen') + '</button>' +
'<button class="act-btn drive-calibration-button" onclick="finishDriveCalibrationMeasurement(' + index + ')"' + (isActive ? '' : ' disabled') + '>1 m erreicht</button>' +
'<button class="act-btn warm drive-calibration-button" onclick="cancelDriveCalibrationMeasurement(' + index + ')"' + (isActive ? '' : ' disabled') + '>Abbrechen</button>' +
'</div>' +
'</article>'
);
});
grid.innerHTML = html.join('');
var infoNode = document.getElementById('drive_calibration_info');
if (infoNode) infoNode.textContent = driveEstimator.calibrationInfo || '-';
updateDriveCalibrationSaveButton();
}
function isDriveMeasurementMoving() {
return isTranslationMode() && Math.abs(getAverageMotorSpeedPercent()) >= driveCalibrationMeasurement.startThresholdPercent;
}
function isDriveMeasurementStopped() {
return Math.abs(getAverageMotorSpeedPercent()) <= driveCalibrationMeasurement.stopThresholdPercent;
}
function startDriveCalibrationMeasurement(index) {
if (!driveEstimator.calibrationPoints[index]) return;
driveCalibrationMeasurement.activeIndex = index;
driveCalibrationMeasurement.waitingForMotion = true;
driveCalibrationMeasurement.startedAtMs = 0;
driveCalibrationMeasurement.elapsedMs = 0;
driveCalibrationMeasurement.stopCandidateStartedAtMs = 0;
driveEstimator.calibrationInfo = driveEstimator.calibrationPoints[index].percent + ' %: warte auf Anfahrt';
renderDriveEstimatorCalibration();
}
function finishDriveCalibrationMeasurement(index, automatic) {
if (driveCalibrationMeasurement.activeIndex !== index) return;
if (driveCalibrationMeasurement.waitingForMotion || driveCalibrationMeasurement.elapsedMs <= 0) {
alert('Die Messung hat noch nicht begonnen. Erst losfahren, dann bei 1 m erneut drücken.');
return;
}
var elapsedSeconds = driveCalibrationMeasurement.elapsedMs / 1000.0;
driveEstimator.calibrationPoints[index].speed_mps = driveCalibrationMeasurement.distanceM / elapsedSeconds;
driveEstimator.calibrationInfo = driveEstimator.calibrationPoints[index].percent + ' %: 1 m in ' + formatNumber(elapsedSeconds, 2) + ' s' + (automatic ? ' (auto)' : '');
driveCalibrationMeasurement.activeIndex = null;
driveCalibrationMeasurement.waitingForMotion = false;
driveCalibrationMeasurement.startedAtMs = 0;
driveCalibrationMeasurement.elapsedMs = 0;
driveCalibrationMeasurement.stopCandidateStartedAtMs = 0;
renderDriveEstimatorCalibration();
renderDriveEstimator();
}
function cancelDriveCalibrationMeasurement(index) {
if (driveCalibrationMeasurement.activeIndex !== index) return;
driveCalibrationMeasurement.activeIndex = null;
driveCalibrationMeasurement.waitingForMotion = false;
driveCalibrationMeasurement.startedAtMs = 0;
driveCalibrationMeasurement.elapsedMs = 0;
driveCalibrationMeasurement.stopCandidateStartedAtMs = 0;
driveEstimator.calibrationInfo = 'Messung abgebrochen';
renderDriveEstimatorCalibration();
}
function updateDriveCalibrationMeasurementState() {
if (driveCalibrationMeasurement.activeIndex === null) return;
if (driveCalibrationMeasurement.waitingForMotion) {
if (isDriveMeasurementMoving()) {
driveCalibrationMeasurement.waitingForMotion = false;
driveCalibrationMeasurement.startedAtMs = Date.now();
driveCalibrationMeasurement.elapsedMs = 0;
driveCalibrationMeasurement.stopCandidateStartedAtMs = 0;
driveEstimator.calibrationInfo = driveEstimator.calibrationPoints[driveCalibrationMeasurement.activeIndex].percent + ' %: Messung läuft';
renderDriveEstimatorCalibration();
}
return;
}
if (driveCalibrationMeasurement.startedAtMs > 0) {
var nowMs = Date.now();
var previousTenths = Math.floor(driveCalibrationMeasurement.elapsedMs / 100);
driveCalibrationMeasurement.elapsedMs = Math.max(0, nowMs - driveCalibrationMeasurement.startedAtMs);
var nextTenths = Math.floor(driveCalibrationMeasurement.elapsedMs / 100);
if (nextTenths !== previousTenths) renderDriveEstimatorCalibration();
if (!isDriveMeasurementStopped()) {
driveCalibrationMeasurement.stopCandidateStartedAtMs = 0;
} else if (driveCalibrationMeasurement.stopCandidateStartedAtMs === 0) {
driveCalibrationMeasurement.stopCandidateStartedAtMs = nowMs;
} else if (nowMs - driveCalibrationMeasurement.stopCandidateStartedAtMs >= driveCalibrationMeasurement.stopDebounceMs) {
finishDriveCalibrationMeasurement(driveCalibrationMeasurement.activeIndex, true);
}
}
}
function interpolateCalibrationSpeed(percentMagnitude) {
var points = driveEstimator.calibrationPoints;
if (!points || !points.length) return 0;
var p = Math.max(0, Math.min(100, Math.abs(percentMagnitude)));
if (p <= points[0].percent) return Number(points[0].speed_mps || 0);
for (var i = 1; i < points.length; i += 1) {
var left = points[i - 1];
var right = points[i];
if (p <= right.percent) {
var span = right.percent - left.percent;
if (span <= 0) return Number(right.speed_mps || 0);
var ratio = (p - left.percent) / span;
return Number(left.speed_mps || 0) + (Number(right.speed_mps || 0) - Number(left.speed_mps || 0)) * ratio;
}
}
return Number(points[points.length - 1].speed_mps || 0);
}
function fillDriveEstimatorCalibrationDefaults() {
if (driveCalibrationMeasurement.activeIndex !== null) return;
driveEstimator.calibrationPoints = normalizeCalibrationPoints(getDriveEstimatorDefaultCalibration());
driveEstimator.calibrationInfo = 'Datenblatt-Kurve geladen';
renderDriveEstimatorCalibration();
renderDriveEstimator();
}
function getDriveEstimatorDiameterM() {
var input = document.getElementById('drive_estimator_diameter_mm');
if (!input) return driveEstimator.wheelDiameterM;
var mm = parseFloat(input.value);
if (isNaN(mm)) return driveEstimator.wheelDiameterM;
mm = Math.max(20, Math.min(300, mm));
input.value = String(mm);
driveEstimator.wheelDiameterM = mm / 1000.0;
return driveEstimator.wheelDiameterM;
}
function getAverageMotorSpeedPercent() {
if (!driveEstimator.motorSpeeds || !driveEstimator.motorSpeeds.length) return 0;
var sum = 0;
var count = 0;
driveEstimator.motorSpeeds.forEach(function(value) {
if (typeof value === 'number' && !isNaN(value)) {
sum += value;
count += 1;
}
});
return count ? (sum / count) : 0;
}
function isTranslationMode() {
return driveEstimator.locomotionMode !== 2;
}
function updateDriveEstimatorModel() {
updateDriveCalibrationMeasurementState();
var nowMs = Date.now();
var dt = Math.max(0, (nowMs - driveEstimator.lastTickMs) / 1000.0);
driveEstimator.lastTickMs = nowMs;
var avgSpeedPercent = getAverageMotorSpeedPercent();
var direction = avgSpeedPercent > 0 ? 1 : (avgSpeedPercent < 0 ? -1 : 0);
var pulsePeriodMs = 1000.0 / driveEstimator.pwmFrequencyHz;
var pulseStepMs = pulsePeriodMs / 4096.0;
var pwmValue = driveEstimator.drivePwmNeutralAverage + (avgSpeedPercent / 100.0 * driveEstimator.drivePwmRange);
var pulseWidthMs = pwmValue * pulseStepMs;
var neutralPulseMs = driveEstimator.drivePwmNeutralAverage * pulseStepMs;
var deltaMs = pulseWidthMs - neutralPulseMs;
var rpm = 0;
if (isTranslationMode() && driveEstimator.motorsEnabled) {
rpm = (deltaMs / driveEstimator.servoFullScalePulseDeltaMs) * driveEstimator.servoMaxRpm;
rpm = Math.max(-driveEstimator.servoMaxRpm, Math.min(driveEstimator.servoMaxRpm, rpm));
}
var speedMagnitude = isTranslationMode() && driveEstimator.motorsEnabled
? interpolateCalibrationSpeed(avgSpeedPercent)
: 0;
var speedMps = direction === 0 ? 0 : speedMagnitude * direction;
if (Math.abs(speedMps) < 0.0005) speedMps = 0;
if (dt > 0 && dt < 2.5) driveEstimator.distanceM += Math.abs(speedMps) * dt;
driveEstimator.rpm = rpm;
driveEstimator.speedMps = speedMps;
driveEstimator.pulseWidthMs = pulseWidthMs;
driveEstimator.modeLabel = LOCOMOTION_MODE_NAMES[driveEstimator.locomotionMode] || '-';
driveEstimator.directionLabel = !driveEstimator.motorsEnabled ? 'Motoren aus'
: !isTranslationMode() ? 'Drehen auf der Stelle'
: direction > 0 ? 'Vorwärts'
: direction < 0 ? 'Rückwärts'
: 'Stopp';
}
function renderDriveEstimator() {
if (!pageConnection.rosOk) {
setText('drive_estimator_speed', '--');
setText('drive_estimator_distance', '--');
setText('drive_estimator_pulse', '--');
setText('drive_estimator_rpm', '--');
setText('drive_estimator_mode', '--');
setText('drive_estimator_direction', '--');
return;
}
updateDriveEstimatorModel();
setText('drive_estimator_speed', formatSpeedCms(driveEstimator.speedMps, 1));
setText('drive_estimator_distance', formatNumber(driveEstimator.distanceM, 2));
setText('drive_estimator_pulse', formatNumber(driveEstimator.pulseWidthMs, 3) + ' ms');
setText('drive_estimator_rpm', formatNumber(driveEstimator.rpm, 1) + ' RPM');
setText('drive_estimator_mode', driveEstimator.modeLabel);
setText('drive_estimator_direction', driveEstimator.directionLabel);
}
function resetDriveEstimator() {
driveEstimator.distanceM = 0;
driveEstimator.lastTickMs = Date.now();
renderDriveEstimator();
}
async function fetchDriveEstimatorConfig() {
try {
var response = await fetch(adminApiBase + '/api/drive-estimator/config');
if (!response.ok) throw new Error('status ' + response.status);
var data = await response.json();
var estimator = data.estimator || {};
driveEstimator.drivePwmNeutralAverage = Number(estimator.drive_pwm_neutral_average || driveEstimator.drivePwmNeutralAverage);
driveEstimator.drivePwmRange = Number(estimator.drive_pwm_range || driveEstimator.drivePwmRange);
driveEstimator.pwmFrequencyHz = Number(estimator.pwm_frequency_hz || driveEstimator.pwmFrequencyHz);
driveEstimator.servoMaxRpm = Number(estimator.servo_max_rpm || driveEstimator.servoMaxRpm);
driveEstimator.servoFullScalePulseDeltaMs = Number(estimator.servo_full_scale_pulse_delta_ms || driveEstimator.servoFullScalePulseDeltaMs);
driveEstimator.wheelDiameterM = Number(estimator.wheel_diameter_m_default || driveEstimator.wheelDiameterM);
var input = document.getElementById('drive_estimator_diameter_mm');
if (input) input.value = String(Math.round(driveEstimator.wheelDiameterM * 1000));
driveEstimator.configLoaded = true;
if (!driveEstimator.calibrationLoaded) {
driveEstimator.calibrationPoints = normalizeCalibrationPoints(getDriveEstimatorDefaultCalibration());
driveEstimator.savedCalibrationPoints = normalizeCalibrationPoints(driveEstimator.calibrationPoints);
driveEstimator.calibrationInfo = 'Datenblatt-Kurve';
renderDriveEstimatorCalibration();
}
renderDriveEstimator();
} catch (e) {
setDriveEstimatorState('Config fehlt', 'state_unknown');
}
}
async function loadDriveEstimatorCalibration() {
try {
var response = await fetch(adminApiBase + '/api/drive-estimator/calibration');
if (!response.ok) throw new Error('status ' + response.status);
var data = await response.json();
var calibration = data.calibration || {};
var points = Array.isArray(calibration.points) && calibration.points.length
? calibration.points
: getDriveEstimatorDefaultCalibration();
var useDefaults = calibration.source !== 'saved' && points.every(function(point) {
return Math.abs(Number(point.speed_mps || 0)) < 0.0005;
});
if (useDefaults) points = getDriveEstimatorDefaultCalibration();
driveEstimator.calibrationPoints = normalizeCalibrationPoints(points);
driveEstimator.savedCalibrationPoints = normalizeCalibrationPoints(points);
driveEstimator.calibrationLoaded = true;
driveCalibrationMeasurement.activeIndex = null;
driveCalibrationMeasurement.waitingForMotion = false;
driveCalibrationMeasurement.startedAtMs = 0;
driveCalibrationMeasurement.elapsedMs = 0;
driveEstimator.calibrationInfo = calibration.source === 'saved'
? ('gespeichert: ' + (calibration.updated_at || 'unbekannt'))
: 'Datenblatt-Kurve';
renderDriveEstimatorCalibration();
renderDriveEstimator();
} catch (e) {
driveEstimator.calibrationPoints = normalizeCalibrationPoints(getDriveEstimatorDefaultCalibration());
driveEstimator.savedCalibrationPoints = normalizeCalibrationPoints(driveEstimator.calibrationPoints);
driveEstimator.calibrationLoaded = true;
driveCalibrationMeasurement.activeIndex = null;
driveCalibrationMeasurement.waitingForMotion = false;
driveCalibrationMeasurement.startedAtMs = 0;
driveCalibrationMeasurement.elapsedMs = 0;
driveEstimator.calibrationInfo = 'Fallback: Datenblatt-Kurve';
renderDriveEstimatorCalibration();
renderDriveEstimator();
}
}
async function saveDriveEstimatorCalibration() {
if (!driveEstimator.calibrationLoaded) return;
if (driveCalibrationMeasurement.activeIndex !== null) {
alert('Bitte die laufende Messung erst abschließen oder abbrechen.');
return;
}
try {
var response = await fetch(adminApiBase + '/api/drive-estimator/calibration', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({points: driveEstimator.calibrationPoints})
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
var calibration = data.calibration || {};
var points = Array.isArray(calibration.points) ? calibration.points : driveEstimator.calibrationPoints;
driveEstimator.calibrationPoints = normalizeCalibrationPoints(points);
driveEstimator.savedCalibrationPoints = normalizeCalibrationPoints(points);
driveEstimator.calibrationInfo = calibration.updated_at
? ('gespeichert: ' + calibration.updated_at)
: 'gespeichert';
renderDriveEstimatorCalibration();
setAdminStatus(data.status || 'Kalibrierung gespeichert');
window.setTimeout(fetchStatus, 1500);
} catch (e) {
alert(e.message || 'Die Kalibrierung konnte nicht gespeichert werden.');
}
}
function connectDriveEstimatorRos() {
if (typeof ROSLIB === 'undefined') {
setDriveEstimatorState('ROSLIB fehlt', 'state_inactive');
return;
}
if (ros) return;
ros = new ROSLIB.Ros({
url: 'ws://' + window.location.hostname + ':9090'
});
ros.on('connection', function() {
markAdminRosConnection(true);
setDriveEstimatorState('verbunden', 'state_active');
});
ros.on('close', function() {
markAdminRosConnection(false);
setDriveEstimatorState('getrennt', 'state_inactive');
ros = null;
roverCommandTopic = null;
motorCommandsTopic = null;
window.setTimeout(connectDriveEstimatorRos, 3000);
});
ros.on('error', function() {
markAdminRosConnection(false);
setDriveEstimatorState('Fehler', 'state_unknown');
});
roverCommandTopic = new ROSLIB.Topic({
ros: ros,
name: '/rover_command',
messageType: 'exomy/RoverCommand'
});
roverCommandTopic.subscribe(function(message) {
driveEstimator.motorsEnabled = !!message.motors_enabled;
driveEstimator.locomotionMode = Number(message.locomotion_mode);
driveEstimator.modeLabel = LOCOMOTION_MODE_NAMES[driveEstimator.locomotionMode] || '-';
});
motorCommandsTopic = new ROSLIB.Topic({
ros: ros,
name: '/motor_commands',
messageType: 'exomy/MotorCommands'
});
motorCommandsTopic.subscribe(function(message) {
driveEstimator.motorSpeeds = Array.isArray(message.motor_speeds) ? message.motor_speeds.map(Number) : [0, 0, 0, 0, 0, 0];
renderDriveEstimator();
});
gpsFixTopic = new ROSLIB.Topic({
ros: ros,
name: '/fix',
messageType: 'sensor_msgs/NavSatFix'
});
gpsFixTopic.subscribe(handleGpsFix);
gpsStatusTopic = new ROSLIB.Topic({
ros: ros,
name: '/gps/status_text',
messageType: 'std_msgs/String'
});
gpsStatusTopic.subscribe(function(message) {
updateGpsStatusSummary(message.data);
});
gpsDiagnosticsTopic = new ROSLIB.Topic({
ros: ros,
name: '/gps/diagnostics_json',
messageType: 'std_msgs/String'
});
gpsDiagnosticsTopic.subscribe(handleGpsDiagnostics);
}
function temperatureToWidth(value) {
if (!value || typeof value !== 'string') return '0%';
var n = parseFloat(value.replace('°C', '').replace(',', '.'));
if (isNaN(n)) return '0%';
return Math.max(0, Math.min(100, ((n - 20) / 60) * 100)).toFixed(0) + '%';
}
function renderStatus(data) {
setAdminStatus(data.status || 'Bereit');
setText('info_ips', data.system && data.system.ips);
setText('info_wifi', data.system && data.system.wifi);
setText('info_temp', data.system && data.system.cpu_temperature);
var bar = document.getElementById('bar-temp-adm');
if (bar && data.system) bar.style.width = temperatureToWidth(data.system.cpu_temperature);
setUndervoltageState('info_undervoltage', data.system && data.system.undervoltage, data.system && data.system.undervoltage_state);
setText('info_uptime', data.system && data.system.uptime);
setText('info_disk', data.system && data.system.disk_free);
setServiceState('service_camera', data.services && data.services.camera);
setServiceState('camera_service_state', data.services && data.services.camera);
setServiceState('service_admin_api', data.services && data.services.admin_api);
setServiceState('service_exomy', data.services && data.services.exomy);
}
async function fetchStatus() {
try {
var response = await fetch(adminApiBase + '/api/status');
if (!response.ok) throw new Error('status ' + response.status);
markAdminApiConnection(true);
renderStatus(await response.json());
} catch (e) {
markAdminApiConnection(false);
setAdminStatus('API nicht erreichbar');
}
}
async function runAdminAction(action) {
var labels = {
'restart-camera': 'Kameradienst jetzt wirklich neu starten?',
'restart-container': 'ExoMy-Container jetzt wirklich neu starten?',
'reboot': 'Raspberry Pi jetzt wirklich neu starten?',
'shutdown': 'Raspberry Pi jetzt wirklich herunterfahren?'
};
if (!window.confirm(labels[action] || 'Befehl ausführen?')) return;
setAdminStatus('Sende Befehl...');
try {
var response = await fetch(adminApiBase + '/api/' + action, { method: 'POST' });
if (!response.ok) throw new Error('status ' + response.status);
var data = await response.json();
setAdminStatus(data.status || 'Befehl gesendet');
window.setTimeout(fetchStatus, 2000);
} catch (e) {
setAdminStatus('Fehler');
alert('Der Admin-Befehl konnte nicht ausgeführt werden.');
}
}
async function fetchDelay() {
try {
var response = await fetch(adminApiBase + '/api/delay');
if (!response.ok) return;
var data = await response.json();
var sel = document.getElementById('delay_select');
if (sel) sel.value = String(data.delay_seconds);
} catch (e) {}
}
async function setDelay(seconds) {
var statusEl = document.getElementById('delay_status');
if (statusEl) statusEl.textContent = 'Setze…';
try {
var response = await fetch(adminApiBase + '/api/delay', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({delay_seconds: seconds})
});
var data = await response.json();
var sel = document.getElementById('delay_select');
if (sel) sel.value = String(seconds);
if (statusEl) statusEl.textContent = data.status || 'Gesetzt';
window.setTimeout(function() { if (statusEl) statusEl.textContent = ''; }, 2000);
} catch (e) {
if (statusEl) statusEl.textContent = 'Fehler';
}
}
async function fetchSpeedLimit() {
try {
var response = await fetch(adminApiBase + '/api/speed-limit');
if (!response.ok) return;
var data = await response.json();
var sel = document.getElementById('speed_limit_select');
if (sel) sel.value = String(data.speed_limit_percent);
} catch (e) {}
}
async function setSpeedLimit(percent) {
var statusEl = document.getElementById('speed_limit_status');
if (statusEl) statusEl.textContent = 'Setze…';
try {
var response = await fetch(adminApiBase + '/api/speed-limit', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({speed_limit_percent: percent})
});
var data = await response.json();
if (statusEl) statusEl.textContent = data.status || 'Gesetzt';
window.setTimeout(function() { if (statusEl) statusEl.textContent = ''; }, 2000);
} catch (e) {
if (statusEl) statusEl.textContent = 'Fehler';
}
}
function refreshCameraPreview() {
var image = document.getElementById('camera_preview_image');
if (!image) return;
image.src = cameraPreviewBase + '?t=' + Date.now();
}
function renderCameraConfig(data) {
var camera = data && data.camera ? data.camera : {};
var selectedProfile = camera.selected_profile || '';
var select = document.getElementById('camera_profile_select');
if (select && selectedProfile) select.value = selectedProfile;
setText('camera_profile_label', selectedProfile || '--');
setText('camera_resolution_label', (camera.width && camera.height) ? (camera.width + ' x ' + camera.height) : '--');
setText('camera_fps_label', camera.fps ? (camera.fps + ' fps') : '--');
}
async function fetchCameraConfig() {
try {
var response = await fetch(adminApiBase + '/api/camera-config');
if (!response.ok) return;
renderCameraConfig(await response.json());
} catch (e) {}
}
async function setCameraProfile(profileId) {
var statusEl = document.getElementById('camera_profile_status');
if (statusEl) statusEl.textContent = 'Speichere…';
try {
var response = await fetch(adminApiBase + '/api/camera-config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({profile: profileId})
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || 'Fehler');
renderCameraConfig(data);
if (statusEl) statusEl.textContent = data.status || 'Gespeichert';
window.setTimeout(fetchStatus, 1500);
window.setTimeout(refreshCameraPreview, 2200);
window.setTimeout(fetchCameraConfig, 2500);
window.setTimeout(function() { if (statusEl) statusEl.textContent = ''; }, 3500);
} catch (e) {
if (statusEl) statusEl.textContent = 'Fehler';
}
}
async function runRumble() {
try {
var response = await fetch(adminApiBase + '/api/rumble', { method: 'POST' });
var data = await response.json();
setAdminStatus(data.status || 'Vibration ausgelöst');
window.setTimeout(function() { setAdminStatus('Bereit'); }, 2000);
} catch (e) {
setAdminStatus('Fehler');
}
}
function getSelectedWheel() {
var selected = document.querySelector('input[name="wheel"]:checked');
return selected ? selected.value : 'fl';
}
function renderContainerStatus(data) {
var containerStatus = data && data.container_status;
var containerName = document.getElementById('container_name');
var containerState = document.getElementById('container_state');
var containerSafeState = document.getElementById('container_safe_state');
lastContainerStatus = containerStatus || null;
if (containerName && containerStatus && containerStatus.container) containerName.textContent = containerStatus.container;
setMotorStateNode(containerState, containerStatus && containerStatus.state);
if (containerSafeState) containerSafeState.textContent = containerStatus && containerStatus.is_safe_for_motor_test ? 'Ja' : 'Nein';
}
function shouldWarnBeforeLeaving() {
return lastContainerStatus && lastContainerStatus.is_safe_for_motor_test && !isContainerActionRunning;
}
function confirmLeaveIfNeeded() {
if (!shouldWarnBeforeLeaving()) return true;
return window.confirm('Der ExoMy-Container ist noch gestoppt. Wirklich die Admin-Seite verlassen?');
}
function switchMotorTestTab(tabName) {
activeMotorTestTab = tabName;
var impulseButton = document.getElementById('tab_button_impulse');
var neutralButton = document.getElementById('tab_button_neutral');
var driveNeutralButton = document.getElementById('tab_button_drive_neutral');
var impulsePanel = document.getElementById('tab_panel_impulse');
var neutralPanel = document.getElementById('tab_panel_neutral');
var driveNeutralPanel = document.getElementById('tab_panel_drive_neutral');
var isImpulse = tabName === 'impulse';
var isNeutral = tabName === 'neutral';
var isDriveNeutral = tabName === 'drive-neutral';
impulseButton.classList.toggle('is_active', isImpulse);
neutralButton.classList.toggle('is_active', isNeutral);
driveNeutralButton.classList.toggle('is_active', isDriveNeutral);
impulseButton.setAttribute('aria-selected', isImpulse ? 'true' : 'false');
neutralButton.setAttribute('aria-selected', isNeutral ? 'true' : 'false');
driveNeutralButton.setAttribute('aria-selected', isDriveNeutral ? 'true' : 'false');
impulsePanel.classList.toggle('is_active', isImpulse);
neutralPanel.classList.toggle('is_active', isNeutral);
driveNeutralPanel.classList.toggle('is_active', isDriveNeutral);
impulsePanel.hidden = !isImpulse;
neutralPanel.hidden = !isNeutral;
driveNeutralPanel.hidden = !isDriveNeutral;
if (isNeutral && !Object.keys(steeringNeutralValues).length) loadSteeringNeutralValues();
if (isDriveNeutral && !Object.keys(driveNeutralValues).length) loadDriveNeutralValue();
}
function renderSteeringNeutralCards() {
var grid = document.getElementById('steering_neutral_grid');
if (!grid) return;
var html = [];
WHEEL_ORDER.forEach(function(wheel) {
var entry = steeringNeutralValues[wheel];
if (!entry) return;
var savedEntry = steeringNeutralSavedValues[wheel];
var isDirty = !savedEntry || Number(savedEntry.value) !== Number(entry.value);
html.push(
'<article class="steering_neutral_card steering_neutral_card_compact' + (isDirty ? ' is_dirty' : '') + '">' +
'<div class="steering_neutral_compact_row">' +
'<div class="steering_neutral_card_header steering_neutral_card_header_compact">' +
'<strong class="steering_neutral_title">' + entry.wheel_label + '</strong>' +
'</div>' +
'<div class="steering_neutral_value_row steering_neutral_value_row_compact">' +
'<button class="button buttonWarm steering_neutral_adjust" onclick="adjustSteeringNeutralValue(\'' + wheel + '\', -' + STEERING_NEUTRAL_STEP + ')">-</button>' +
'<strong class="steering_neutral_value">' + entry.value + '</strong>' +
'<button class="button buttonA steering_neutral_adjust" onclick="adjustSteeringNeutralValue(\'' + wheel + '\', ' + STEERING_NEUTRAL_STEP + ')">+</button>' +
'</div>' +
'</div>' +
'</article>'
);
});
grid.innerHTML = html.join('');
updateNeutralSaveButton();
}
function hasUnsavedNeutralChanges() {
return WHEEL_ORDER.some(function(wheel) {
var current = steeringNeutralValues[wheel];
var saved = steeringNeutralSavedValues[wheel];
if (!current || !saved) return false;
return Number(current.value) !== Number(saved.value);
});
}
function updateNeutralSaveButton() {
var button = document.getElementById('neutral_save_button');
if (button) button.disabled = isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || !hasUnsavedNeutralChanges();
}
function renderDriveNeutralCards() {
var grid = document.getElementById('drive_neutral_grid');
if (!grid) return;
var html = [];
WHEEL_ORDER.forEach(function(wheel) {
var entry = driveNeutralValues[wheel];
if (!entry) return;
var savedEntry = driveNeutralSavedValues[wheel];
var isDirty = !savedEntry || Number(savedEntry.value) !== Number(entry.value);
html.push(
'<article class="steering_neutral_card steering_neutral_card_compact' + (isDirty ? ' is_dirty' : '') + '">' +
'<div class="steering_neutral_compact_row">' +
'<div class="steering_neutral_card_header steering_neutral_card_header_compact">' +
'<strong class="steering_neutral_title">' + entry.wheel_label + '</strong>' +
'</div>' +
'<div class="steering_neutral_value_row steering_neutral_value_row_compact">' +
'<button class="button buttonWarm steering_neutral_adjust" onclick="adjustDriveNeutralValue(\'' + wheel + '\', -' + STEERING_NEUTRAL_STEP + ')">-</button>' +
'<strong class="steering_neutral_value">' + entry.value + '</strong>' +
'<button class="button buttonA steering_neutral_adjust" onclick="adjustDriveNeutralValue(\'' + wheel + '\', ' + STEERING_NEUTRAL_STEP + ')">+</button>' +
'</div>' +
'</div>' +
'</article>'
);
});
grid.innerHTML = html.join('');
updateDriveNeutralSaveButton();
}
function hasUnsavedDriveNeutralChanges() {
return WHEEL_ORDER.some(function(wheel) {
var current = driveNeutralValues[wheel];
var saved = driveNeutralSavedValues[wheel];
if (!current || !saved) return false;
return Number(current.value) !== Number(saved.value);
});
}
function updateDriveNeutralSaveButton() {
var button = document.getElementById('drive_neutral_save_button');
if (button) button.disabled = isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving || !hasUnsavedDriveNeutralChanges();
}
async function fetchMotorTestStatus() {
try {
var response = await fetch(adminApiBase + '/api/motor-test/status');
if (!response.ok) throw new Error('status ' + response.status);
renderContainerStatus(await response.json());
} catch (error) {
setMotorTestStatus('API nicht erreichbar');
}
}
async function setContainerState(action) {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) return;
isContainerActionRunning = true;
updateNeutralSaveButton();
updateDriveNeutralSaveButton();
setMotorTestStatus(action === 'stop' ? 'Stoppe Container...' : 'Starte Container...');
try {
var response = await fetch(adminApiBase + '/api/motor-test/' + action + '-container', { method: 'POST' });
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
setMotorTestStatus(data.status || 'Fertig');
renderContainerStatus(data);
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Container-Aktion konnte nicht ausgeführt werden.');
} finally {
isContainerActionRunning = false;
updateNeutralSaveButton();
updateDriveNeutralSaveButton();
}
}
async function runMotorTest(action) {
if (isMotorTestRunning || isContainerActionRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) return;
isMotorTestRunning = true;
updateNeutralSaveButton();
updateDriveNeutralSaveButton();
setMotorTestStatus('Teste...');
try {
var response = await fetch(adminApiBase + '/api/motor-test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ wheel: getSelectedWheel(), action: action })
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
setMotorTestStatus(data.status || 'Test beendet');
renderContainerStatus(data);
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Der Motortest konnte nicht ausgeführt werden.');
} finally {
isMotorTestRunning = false;
updateNeutralSaveButton();
updateDriveNeutralSaveButton();
}
}
async function loadSteeringNeutralValues() {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) return;
isNeutralLoading = true;
updateNeutralSaveButton();
setMotorTestStatus('Lade Servo-Mitten...');
try {
var response = await fetch(adminApiBase + '/api/motor-test/steering-neutral');
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
steeringNeutralValues = data.neutral_values && data.neutral_values.values ? data.neutral_values.values : {};
steeringNeutralSavedValues = JSON.parse(JSON.stringify(steeringNeutralValues));
renderSteeringNeutralCards();
renderContainerStatus(data);
setMotorTestStatus(data.status || 'Servo-Mitten geladen');
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Servo-Mitten konnten nicht geladen werden.');
} finally {
isNeutralLoading = false;
updateNeutralSaveButton();
}
}
async function adjustSteeringNeutralValue(wheel, delta) {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) return;
var entry = steeringNeutralValues[wheel];
if (!entry) return;
steeringNeutralValues[wheel].value = Math.max(0, Math.min(4095, Number(entry.value) + Number(delta)));
renderSteeringNeutralCards();
isNeutralPreviewRunning = true;
updateNeutralSaveButton();
setMotorTestStatus('Fahre Servo-Mitte an...');
try {
var response = await fetch(adminApiBase + '/api/motor-test/steering-neutral/preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ wheel: wheel, value: steeringNeutralValues[wheel].value })
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
renderContainerStatus(data);
setMotorTestStatus(data.status || 'Servo angefahren');
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Servo-Vorschau konnte nicht ausgeführt werden.');
} finally {
isNeutralPreviewRunning = false;
updateNeutralSaveButton();
}
}
async function saveSteeringNeutralValues() {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving || !hasUnsavedNeutralChanges()) return;
var payload = {};
WHEEL_ORDER.forEach(function(wheel) {
var entry = steeringNeutralValues[wheel];
if (entry) payload[entry.key] = Number(entry.value);
});
isNeutralSaving = true;
updateNeutralSaveButton();
setMotorTestStatus('Speichere Servo-Mitten...');
try {
var response = await fetch(adminApiBase + '/api/motor-test/steering-neutral/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ values: payload })
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
steeringNeutralValues = data.neutral_values && data.neutral_values.values ? data.neutral_values.values : steeringNeutralValues;
steeringNeutralSavedValues = JSON.parse(JSON.stringify(steeringNeutralValues));
renderSteeringNeutralCards();
renderContainerStatus(data);
setMotorTestStatus(data.status || 'Servo-Mitten gespeichert');
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Servo-Mitten konnten nicht gespeichert werden.');
} finally {
isNeutralSaving = false;
updateNeutralSaveButton();
}
}
async function loadDriveNeutralValue() {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) return;
isDriveNeutralLoading = true;
updateDriveNeutralSaveButton();
setMotorTestStatus('Lade Fahr-Neutralwerte...');
try {
var response = await fetch(adminApiBase + '/api/motor-test/drive-neutral');
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
driveNeutralValues = data.drive_neutral && data.drive_neutral.values ? data.drive_neutral.values : {};
driveNeutralSavedValues = JSON.parse(JSON.stringify(driveNeutralValues));
renderDriveNeutralCards();
renderContainerStatus(data);
setMotorTestStatus(data.status || 'Fahr-Neutralwerte geladen');
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Fahr-Neutralwerte konnten nicht geladen werden.');
} finally {
isDriveNeutralLoading = false;
updateDriveNeutralSaveButton();
}
}
async function adjustDriveNeutralValue(wheel, delta) {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving) return;
var entry = driveNeutralValues[wheel];
if (!entry) return;
entry.value = Math.max(0, Math.min(4095, Number(entry.value) + Number(delta)));
renderDriveNeutralCards();
isDriveNeutralPreviewRunning = true;
updateDriveNeutralSaveButton();
setMotorTestStatus('Fahre Fahr-Neutralwerte an...');
var values = {};
WHEEL_ORDER.forEach(function(key) {
if (driveNeutralValues[key]) values[key] = Number(driveNeutralValues[key].value);
});
try {
var response = await fetch(adminApiBase + '/api/motor-test/drive-neutral/preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ values: values })
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
driveNeutralValues = data.drive_neutral && data.drive_neutral.values ? data.drive_neutral.values : driveNeutralValues;
renderDriveNeutralCards();
renderContainerStatus(data);
setMotorTestStatus(data.status || 'Fahr-Neutralwerte angefahren');
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Fahr-Vorschau konnte nicht ausgeführt werden.');
} finally {
isDriveNeutralPreviewRunning = false;
updateDriveNeutralSaveButton();
}
}
async function saveDriveNeutralValue() {
if (isContainerActionRunning || isMotorTestRunning || isNeutralLoading || isNeutralPreviewRunning || isNeutralSaving || isDriveNeutralLoading || isDriveNeutralPreviewRunning || isDriveNeutralSaving || !hasUnsavedDriveNeutralChanges()) return;
isDriveNeutralSaving = true;
updateDriveNeutralSaveButton();
setMotorTestStatus('Speichere Fahr-Neutralwerte...');
var values = {};
WHEEL_ORDER.forEach(function(wheel) {
var entry = driveNeutralValues[wheel];
if (entry) values[entry.key] = Number(entry.value);
});
try {
var response = await fetch(adminApiBase + '/api/motor-test/drive-neutral/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ values: values })
});
var data = await response.json();
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
driveNeutralValues = data.drive_neutral && data.drive_neutral.values ? data.drive_neutral.values : driveNeutralValues;
driveNeutralSavedValues = JSON.parse(JSON.stringify(driveNeutralValues));
renderDriveNeutralCards();
renderContainerStatus(data);
setMotorTestStatus(data.status || 'Fahr-Neutralwerte gespeichert');
} catch (error) {
setMotorTestStatus('Fehler');
alert(error.message || 'Die Fahr-Neutralwerte konnten nicht gespeichert werden.');
} finally {
isDriveNeutralSaving = false;
updateDriveNeutralSaveButton();
}
}
function updateClock() {
var el = document.getElementById('utc-clock');
if (el) el.textContent = new Date().toUTCString().split(' ')[4];
}
window.addEventListener('load', function() {
var input = document.getElementById('auth_input');
var gpsCanvas = document.getElementById('gps_sky_canvas');
input.focus();
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') submitPassword();
});
if (gpsCanvas) gpsCanvas.addEventListener('click', onGpsSkyCanvasClick);
if (window.location.hash === '#motor') {
switchAdminSection('motor');
} else if (window.location.hash === '#camera') {
switchAdminSection('camera');
} else if (window.location.hash === '#drive') {
switchAdminSection('drive');
} else if (window.location.hash === '#gps') {
switchAdminSection('gps');
} else if (window.location.hash === '#map') {
switchAdminSection('map');
}
updateClock();
updatePageConnectionState();
window.setInterval(updateClock, 1000);
fetchStatus();
fetchCameraConfig();
fetchDelay();
fetchSpeedLimit();
fetchMotorTestStatus();
fetchDriveEstimatorConfig();
loadDriveEstimatorCalibration();
refreshRouteLibrary();
connectDriveEstimatorRos();
renderWaypointList();
applyWaypointPlanningState();
renderDriveEstimator();
updateGpsDiagnosticsPanel();
refreshCameraPreview();
window.setInterval(fetchStatus, 10000);
window.setInterval(fetchCameraConfig, 10000);
window.setInterval(fetchDelay, 5000);
window.setInterval(fetchSpeedLimit, 5000);
window.setInterval(fetchMotorTestStatus, 4000);
window.setInterval(renderDriveEstimator, 250);
window.setInterval(updatePageConnectionState, 1000);
});
window.addEventListener('beforeunload', function(event) {
if (!shouldWarnBeforeLeaving()) return;
event.preventDefault();
event.returnValue = '';
});