2195 lines
86 KiB
JavaScript
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, '"') + '">' + 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: '© 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 = '';
|
|
});
|