diff --git a/ExoMy_Software-master/config/drive_estimator_calibration.json b/ExoMy_Software-master/config/drive_estimator_calibration.json new file mode 100644 index 0000000..467a98c --- /dev/null +++ b/ExoMy_Software-master/config/drive_estimator_calibration.json @@ -0,0 +1,37 @@ +{ + "updated_at": "2026-05-23 09:21:10", + "points": [ + { + "percent": 0, + "speed_mps": 0.0 + }, + { + "percent": 20, + "speed_mps": 0.04629415304846998 + }, + { + "percent": 30, + "speed_mps": 0.1198609612849095 + }, + { + "percent": 40, + "speed_mps": 0.17397355601948503 + }, + { + "percent": 50, + "speed_mps": 0.21978021978021978 + }, + { + "percent": 60, + "speed_mps": 0.22972662531587412 + }, + { + "percent": 80, + "speed_mps": 0.21968365553602814 + }, + { + "percent": 100, + "speed_mps": 0.23523876734885907 + } + ] +} diff --git a/ExoMy_Software-master/gui/admin.html b/ExoMy_Software-master/gui/admin.html index c2034b9..378bb6f 100644 --- a/ExoMy_Software-master/gui/admin.html +++ b/ExoMy_Software-master/gui/admin.html @@ -5,6 +5,7 @@ CUNO // Admin + @@ -38,6 +39,7 @@
+
@@ -169,6 +171,80 @@
+
+
+
+
+
+
+ Geschwindigkeit / Weg + Estimated Telemetry +
+
+ Die Werte sind eine Schätzung auf Basis der Servo-Pulsbreite, des Parallax-Datenblatts und des eingestellten Radmaßes. Punkt-Drehen zählt nicht als Fahrweg. +
+
+ + +
+
+
+
Geschwindigkeit
+
0,00
+
cm/s
+
+
+
Weg
+
0,00
+
m
+
+
+
Quelle
+
+ + ROSBridge + verbinde… +
+
+ + Impulsbreite + - +
+
+ + Servo-Drehzahl + - +
+
+ + Modus + - +
+
+ + Richtung + - +
+
Kalibrierung
+
+ Für jede Stufe drückst du 1 m messen. Der Timer startet automatisch, sobald der Rover wirklich anfährt. Wenn genau 1 m erreicht ist, drückst du 1 m erreicht. Daraus wird die echte Geschwindigkeit in cm/s berechnet. +
+
+ nicht geladen + + + +
+
+
+
+
+
+
@@ -286,6 +362,9 @@ var adminApiBase = window.location.protocol + '//' + window.location.hostname + ':8082'; var AUTH_TOKEN = 'Y3Vubw=='; var activeAdminSection = 'system'; + var ros = null; + var roverCommandTopic = null; + var motorCommandsTopic = null; var isMotorTestRunning = false; var isContainerActionRunning = false; @@ -303,6 +382,47 @@ 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 driveCalibrationMeasurement = { + distanceM: 1.0, + activeIndex: null, + waitingForMotion: false, + startedAtMs: 0, + elapsedMs: 0, + stopCandidateStartedAtMs: 0, + stopDebounceMs: 400, + startThresholdPercent: 2, + stopThresholdPercent: 1 + }; function submitPassword() { var input = document.getElementById('auth_input'); @@ -328,22 +448,29 @@ function switchAdminSection(sectionName) { activeAdminSection = sectionName; var systemTab = document.getElementById('admin_tab_system'); + var driveTab = document.getElementById('admin_tab_drive'); var motorTab = document.getElementById('admin_tab_motor'); var systemPanel = document.getElementById('admin_panel_system'); + var drivePanel = document.getElementById('admin_panel_drive'); var motorPanel = document.getElementById('admin_panel_motor'); var isSystem = sectionName === 'system'; + var isDrive = sectionName === 'drive'; + var isMotor = sectionName === 'motor'; systemTab.classList.toggle('is_active', isSystem); - motorTab.classList.toggle('is_active', !isSystem); + driveTab.classList.toggle('is_active', isDrive); + motorTab.classList.toggle('is_active', isMotor); systemTab.setAttribute('aria-selected', isSystem ? 'true' : 'false'); - motorTab.setAttribute('aria-selected', isSystem ? 'false' : 'true'); + driveTab.setAttribute('aria-selected', isDrive ? 'true' : 'false'); + motorTab.setAttribute('aria-selected', isMotor ? 'true' : 'false'); systemPanel.classList.toggle('is_active', isSystem); - motorPanel.classList.toggle('is_active', !isSystem); - if (!isSystem) fetchMotorTestStatus(); + drivePanel.classList.toggle('is_active', isDrive); + motorPanel.classList.toggle('is_active', isMotor); + if (isMotor) fetchMotorTestStatus(); } function setText(id, value) { var node = document.getElementById(id); - if (node) node.textContent = value || '-'; + if (node) node.textContent = (value === undefined || value === null || value === '') ? '-' : value; } function setServiceState(id, value) { @@ -381,6 +508,474 @@ node.classList.add('state_unknown'); } + function formatNumber(value, digits) { + return Number(value || 0).toLocaleString('de-DE', { + minimumFractionDigits: digits, + maximumFractionDigits: digits + }); + } + + function formatSpeedCms(speedMps, digits) { + return formatNumber(Number(speedMps || 0) * 100, digits); + } + + function setDriveEstimatorState(text, stateClass) { + var node = document.getElementById('drive_estimator_ros_state'); + if (!node) return; + node.textContent = text || '-'; + node.classList.remove('state_active', 'state_inactive', 'state_unknown'); + node.classList.add(stateClass || 'state_unknown'); + } + + function cloneCalibrationPoints(points) { + return (points || []).map(function(item) { + return { + percent: Number(item.percent), + speed_mps: Number(item.speed_mps) + }; + }); + } + + function normalizeCalibrationPoints(points) { + var source = cloneCalibrationPoints(points).sort(function(a, b) { + return a.percent - b.percent; + }); + if (!source.length) source = [{percent: 0, speed_mps: 0}]; + + return REQUIRED_CALIBRATION_POINTS.map(function(targetPercent) { + var exact = source.find(function(point) { + return Number(point.percent) === Number(targetPercent); + }); + if (exact) { + return { + percent: targetPercent, + speed_mps: Number(exact.speed_mps || 0) + }; + } + + var lower = null; + var upper = null; + source.forEach(function(point) { + if (point.percent < targetPercent) lower = point; + if (!upper && point.percent > targetPercent) upper = point; + }); + + if (!lower && upper) { + return { + percent: targetPercent, + speed_mps: Number(upper.speed_mps || 0) + }; + } + if (lower && !upper) { + return { + percent: targetPercent, + speed_mps: Number(lower.speed_mps || 0) + }; + } + if (lower && upper) { + var span = Number(upper.percent) - Number(lower.percent); + var ratio = span <= 0 ? 0 : (targetPercent - Number(lower.percent)) / span; + return { + percent: targetPercent, + speed_mps: Number(lower.speed_mps || 0) + (Number(upper.speed_mps || 0) - Number(lower.speed_mps || 0)) * ratio + }; + } + + return { + percent: targetPercent, + speed_mps: 0 + }; + }); + } + + function getDriveEstimatorDefaultCalibration() { + var wheelDiameterM = getDriveEstimatorDiameterM(); + return REQUIRED_CALIBRATION_POINTS.map(function(percent) { + var rpm = driveEstimator.servoMaxRpm * (percent / 100.0); + return { + percent: percent, + speed_mps: (rpm / 60.0) * Math.PI * wheelDiameterM + }; + }); + } + + function hasUnsavedDriveCalibrationChanges() { + if (driveEstimator.calibrationPoints.length !== driveEstimator.savedCalibrationPoints.length) return true; + for (var i = 0; i < driveEstimator.calibrationPoints.length; i += 1) { + var current = driveEstimator.calibrationPoints[i]; + var saved = driveEstimator.savedCalibrationPoints[i]; + if (!saved) return true; + if (Number(current.percent) !== Number(saved.percent)) return true; + if (Math.abs(Number(current.speed_mps) - Number(saved.speed_mps)) > 0.0005) return true; + } + return false; + } + + function updateDriveCalibrationSaveButton() { + var button = document.getElementById('drive_calibration_save_button'); + if (button) button.disabled = !driveEstimator.calibrationLoaded || !hasUnsavedDriveCalibrationChanges(); + } + + function renderDriveEstimatorCalibration() { + var grid = document.getElementById('drive_calibration_grid'); + if (!grid) return; + var html = []; + driveEstimator.calibrationPoints.forEach(function(point, index) { + var saved = driveEstimator.savedCalibrationPoints[index]; + var isDirty = !saved || Math.abs(Number(point.speed_mps) - Number(saved.speed_mps)) > 0.0005; + var isActive = driveCalibrationMeasurement.activeIndex === index; + var measuredSeconds = point.speed_mps > 0 ? (driveCalibrationMeasurement.distanceM / point.speed_mps) : 0; + var statusText = 'Bereit'; + if (isActive && driveCalibrationMeasurement.waitingForMotion) statusText = 'Warte auf Anfahrt'; + else if (isActive) statusText = 'Misst: ' + formatNumber(driveCalibrationMeasurement.elapsedMs / 1000.0, 2) + ' s'; + html.push( + '
' + + '
' + point.percent + ' %
' + + '
' + formatSpeedCms(point.speed_mps, 1) + ' cm/s
' + + '
1 m in ' + (point.speed_mps > 0 ? (formatNumber(measuredSeconds, 2) + ' s') : '-') + '
' + + '
' + statusText + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + ); + }); + grid.innerHTML = html.join(''); + var infoNode = document.getElementById('drive_calibration_info'); + if (infoNode) infoNode.textContent = driveEstimator.calibrationInfo || '-'; + updateDriveCalibrationSaveButton(); + } + + function isDriveMeasurementMoving() { + return isTranslationMode() && Math.abs(getAverageMotorSpeedPercent()) >= driveCalibrationMeasurement.startThresholdPercent; + } + + function isDriveMeasurementStopped() { + return Math.abs(getAverageMotorSpeedPercent()) <= driveCalibrationMeasurement.stopThresholdPercent; + } + + function startDriveCalibrationMeasurement(index) { + if (!driveEstimator.calibrationPoints[index]) return; + driveCalibrationMeasurement.activeIndex = index; + driveCalibrationMeasurement.waitingForMotion = true; + driveCalibrationMeasurement.startedAtMs = 0; + driveCalibrationMeasurement.elapsedMs = 0; + driveCalibrationMeasurement.stopCandidateStartedAtMs = 0; + driveEstimator.calibrationInfo = driveEstimator.calibrationPoints[index].percent + ' %: warte auf Anfahrt'; + renderDriveEstimatorCalibration(); + } + + function finishDriveCalibrationMeasurement(index, automatic) { + if (driveCalibrationMeasurement.activeIndex !== index) return; + if (driveCalibrationMeasurement.waitingForMotion || driveCalibrationMeasurement.elapsedMs <= 0) { + alert('Die Messung hat noch nicht begonnen. Erst losfahren, dann bei 1 m erneut drücken.'); + return; + } + var elapsedSeconds = driveCalibrationMeasurement.elapsedMs / 1000.0; + driveEstimator.calibrationPoints[index].speed_mps = driveCalibrationMeasurement.distanceM / elapsedSeconds; + driveEstimator.calibrationInfo = driveEstimator.calibrationPoints[index].percent + ' %: 1 m in ' + formatNumber(elapsedSeconds, 2) + ' s' + (automatic ? ' (auto)' : ''); + driveCalibrationMeasurement.activeIndex = null; + driveCalibrationMeasurement.waitingForMotion = false; + driveCalibrationMeasurement.startedAtMs = 0; + driveCalibrationMeasurement.elapsedMs = 0; + driveCalibrationMeasurement.stopCandidateStartedAtMs = 0; + renderDriveEstimatorCalibration(); + renderDriveEstimator(); + } + + function cancelDriveCalibrationMeasurement(index) { + if (driveCalibrationMeasurement.activeIndex !== index) return; + driveCalibrationMeasurement.activeIndex = null; + driveCalibrationMeasurement.waitingForMotion = false; + driveCalibrationMeasurement.startedAtMs = 0; + driveCalibrationMeasurement.elapsedMs = 0; + driveCalibrationMeasurement.stopCandidateStartedAtMs = 0; + driveEstimator.calibrationInfo = 'Messung abgebrochen'; + renderDriveEstimatorCalibration(); + } + + function updateDriveCalibrationMeasurementState() { + if (driveCalibrationMeasurement.activeIndex === null) return; + if (driveCalibrationMeasurement.waitingForMotion) { + if (isDriveMeasurementMoving()) { + driveCalibrationMeasurement.waitingForMotion = false; + driveCalibrationMeasurement.startedAtMs = Date.now(); + driveCalibrationMeasurement.elapsedMs = 0; + driveCalibrationMeasurement.stopCandidateStartedAtMs = 0; + driveEstimator.calibrationInfo = driveEstimator.calibrationPoints[driveCalibrationMeasurement.activeIndex].percent + ' %: Messung läuft'; + renderDriveEstimatorCalibration(); + } + return; + } + if (driveCalibrationMeasurement.startedAtMs > 0) { + var nowMs = Date.now(); + var previousTenths = Math.floor(driveCalibrationMeasurement.elapsedMs / 100); + driveCalibrationMeasurement.elapsedMs = Math.max(0, nowMs - driveCalibrationMeasurement.startedAtMs); + var nextTenths = Math.floor(driveCalibrationMeasurement.elapsedMs / 100); + if (nextTenths !== previousTenths) renderDriveEstimatorCalibration(); + if (!isDriveMeasurementStopped()) { + driveCalibrationMeasurement.stopCandidateStartedAtMs = 0; + } else if (driveCalibrationMeasurement.stopCandidateStartedAtMs === 0) { + driveCalibrationMeasurement.stopCandidateStartedAtMs = nowMs; + } else if (nowMs - driveCalibrationMeasurement.stopCandidateStartedAtMs >= driveCalibrationMeasurement.stopDebounceMs) { + finishDriveCalibrationMeasurement(driveCalibrationMeasurement.activeIndex, true); + } + } + } + + function interpolateCalibrationSpeed(percentMagnitude) { + var points = driveEstimator.calibrationPoints; + if (!points || !points.length) return 0; + var p = Math.max(0, Math.min(100, Math.abs(percentMagnitude))); + if (p <= points[0].percent) return Number(points[0].speed_mps || 0); + for (var i = 1; i < points.length; i += 1) { + var left = points[i - 1]; + var right = points[i]; + if (p <= right.percent) { + var span = right.percent - left.percent; + if (span <= 0) return Number(right.speed_mps || 0); + var ratio = (p - left.percent) / span; + return Number(left.speed_mps || 0) + (Number(right.speed_mps || 0) - Number(left.speed_mps || 0)) * ratio; + } + } + return Number(points[points.length - 1].speed_mps || 0); + } + + function fillDriveEstimatorCalibrationDefaults() { + if (driveCalibrationMeasurement.activeIndex !== null) return; + driveEstimator.calibrationPoints = normalizeCalibrationPoints(getDriveEstimatorDefaultCalibration()); + driveEstimator.calibrationInfo = 'Datenblatt-Kurve geladen'; + renderDriveEstimatorCalibration(); + renderDriveEstimator(); + } + + function getDriveEstimatorDiameterM() { + var input = document.getElementById('drive_estimator_diameter_mm'); + if (!input) return driveEstimator.wheelDiameterM; + var mm = parseFloat(input.value); + if (isNaN(mm)) return driveEstimator.wheelDiameterM; + mm = Math.max(20, Math.min(300, mm)); + input.value = String(mm); + driveEstimator.wheelDiameterM = mm / 1000.0; + return driveEstimator.wheelDiameterM; + } + + function getAverageMotorSpeedPercent() { + if (!driveEstimator.motorSpeeds || !driveEstimator.motorSpeeds.length) return 0; + var sum = 0; + var count = 0; + driveEstimator.motorSpeeds.forEach(function(value) { + if (typeof value === 'number' && !isNaN(value)) { + sum += value; + count += 1; + } + }); + return count ? (sum / count) : 0; + } + + function isTranslationMode() { + return driveEstimator.locomotionMode !== 2; + } + + function updateDriveEstimatorModel() { + updateDriveCalibrationMeasurementState(); + var nowMs = Date.now(); + var dt = Math.max(0, (nowMs - driveEstimator.lastTickMs) / 1000.0); + driveEstimator.lastTickMs = nowMs; + + var avgSpeedPercent = getAverageMotorSpeedPercent(); + var direction = avgSpeedPercent > 0 ? 1 : (avgSpeedPercent < 0 ? -1 : 0); + var pulsePeriodMs = 1000.0 / driveEstimator.pwmFrequencyHz; + var pulseStepMs = pulsePeriodMs / 4096.0; + var pwmValue = driveEstimator.drivePwmNeutralAverage + (avgSpeedPercent / 100.0 * driveEstimator.drivePwmRange); + var pulseWidthMs = pwmValue * pulseStepMs; + var neutralPulseMs = driveEstimator.drivePwmNeutralAverage * pulseStepMs; + var deltaMs = pulseWidthMs - neutralPulseMs; + var rpm = 0; + if (isTranslationMode() && driveEstimator.motorsEnabled) { + rpm = (deltaMs / driveEstimator.servoFullScalePulseDeltaMs) * driveEstimator.servoMaxRpm; + rpm = Math.max(-driveEstimator.servoMaxRpm, Math.min(driveEstimator.servoMaxRpm, rpm)); + } + + var speedMagnitude = isTranslationMode() && driveEstimator.motorsEnabled + ? interpolateCalibrationSpeed(avgSpeedPercent) + : 0; + var speedMps = direction === 0 ? 0 : speedMagnitude * direction; + + if (Math.abs(speedMps) < 0.0005) speedMps = 0; + if (dt > 0 && dt < 2.5) driveEstimator.distanceM += Math.abs(speedMps) * dt; + + driveEstimator.rpm = rpm; + driveEstimator.speedMps = speedMps; + driveEstimator.pulseWidthMs = pulseWidthMs; + driveEstimator.modeLabel = LOCOMOTION_MODE_NAMES[driveEstimator.locomotionMode] || '-'; + driveEstimator.directionLabel = !driveEstimator.motorsEnabled ? 'Motoren aus' + : !isTranslationMode() ? 'Drehen auf der Stelle' + : direction > 0 ? 'Vorwärts' + : direction < 0 ? 'Rückwärts' + : 'Stopp'; + } + + function renderDriveEstimator() { + 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() { + setDriveEstimatorState('verbunden', 'state_active'); + }); + ros.on('close', function() { + setDriveEstimatorState('getrennt', 'state_inactive'); + ros = null; + roverCommandTopic = null; + motorCommandsTopic = null; + window.setTimeout(connectDriveEstimatorRos, 3000); + }); + ros.on('error', function() { + 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(); + }); + } + function temperatureToWidth(value) { if (!value || typeof value !== 'string') return '0%'; var n = parseFloat(value.replace('°C', '').replace('°C', '').replace(',', '.')); @@ -880,6 +1475,8 @@ }); if (window.location.hash === '#motor') { switchAdminSection('motor'); + } else if (window.location.hash === '#drive') { + switchAdminSection('drive'); } updateClock(); window.setInterval(updateClock, 1000); @@ -887,10 +1484,15 @@ fetchDelay(); fetchSpeedLimit(); fetchMotorTestStatus(); + fetchDriveEstimatorConfig(); + loadDriveEstimatorCalibration(); + connectDriveEstimatorRos(); + renderDriveEstimator(); window.setInterval(fetchStatus, 10000); window.setInterval(fetchDelay, 5000); window.setInterval(fetchSpeedLimit, 5000); window.setInterval(fetchMotorTestStatus, 4000); + window.setInterval(renderDriveEstimator, 250); }); window.addEventListener('beforeunload', function(event) { diff --git a/ExoMy_Software-master/gui/app.css b/ExoMy_Software-master/gui/app.css index 9f7ccd3..001cb83 100644 --- a/ExoMy_Software-master/gui/app.css +++ b/ExoMy_Software-master/gui/app.css @@ -990,6 +990,10 @@ body.page-admin .motor-admin-tab .steering_neutral_adjust{ padding:4px 10px !imp body.page-admin .admin-section-tab{ flex:1; text-align:center; padding:10px 8px; } body.page-admin .motor-admin-tab .tab-row{ grid-template-columns:1fr; } body.page-admin .motor-admin-tab .tab-box + .tab-box{ border-left:none; border-top:1px solid rgba(0,255,145,.06); } + body.page-admin .drive-estimator-toolbar{ flex-direction:column; align-items:stretch; } + body.page-admin .drive-estimator-input{ grid-template-columns:1fr; } + body.page-admin .drive-estimator-grid{ grid-template-columns:1fr; } + body.page-admin .drive-estimator-value{ font-size:28px; } } /* ── ACTION BUTTONS ─────────────────────────────── */ @@ -1032,6 +1036,94 @@ body.page-admin .ctrl-select{ } body.page-admin .ctrl-select:focus{ border-color:rgba(0,255,145,.5); } body.page-admin .ctrl-status{ font-size:9px; letter-spacing:1.2px; color:rgba(225,255,242,.92); white-space:nowrap; min-width:64px; text-align:right; } +body.page-admin .drive-estimator-copy{ + font-size:10px; color:rgba(205,255,235,.86); letter-spacing:.4px; line-height:1.6; + padding:8px 12px; border-bottom:1px solid rgba(0,255,145,.05); +} +body.page-admin .drive-estimator-toolbar{ + display:flex; align-items:end; gap:10px; padding:12px; border-bottom:1px solid rgba(0,255,145,.05); +} +body.page-admin .drive-estimator-input{ + display:grid; grid-template-columns:auto 88px auto; align-items:center; gap:8px; flex:1; +} +body.page-admin .drive-estimator-input span{ + font-size:9px; letter-spacing:1.6px; color:rgba(210,255,235,.9); text-transform:uppercase; +} +body.page-admin .drive-estimator-input input{ + font-family:var(--mono); font-size:12px; letter-spacing:1px; color:rgba(245,255,250,.96); + background:#050A14; border:1px solid rgba(120,255,205,.36); padding:8px 10px; outline:none; +} +body.page-admin .drive-estimator-input input:focus{ border-color:rgba(0,255,145,.5); } +body.page-admin .drive-estimator-input em{ + font-style:normal; font-size:10px; color:rgba(225,255,242,.92); letter-spacing:1px; +} +body.page-admin .drive-estimator-grid{ + display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; padding:12px; +} +body.page-admin .drive-estimator-card{ + border:1px solid rgba(0,255,145,.12); + background:linear-gradient(180deg, rgba(0,255,145,.05), rgba(0,255,145,.015)); + padding:14px 12px; + box-shadow:inset 0 0 24px rgba(0,255,145,.018); +} +body.page-admin .drive-estimator-label{ + font-size:9px; letter-spacing:2px; color:rgba(205,255,235,.86); text-transform:uppercase; margin-bottom:10px; +} +body.page-admin .drive-estimator-value{ + font-size:34px; font-weight:700; line-height:1; letter-spacing:2px; color:#fff; + text-shadow:0 0 18px rgba(120,255,205,.16); +} +body.page-admin .drive-estimator-unit{ + margin-top:8px; font-size:11px; letter-spacing:2px; color:rgba(120,255,205,.9); text-transform:uppercase; +} +body.page-admin .drive-calibration-toolbar{ + display:flex; align-items:center; gap:8px; padding:10px 12px; border-top:1px solid rgba(0,255,145,.05); border-bottom:1px solid rgba(0,255,145,.05); +} +body.page-admin .drive-calibration-info{ + margin-right:auto; min-width:auto; text-align:left; +} +body.page-admin .drive-calibration-grid{ + display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:8px; padding:12px; +} +body.page-admin .drive-calibration-card{ + border:1px solid rgba(0,255,145,.12); + background:rgba(0,255,145,.025); + padding:10px; +} +body.page-admin .drive-calibration-card.is_active{ + border-color:rgba(180,255,225,.72); + box-shadow:0 0 16px rgba(120,255,205,.14), inset 0 0 16px rgba(180,255,225,.05); +} +body.page-admin .drive-calibration-card.is_dirty{ + border-color:rgba(255,140,0,.4); + background:rgba(255,140,0,.03); +} +body.page-admin .drive-calibration-percent{ + font-size:11px; font-weight:700; letter-spacing:1.6px; color:#fff; margin-bottom:10px; +} +body.page-admin .drive-calibration-value{ + font-size:22px; font-weight:700; letter-spacing:1.5px; color:#fff; margin-bottom:6px; +} +body.page-admin .drive-calibration-meta{ + font-size:10px; letter-spacing:1px; color:rgba(205,255,235,.86); margin-bottom:8px; +} +body.page-admin .drive-calibration-status{ + font-size:9px; letter-spacing:1.4px; color:rgba(120,255,205,.9); text-transform:uppercase; margin-bottom:10px; +} +body.page-admin .drive-calibration-actions{ + display:grid; grid-template-columns:1fr; gap:6px; +} +body.page-admin .drive-calibration-button{ + width:100%; +} +@media (max-width:479px){ + body.page-admin .drive-estimator-toolbar{ flex-direction:column; align-items:stretch; } + body.page-admin .drive-estimator-input{ grid-template-columns:1fr; } + body.page-admin .drive-estimator-grid{ grid-template-columns:1fr; } + body.page-admin .drive-estimator-value{ font-size:28px; } + body.page-admin .drive-calibration-toolbar{ flex-direction:column; align-items:stretch; } + body.page-admin .drive-calibration-grid{ grid-template-columns:1fr; } +} /* ── AUTH OVERLAY ───────────────────────────────── */ body.page-admin .auth-overlay{ diff --git a/ExoMy_Software-master/gui/index.html b/ExoMy_Software-master/gui/index.html index 2b67855..a022098 100644 --- a/ExoMy_Software-master/gui/index.html +++ b/ExoMy_Software-master/gui/index.html @@ -71,6 +71,18 @@ HOST: --
STREAM: 8083
+
+
+ V + 0,0 + cm/s +
+
+ S + 0,00 + m +
+
diff --git a/ExoMy_Software-master/scripts/exomy_admin_api.py b/ExoMy_Software-master/scripts/exomy_admin_api.py index 1c33fe0..2e639df 100644 --- a/ExoMy_Software-master/scripts/exomy_admin_api.py +++ b/ExoMy_Software-master/scripts/exomy_admin_api.py @@ -15,6 +15,9 @@ PORT = 8082 EXOMY_CONTAINER = 'exomy_autostart' DELAY_FILE = '/tmp/exomy_delay.txt' SPEED_LIMIT_FILE = '/tmp/exomy_speed_limit.txt' +DRIVE_ESTIMATOR_CALIBRATION_FILE = os.path.join( + os.path.dirname(__file__), '..', 'config', 'drive_estimator_calibration.json' +) MOTOR_TEST_LOCK = threading.Lock() @@ -492,6 +495,136 @@ def get_drive_neutral_status(): return 500, {'status': 'Fahr-Neutralwerte konnten nicht geladen werden'} +def get_drive_estimator_config(): + from admin_motor_test import WHEELS, _load_config + + try: + config_dir = os.path.join(os.path.dirname(__file__), '..', 'config') + config_candidates = [ + os.path.join(config_dir, 'exomy.yaml'), + os.path.join(config_dir, 'exomy.remote.yaml'), + os.path.join(config_dir, 'exomy.yaml.template'), + ] + config_path = next((path for path in config_candidates if os.path.exists(path)), None) + if config_path is None: + raise FileNotFoundError('Konfigurationsdatei nicht gefunden') + + config = _load_config(config_path) + neutral_values = [ + int(config[f'drive_pwm_neutral_{wheel}']) + for wheel in WHEELS + ] + average_neutral = sum(neutral_values) / len(neutral_values) + return 200, { + 'status': 'Bereit', + 'estimator': { + 'config_path': os.path.abspath(config_path), + 'drive_pwm_neutral_average': average_neutral, + 'drive_pwm_neutral_values': neutral_values, + 'drive_pwm_range': int(config['drive_pwm_range']), + 'pwm_frequency_hz': 50, + 'servo_max_rpm': 50.0, + 'servo_full_scale_pulse_delta_ms': 0.2, + 'wheel_diameter_m_default': 0.1, + }, + } + except FileNotFoundError: + return 500, {'status': 'Motor-Konfiguration fehlt'} + except Exception: + return 500, {'status': 'Estimator-Konfiguration konnte nicht geladen werden'} + + +def get_drive_estimator_calibration(): + default_points = [0, 20, 30, 40, 50, 60, 80, 100] + data = { + 'points': [ + {'percent': point, 'speed_mps': 0.0} + for point in default_points + ], + 'updated_at': None, + 'source': 'default', + } + + try: + with open(DRIVE_ESTIMATOR_CALIBRATION_FILE, 'r', encoding='utf-8') as handle: + payload = json.load(handle) + points = payload.get('points') + if isinstance(points, list): + cleaned_points = [] + for item in points: + if not isinstance(item, dict): + continue + percent = int(item.get('percent', 0)) + speed_mps = float(item.get('speed_mps', 0.0)) + cleaned_points.append({ + 'percent': max(0, min(100, percent)), + 'speed_mps': max(0.0, speed_mps), + }) + if cleaned_points: + cleaned_points.sort(key=lambda item: item['percent']) + data = { + 'points': cleaned_points, + 'updated_at': payload.get('updated_at'), + 'source': 'saved', + } + except FileNotFoundError: + pass + except (OSError, ValueError, TypeError, json.JSONDecodeError): + return 500, {'status': 'Kalibrierung konnte nicht geladen werden'} + + return 200, { + 'status': 'Bereit', + 'calibration': data, + } + + +def save_drive_estimator_calibration(points): + if not isinstance(points, list) or not points: + return 400, {'status': 'Kalibrierpunkte fehlen'} + + cleaned_points = [] + seen_percents = set() + for item in points: + if not isinstance(item, dict): + return 400, {'status': 'Kalibrierpunkte sind ungültig'} + try: + percent = int(item.get('percent', 0)) + speed_mps = float(item.get('speed_mps', 0.0)) + except (TypeError, ValueError): + return 400, {'status': 'Kalibrierwerte sind ungültig'} + percent = max(0, min(100, percent)) + speed_mps = max(0.0, speed_mps) + if percent in seen_percents: + return 400, {'status': 'Jede Stufe darf nur einmal vorkommen'} + seen_percents.add(percent) + cleaned_points.append({ + 'percent': percent, + 'speed_mps': speed_mps, + }) + + cleaned_points.sort(key=lambda item: item['percent']) + os.makedirs(os.path.dirname(DRIVE_ESTIMATOR_CALIBRATION_FILE), exist_ok=True) + payload = { + 'updated_at': time.strftime('%Y-%m-%d %H:%M:%S'), + 'points': cleaned_points, + } + try: + with open(DRIVE_ESTIMATOR_CALIBRATION_FILE, 'w', encoding='utf-8') as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write('\n') + except OSError: + return 500, {'status': 'Kalibrierung konnte nicht gespeichert werden'} + + return 200, { + 'status': 'Kalibrierung gespeichert', + 'calibration': { + 'points': cleaned_points, + 'updated_at': payload['updated_at'], + 'source': 'saved', + }, + } + + def preview_drive_neutral_action(values): from admin_motor_test import preview_drive_neutral_value @@ -587,6 +720,14 @@ class Handler(http.server.BaseHTTPRequestHandler): status_code, response = get_drive_neutral_status() self.write_json(status_code, response) return + if self.path == '/api/drive-estimator/config': + status_code, response = get_drive_estimator_config() + self.write_json(status_code, response) + return + if self.path == '/api/drive-estimator/calibration': + status_code, response = get_drive_estimator_calibration() + self.write_json(status_code, response) + return self.write_json(404, {'status': 'Unbekannt'}) def do_POST(self): @@ -626,6 +767,21 @@ class Handler(http.server.BaseHTTPRequestHandler): self.write_json(500, {'status': str(exc)}) return + if self.path == '/api/drive-estimator/calibration': + try: + content_length = int(self.headers.get('Content-Length', '0')) + except ValueError: + content_length = 0 + raw_body = self.rfile.read(content_length) if content_length > 0 else b'{}' + try: + payload = json.loads(raw_body.decode('utf-8')) + except (UnicodeDecodeError, json.JSONDecodeError): + self.write_json(400, {'status': 'Ungültige Anfrage'}) + return + status_code, response = save_drive_estimator_calibration(payload.get('points')) + self.write_json(status_code, response) + return + if self.path == '/api/motor-test/drive-neutral/preview': try: content_length = int(self.headers.get('Content-Length', '0'))