berechnung Weg und geschwindigkeit

This commit is contained in:
2026-05-23 09:27:19 +02:00
parent 27de6aec10
commit bbf7829099
5 changed files with 904 additions and 5 deletions
@@ -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
}
]
}
+607 -5
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CUNO // Admin</title>
<link rel="stylesheet" href="./app.css?v=20260522104500">
<script src="./vendor/roslib.min.js"></script>
</head>
<body class="page-admin">
@@ -38,6 +39,7 @@
<div class="admin-shell">
<div class="admin-section-tabs" role="tablist" aria-label="Admin-Bereiche">
<button id="admin_tab_system" class="admin-section-tab is_active" role="tab" aria-selected="true" onclick="switchAdminSection('system')">Admin</button>
<button id="admin_tab_drive" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('drive')">Geschwindigkeit / Weg</button>
<button id="admin_tab_motor" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('motor')">Motorentest</button>
</div>
@@ -169,6 +171,80 @@
</div>
</section>
<section id="admin_panel_drive" class="admin-section-panel" role="tabpanel">
<div class="admin-section-grid">
<div class="admin-col">
<div class="panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Geschwindigkeit / Weg</span>
<span class="p-tag">Estimated Telemetry</span>
</div>
<div class="copy-text drive-estimator-copy">
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.
</div>
<div class="drive-estimator-toolbar">
<label class="drive-estimator-input">
<span>Raddurchmesser</span>
<input id="drive_estimator_diameter_mm" type="number" min="20" max="300" step="1" value="100">
<em>mm</em>
</label>
<button class="act-btn warm" onclick="resetDriveEstimator()">Nullen</button>
</div>
<div class="drive-estimator-grid">
<article class="drive-estimator-card">
<div class="drive-estimator-label">Geschwindigkeit</div>
<div id="drive_estimator_speed" class="drive-estimator-value">0,00</div>
<div class="drive-estimator-unit">cm/s</div>
</article>
<article class="drive-estimator-card">
<div class="drive-estimator-label">Weg</div>
<div id="drive_estimator_distance" class="drive-estimator-value">0,00</div>
<div class="drive-estimator-unit">m</div>
</article>
</div>
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Quelle</span><div class="ss-hdr-line"></div></div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">ROSBridge</span>
<strong id="drive_estimator_ros_state" class="sr-val service_state state_unknown">verbinde…</strong>
</div>
<div class="sr">
<span class="sr-led led-ok"></span>
<span class="sr-key">Impulsbreite</span>
<span id="drive_estimator_pulse" class="sr-val">-</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Servo-Drehzahl</span>
<span id="drive_estimator_rpm" class="sr-val">-</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Modus</span>
<span id="drive_estimator_mode" class="sr-val">-</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Richtung</span>
<span id="drive_estimator_direction" class="sr-val">-</span>
</div>
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Kalibrierung</span><div class="ss-hdr-line"></div></div>
<div class="copy-text drive-estimator-copy">
Für jede Stufe drückst du <code>1 m messen</code>. Der Timer startet automatisch, sobald der Rover wirklich anfährt. Wenn genau <code>1 m</code> erreicht ist, drückst du <code>1 m erreicht</code>. Daraus wird die echte Geschwindigkeit in <code>cm/s</code> berechnet.
</div>
<div class="drive-calibration-toolbar">
<span id="drive_calibration_info" class="ctrl-status drive-calibration-info">nicht geladen</span>
<button class="act-btn subtle" onclick="loadDriveEstimatorCalibration()">Neu laden</button>
<button class="act-btn warm" onclick="fillDriveEstimatorCalibrationDefaults()">Datenblatt</button>
<button id="drive_calibration_save_button" class="act-btn" onclick="saveDriveEstimatorCalibration()">Speichern</button>
</div>
<div id="drive_calibration_grid" class="drive-calibration-grid"></div>
</div>
</div>
</div>
</section>
<section id="admin_panel_motor" class="admin-section-panel motor-admin-tab" role="tabpanel">
<div class="admin-section-grid">
<div class="admin-col">
@@ -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(
'<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() {
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) {
+92
View File
@@ -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{
+12
View File
@@ -71,6 +71,18 @@
HOST: <span id="cam-host">--</span><br>
STREAM: 8083
</div>
<div class="c-bm">
<div class="c-bm-box">
<span class="c-bm-lbl">V</span>
<strong id="overlay-speed">0,0</strong>
<span class="c-bm-unit">cm/s</span>
</div>
<div class="c-bm-box">
<span class="c-bm-lbl">S</span>
<strong id="overlay-distance">0,00</strong>
<span class="c-bm-unit">m</span>
</div>
</div>
<div class="c-xhair">
<svg viewBox="-90 -90 180 180" xmlns="http://www.w3.org/2000/svg" width="180" height="180">
<!-- äußerer rotierender Strichelring -->
@@ -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'))