3542 lines
150 KiB
HTML
3542 lines
150 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>CUNO // Admin</title>
|
||
<link rel="stylesheet" href="./app.css?v=20260525133500">
|
||
<link rel="stylesheet" href="./vendor/leaflet/leaflet.css">
|
||
<script src="./vendor/roslib.min.js"></script>
|
||
<script src="./vendor/leaflet/leaflet.js"></script>
|
||
</head>
|
||
<body class="page-admin">
|
||
|
||
<header>
|
||
<svg class="hdr-icon" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||
<ellipse cx="14" cy="51" rx="6" ry="7" fill="#FFB21F"/>
|
||
<ellipse cx="28" cy="50" rx="6" ry="8" fill="#FFB21F"/>
|
||
<ellipse cx="43" cy="50" rx="6" ry="8" fill="#FFB21F"/>
|
||
<ellipse cx="55" cy="49" rx="5" ry="7" fill="#FFB21F"/>
|
||
<path d="M11 36L23 30H50L58 32L47 37H14L11 36Z" fill="#1F43C9"/>
|
||
<rect x="24" y="14" width="7" height="23" rx="3.5" fill="#1B120F"/>
|
||
<path d="M18 38L25 36L41 36L51 40L51 46L42 49L38 59L31 59L29 50L19 47L18 38Z" fill="#1B120F"/>
|
||
<rect x="21" y="38" width="5" height="10" rx="2.5" fill="#1B120F"/>
|
||
<rect x="43" y="39" width="5" height="11" rx="2.5" fill="#1B120F"/>
|
||
<path d="M15 41L9 44L8 49" stroke="#1B120F" stroke-width="4" stroke-linecap="round"/>
|
||
<path d="M49 41L57 43L57 47" stroke="#1B120F" stroke-width="4" stroke-linecap="round"/>
|
||
<path d="M14 16C14 12 17 9 22 9H32C38 9 41 12 41 16C41 22 36 26 28 26H20C16 26 14 23 14 16Z" fill="#FFB21F"/>
|
||
<circle cx="21" cy="15.5" r="3.2" fill="white"/>
|
||
<circle cx="30.5" cy="14.5" r="3.6" fill="white"/>
|
||
<circle cx="21.5" cy="17" r="1.8" fill="#000"/>
|
||
<circle cx="30.8" cy="16" r="2.1" fill="#000"/>
|
||
<path d="M25 18C25.8 19.2 27.2 19.2 28 18V20.2C28 21.8 26.8 22.7 25.5 22.7C24.2 22.7 23 21.8 23 20.2V18C23.6 18.8 24.4 19.1 25 18Z" fill="#1B120F"/>
|
||
<path d="M12 12C13.5 9.2 17.8 8.2 21 10" stroke="#1B120F" stroke-width="2.8" stroke-linecap="round"/>
|
||
<path d="M29 7.5C31.8 6.2 35.6 7.1 37.6 9.7" stroke="#1B120F" stroke-width="2.8" stroke-linecap="round"/>
|
||
</svg>
|
||
<span class="hdr-title" title="CUNO = Celestian Unified Navigation and Observation">CUNO <em>ADMIN</em></span>
|
||
<div class="hdr-div"></div>
|
||
<div class="hdr-badge">System Control</div>
|
||
<div class="hdr-gap"></div>
|
||
<span id="admin_status" class="hdr-status">Lade...</span>
|
||
<div class="hdr-div"></div>
|
||
<div class="mission-ok"><div class="pulse-dot"></div><span id="page-connection-label">Prüfe…</span></div>
|
||
<div class="hdr-div"></div>
|
||
<a class="hdr-back" href="./index.html">← Ground Control</a>
|
||
<div class="hdr-div"></div>
|
||
<div class="hdr-clk">
|
||
<div class="hdr-clk-lbl">UTC</div>
|
||
<div class="hdr-clk-val" id="utc-clock">--:--:--</div>
|
||
</div>
|
||
</header>
|
||
<div id="connection-banner" class="connection-banner" hidden>
|
||
<strong id="connection-banner-title">Verbindung wird geprüft</strong>
|
||
<span id="connection-banner-detail">Warte auf ROS und Admin-API.</span>
|
||
</div>
|
||
|
||
<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_camera" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('camera')">Kamera</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_map" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('map')">Karte</button>
|
||
<button id="admin_tab_gps" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('gps')">GPS</button>
|
||
<button id="admin_tab_motor" class="admin-section-tab" role="tab" aria-selected="false" onclick="switchAdminSection('motor')">Motorentest</button>
|
||
</div>
|
||
|
||
<section id="admin_panel_system" class="admin-section-panel is_active" 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">Aktionen</span>
|
||
<span class="p-tag">System Control</span>
|
||
</div>
|
||
<div class="act-grid">
|
||
<button class="act-btn warm" onclick="runAdminAction('restart-camera')">Kamera neu starten</button>
|
||
<button class="act-btn danger" onclick="runAdminAction('restart-container')">Container neu starten</button>
|
||
<button class="act-btn warm" onclick="runAdminAction('reboot')">Pi neu starten</button>
|
||
<button class="act-btn danger" onclick="runAdminAction('shutdown')">Pi herunterfahren</button>
|
||
<button class="act-btn subtle full" onclick="runRumble()">Controller vibrieren</button>
|
||
</div>
|
||
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Dienste</span><div class="ss-hdr-line"></div></div>
|
||
<div class="sr">
|
||
<span class="sr-led led-ok"></span>
|
||
<span class="sr-key">Kamera</span>
|
||
<strong id="service_camera" class="sr-val service_state state_unknown">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Admin API</span>
|
||
<strong id="service_admin_api" class="sr-val service_state state_unknown">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-ok"></span>
|
||
<span class="sr-key">ExoMy</span>
|
||
<strong id="service_exomy" class="sr-val service_state state_unknown">-</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">IMU BNO085</span>
|
||
<span class="p-tag">I2C 0x4A</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Status</span>
|
||
<strong id="imu_state" class="sr-val service_state state_unknown">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">Adresse</span>
|
||
<strong id="imu_address" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">Accel</span>
|
||
<strong id="imu_acceleration" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">Gyro</span>
|
||
<strong id="imu_gyro" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">Mag</span>
|
||
<strong id="imu_magnetic" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">Quaternion</span>
|
||
<strong id="imu_quaternion" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">RPY</span>
|
||
<strong id="imu_roll_pitch_yaw" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Heading roh</span>
|
||
<strong id="imu_heading_raw" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-ok"></span>
|
||
<span class="sr-key">Heading korr.</span>
|
||
<strong id="imu_heading_corrected" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">Nord-Offset</span>
|
||
<strong id="imu_heading_offset" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-ok"></span>
|
||
<span class="sr-key">Roll korr.</span>
|
||
<strong id="imu_roll_corrected" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-ok"></span>
|
||
<span class="sr-key">Pitch korr.</span>
|
||
<strong id="imu_pitch_corrected" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">Neig.-Offset</span>
|
||
<strong id="imu_tilt_offset" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="act-grid">
|
||
<button class="act-btn subtle full" onclick="setImuNorth()">Aktuelle Richtung als Norden setzen</button>
|
||
<button class="act-btn subtle full" onclick="setImuLevel()">Aktuelle Neigung als Null setzen</button>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">HUD-Farbe</span>
|
||
<span class="sr-val" style="display:flex;align-items:center;gap:6px;">
|
||
<input type="color" id="hud_color_picker" value="#00ff91" style="width:32px;height:22px;border:none;background:none;cursor:pointer;padding:0;" oninput="applyHudColorFromAdmin(this.value)" onchange="applyHudColorFromAdmin(this.value)">
|
||
<button class="act-btn subtle" style="padding:2px 8px;font-size:11px;" onclick="resetHudColor()">Reset</button>
|
||
</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">Ziel-Heading</span>
|
||
<span class="sr-val" style="display:flex;align-items:center;gap:6px;">
|
||
<input type="number" id="rotate_target" min="0" max="359" step="1" value="0"
|
||
style="width:60px;background:#111;color:#eee;border:1px solid #444;padding:2px 4px;font-size:12px;border-radius:3px;">
|
||
<span style="font-size:11px;color:#888;">° (0=N, 90=O, 180=S, 270=W)</span>
|
||
</span>
|
||
</div>
|
||
<div class="act-grid">
|
||
<button class="act-btn subtle full" id="rotate_start_btn" onclick="startRotate()">Zu Heading drehen</button>
|
||
<button class="act-btn subtle full" id="rotate_stop_btn" onclick="stopRotate()" style="display:none;">Drehung abbrechen</button>
|
||
</div>
|
||
<div class="sr" id="rotate_status_row" style="display:none;">
|
||
<span class="sr-led led-blue" id="rotate_status_led"></span>
|
||
<span class="sr-key">Drehung</span>
|
||
<strong id="rotate_status_val" class="sr-val">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">Hinweis</span>
|
||
<strong id="imu_error" class="sr-val">-</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-col">
|
||
<div class="panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">Latenz-Simulation</span>
|
||
<span class="p-tag">Signal Delay</span>
|
||
</div>
|
||
<div class="dropdown-row">
|
||
<label>Delay</label>
|
||
<select id="delay_select" class="ctrl-select" onchange="setDelay(parseFloat(this.value))">
|
||
<option value="0">0 s – kein Delay</option>
|
||
<option value="1">1 s</option>
|
||
<option value="2">2 s</option>
|
||
<option value="3">3 s</option>
|
||
<option value="4">4 s</option>
|
||
<option value="5">5 s</option>
|
||
<option value="6">6 s</option>
|
||
<option value="7">7 s</option>
|
||
<option value="8">8 s</option>
|
||
<option value="9">9 s</option>
|
||
<option value="10">10 s</option>
|
||
</select>
|
||
<span id="delay_status" class="ctrl-status"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">Geschwindigkeit</span>
|
||
<span class="p-tag">Speed Limit</span>
|
||
</div>
|
||
<div class="dropdown-row">
|
||
<label>Limit</label>
|
||
<select id="speed_limit_select" class="ctrl-select" onchange="setSpeedLimit(parseInt(this.value, 10))">
|
||
<option value="10">10 %</option>
|
||
<option value="20">20 %</option>
|
||
<option value="30">30 %</option>
|
||
<option value="40">40 %</option>
|
||
<option value="50">50 %</option>
|
||
<option value="60">60 %</option>
|
||
<option value="70">70 %</option>
|
||
<option value="80">80 %</option>
|
||
<option value="90">90 %</option>
|
||
<option value="100" selected>100 % – kein Limit</option>
|
||
</select>
|
||
<span id="speed_limit_status" class="ctrl-status"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">Systemstatus</span>
|
||
<span class="p-tag">Telemetry</span>
|
||
</div>
|
||
<div class="systemstatus-grid">
|
||
<div class="systemstatus-section">
|
||
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Netzwerk</span><div class="ss-hdr-line"></div></div>
|
||
<div class="sr">
|
||
<span class="sr-led led-ok"></span>
|
||
<span class="sr-key">WLAN</span>
|
||
<span class="sr-val" id="info_wifi">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">SSID</span>
|
||
<span class="sr-val" id="info_wifi_ssid">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Signal</span>
|
||
<span class="sr-val" id="info_wifi_signal">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Signal dBm</span>
|
||
<span class="sr-val" id="info_wifi_signal_dbm">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Kanal</span>
|
||
<span class="sr-val" id="info_wifi_channel">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Frequenz</span>
|
||
<span class="sr-val" id="info_wifi_frequency">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">IP</span>
|
||
<span class="sr-val" id="info_ips">-</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="systemstatus-section">
|
||
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">System</span><div class="ss-hdr-line"></div></div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Pi-Modell</span>
|
||
<span class="sr-val" id="info_pi_model">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Hostname</span>
|
||
<span class="sr-val" id="info_hostname">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Kernel</span>
|
||
<span class="sr-val" id="info_kernel">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Pi-Zeit</span>
|
||
<span class="sr-val" id="info_system_time">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Zeitzone</span>
|
||
<span class="sr-val" id="info_system_timezone">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Zeit-Sync</span>
|
||
<span class="sr-val" id="info_time_sync">-</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="systemstatus-section">
|
||
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Hardware</span><div class="ss-hdr-line"></div></div>
|
||
<div class="sr sr-gauge gauge-temp">
|
||
<span class="sr-key">Temp</span>
|
||
<div class="gauge"><div class="gauge-fill" id="bar-temp-adm"></div></div>
|
||
<span class="gauge-num" id="info_temp">--</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">GPU-/SoC-Temp</span>
|
||
<span class="sr-val" id="info_gpu_temp">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">CPU gesamt</span>
|
||
<span class="sr-val" id="info_cpu_usage">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">CPU Kerne</span>
|
||
<span class="sr-val" id="info_cpu_per_core">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">RAM</span>
|
||
<span class="sr-val" id="info_memory_details">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">Spannung</span>
|
||
<span class="sr-val" id="info_undervoltage">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Throttling</span>
|
||
<span class="sr-val" id="info_throttling">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Uptime</span>
|
||
<span class="sr-val" id="info_uptime">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Load 1/5/15</span>
|
||
<span class="sr-val" id="info_load_average">-</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="systemstatus-section">
|
||
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Speicher & Container</span><div class="ss-hdr-line"></div></div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Root-Dateisystem</span>
|
||
<span class="sr-val" id="info_disk_root">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Docker-Speicher</span>
|
||
<span class="sr-val" id="info_disk_docker">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Inodes frei</span>
|
||
<span class="sr-val" id="info_inode_free">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Container-Uptime</span>
|
||
<span class="sr-val" id="info_container_uptime">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Container-Restarts</span>
|
||
<span class="sr-val" id="info_container_restarts">-</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Disk frei</span>
|
||
<span class="sr-val" id="info_disk">-</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="admin_panel_camera" class="admin-section-panel" role="tabpanel">
|
||
<div class="admin-section-grid">
|
||
<div class="admin-col">
|
||
<div class="panel camera-panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">Kamera-Vorschau</span>
|
||
<span class="p-tag">Live Stream</span>
|
||
</div>
|
||
<div class="copy-text camera-copy">
|
||
Die Vorschau kommt direkt vom Kameradienst auf Port <code>8081</code>. Nach einem Profilwechsel startet der Dienst kurz neu, das Bild setzt dann automatisch wieder ein.
|
||
</div>
|
||
<div class="camera-preview-stage">
|
||
<img id="camera_preview_image" class="camera-preview-image" alt="Kamera-Vorschau" src="" loading="eager">
|
||
</div>
|
||
<div class="camera-preview-meta">
|
||
<div class="sr">
|
||
<span class="sr-led led-ok"></span>
|
||
<span class="sr-key">Profil</span>
|
||
<span id="camera_profile_label" class="sr-val">--</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Auflösung</span>
|
||
<span id="camera_resolution_label" class="sr-val">--</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Bildrate</span>
|
||
<span id="camera_fps_label" class="sr-val">--</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-col">
|
||
<div class="panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">Kamera-Stream</span>
|
||
<span class="p-tag">Konfiguration</span>
|
||
</div>
|
||
<div class="dropdown-row">
|
||
<label>Profil</label>
|
||
<select id="camera_profile_select" class="ctrl-select" onchange="setCameraProfile(this.value)">
|
||
<option value="640x480">640 x 480</option>
|
||
<option value="1024x768">1024 x 768</option>
|
||
<option value="1296x972">1296 x 972</option>
|
||
</select>
|
||
</div>
|
||
<div class="dropdown-row">
|
||
<label>Bildrate</label>
|
||
<select id="camera_fps_select" class="ctrl-select" onchange="setCameraFps(this.value)">
|
||
<option value="1">1 fps</option>
|
||
<option value="2">2 fps</option>
|
||
<option value="3">3 fps</option>
|
||
<option value="4">4 fps</option>
|
||
<option value="5">5 fps</option>
|
||
<option value="7">7 fps</option>
|
||
<option value="10">10 fps</option>
|
||
<option value="20">20 fps</option>
|
||
</select>
|
||
</div>
|
||
<div class="dropdown-status-row">
|
||
<span id="camera_profile_status" class="ctrl-status ctrl-status_block"></span>
|
||
</div>
|
||
<div class="copy-text camera-copy">
|
||
Auflösung und Bildrate werden gespeichert. Danach startet der Kameradienst automatisch neu. Der Videostream ist dabei für einen kurzen Moment unterbrochen.
|
||
</div>
|
||
<div class="act-grid act-grid_single">
|
||
<button class="act-btn subtle full" onclick="refreshCameraPreview()">Vorschau neu laden</button>
|
||
</div>
|
||
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Dienst</span><div class="ss-hdr-line"></div></div>
|
||
<div class="sr">
|
||
<span class="sr-led led-ok"></span>
|
||
<span class="sr-key">Kamera</span>
|
||
<strong id="camera_service_state" class="sr-val service_state state_unknown">-</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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_map" class="admin-section-panel" role="tabpanel">
|
||
<div class="admin-section-grid admin-section-grid_map">
|
||
<div class="admin-col admin-col_map_main">
|
||
<div class="panel map-panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">Rover-Karte</span>
|
||
<span class="p-tag">Live Position</span>
|
||
</div>
|
||
<div class="map-toolbar">
|
||
<button id="map_plan_toggle" class="act-btn subtle" onclick="toggleWaypointPlanning()">Route planen: aus</button>
|
||
<button class="act-btn" onclick="addWaypointAtMapCenter()">Wegpunkt an Mitte setzen</button>
|
||
<button class="act-btn warm" onclick="centerMapOnRover()">Auf Rover zentrieren</button>
|
||
<button class="act-btn subtle" onclick="clearPlannedRoute()">Route löschen</button>
|
||
</div>
|
||
<div class="copy-text map-copy">
|
||
Die Karte zeigt den aktuellen GPS-Standort des Rovers. Ein Klick in die Karte setzt sofort einen Wegpunkt, solange <code>Route planen</code> aktiv ist. Die eigentliche Routenausführung binden wir später darauf auf.
|
||
</div>
|
||
<div class="map-stage">
|
||
<div id="rover_map" class="rover-map"></div>
|
||
<div id="rover_map_fallback" class="rover-map-fallback map-hidden" hidden>
|
||
Kartenbibliothek nicht geladen. GPS-Daten sind trotzdem unten sichtbar.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-col admin-col_map_side">
|
||
<div class="panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">GPS</span>
|
||
<span class="p-tag">Telemetry</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-ok"></span>
|
||
<span class="sr-key">Status</span>
|
||
<span id="map_gps_status" class="sr-val">Offline</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Sat</span>
|
||
<span id="map_gps_satellites" class="sr-val">--</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Breite</span>
|
||
<span id="map_gps_lat" class="sr-val">--</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Länge</span>
|
||
<span id="map_gps_lon" class="sr-val">--</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Höhe</span>
|
||
<span id="map_gps_alt" class="sr-val">--</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Genauigk.</span>
|
||
<span id="map_gps_accuracy" class="sr-val">--</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Letztes Fix</span>
|
||
<span id="map_gps_last_fix" class="sr-val">--</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">Route</span>
|
||
<span class="p-tag">Planning Scaffold</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Planung</span>
|
||
<span id="map_route_mode" class="sr-val">aus</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Wegpunkte</span>
|
||
<span id="map_waypoint_count" class="sr-val">0</span>
|
||
</div>
|
||
<div class="copy-text map-copy">
|
||
Diese Wegpunkte sind aktuell nur lokal in der Admin-Seite. Sie dienen als Vorbereitung für die spätere echte Routenprogrammierung.
|
||
</div>
|
||
<div class="map-route-storage">
|
||
<label class="map-route-storage-label" for="map_route_name">Name</label>
|
||
<input id="map_route_name" class="map-route-storage-input" type="text" placeholder="z. B. Hofrunde">
|
||
<button class="act-btn" onclick="saveCurrentRoute()">Route speichern</button>
|
||
</div>
|
||
<div class="map-route-storage map-route-storage_secondary">
|
||
<label class="map-route-storage-label" for="map_route_select">Gespeichert</label>
|
||
<select id="map_route_select" class="ctrl-select" onchange="loadSelectedRoute()">
|
||
<option value="">Route wählen</option>
|
||
</select>
|
||
<button class="act-btn subtle" onclick="refreshRouteLibrary()">Neu laden</button>
|
||
<button class="act-btn warm" onclick="deleteSelectedRoute()">Route löschen</button>
|
||
</div>
|
||
<div id="map_waypoint_list" class="map-waypoint-list"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="admin_panel_gps" class="admin-section-panel" role="tabpanel">
|
||
<div class="admin-section-grid admin-section-grid_gps">
|
||
<div class="admin-col admin-col_gps_main">
|
||
<div class="panel gps-panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">GNSS-Satelliten</span>
|
||
<span class="p-tag">Himmelskarte</span>
|
||
</div>
|
||
<div class="copy-text gps-copy">
|
||
Diese Ansicht zeigt die vom Empfänger gemeldeten Satelliten im Himmelsplot. Außen ist der Horizont, die Mitte ist der Zenit.
|
||
</div>
|
||
<div class="gps-sky-stage">
|
||
<canvas id="gps_sky_canvas" class="gps-sky-canvas" width="560" height="560"></canvas>
|
||
<div id="gps_satellite_tooltip" class="gps-satellite-tooltip" hidden></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-col admin-col_gps_side">
|
||
<div class="panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">Fix-Diagnose</span>
|
||
<span class="p-tag">GNSS-Diagnose</span>
|
||
</div>
|
||
<div class="act-grid act-grid_single">
|
||
<button class="act-btn danger full" onclick="runAdminAction('cold-start-gps')">GPS-Kaltstart</button>
|
||
<button class="act-btn warm full" onclick="runAdminAction('restart-gps')">GPS neu starten</button>
|
||
<button class="act-btn subtle full" onclick="runAdminAction('set-time-from-gps')">Pi-Zeit aus GPS setzen</button>
|
||
</div>
|
||
<div class="sr"><span class="sr-led led-ok"></span><span class="sr-key">Status</span><span id="gps_diag_status" class="sr-val">Offline</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Fix-Qualität</span><span id="gps_diag_fix_quality" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Frame</span><span id="gps_diag_frame" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Breite</span><span id="gps_diag_lat" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Länge</span><span id="gps_diag_lon" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Höhe</span><span id="gps_diag_alt" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Geschwindigkeit</span><span id="gps_diag_speed" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Kurs</span><span id="gps_diag_track" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">UTC-Datum</span><span id="gps_diag_utc_date" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">UTC-Zeit</span><span id="gps_diag_utc_time" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Fix-Alter</span><span id="gps_diag_fix_age" class="sr-val">--</span></div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">Genauigkeit</span>
|
||
<span class="p-tag">Qualität</span>
|
||
</div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Positionsgenauigkeit</span><span id="gps_diag_pdop" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Horizontale Genauigkeit</span><span id="gps_diag_hdop" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Vertikale Genauigkeit</span><span id="gps_diag_vdop" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Für Fix genutzt</span><span id="gps_diag_sat_used" class="sr-val">--</span></div>
|
||
<div class="sr"><span class="sr-led led-blue"></span><span class="sr-key">Im Himmelsplot</span><span id="gps_diag_sat_visible" class="sr-val">--</span></div>
|
||
<div class="copy-text gps-copy">
|
||
Für eine gute Fix-Qualität sind niedrige DOP-Werte und hohe, gleichmäßig verteilte Satelliten sinnvoll. Der Himmelsplot zeigt nur Satelliten, die der Empfänger per GSV mit Richtung und Höhe meldet.
|
||
</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">
|
||
<div class="panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">Testmodus</span>
|
||
<span class="p-tag">Container Control</span>
|
||
</div>
|
||
<div class="copy-text">
|
||
Stoppe zuerst den ExoMy-Container. Erst wenn der Status eindeutig auf gestoppt steht, solltest du Motoren testen oder die Servo-Mitten ausrichten.
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-blue"></span>
|
||
<span class="sr-key">Container</span>
|
||
<span class="sr-val" id="container_name">exomy_autostart</span>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">Status</span>
|
||
<strong id="container_state" class="sr-val service_state state_unknown">-</strong>
|
||
</div>
|
||
<div class="sr">
|
||
<span class="sr-led led-off"></span>
|
||
<span class="sr-key">Sicher testen</span>
|
||
<span class="sr-val" id="container_safe_state">-</span>
|
||
</div>
|
||
<div class="container-grid">
|
||
<button class="button buttonDanger" onclick="setContainerState('stop')">Container stoppen</button>
|
||
<button class="button buttonA" onclick="setContainerState('start')">Container starten</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-col">
|
||
<div class="panel">
|
||
<div class="p-hdr">
|
||
<div class="p-dot"></div>
|
||
<span class="p-lbl">Motorentest</span>
|
||
<span class="p-tag">Motor Diagnostics</span>
|
||
</div>
|
||
|
||
<div class="tab-bar" role="tablist" aria-label="Motorentest Bereiche">
|
||
<button id="tab_button_impulse" class="motor_test_tab_button is_active" role="tab" aria-selected="true" onclick="switchMotorTestTab('impulse')">Impulstest</button>
|
||
<button id="tab_button_neutral" class="motor_test_tab_button" role="tab" aria-selected="false" onclick="switchMotorTestTab('neutral')">Servo-Mitte</button>
|
||
<button id="tab_button_drive_neutral" class="motor_test_tab_button" role="tab" aria-selected="false" onclick="switchMotorTestTab('drive-neutral')">Fahr-Null</button>
|
||
</div>
|
||
|
||
<div class="tab-content-wrap">
|
||
<section id="tab_panel_impulse" class="motor_test_tab_panel is_active" role="tabpanel">
|
||
<div class="tab-row">
|
||
<div class="tab-box">
|
||
<div class="tab-box-lbl">Rad auswählen</div>
|
||
<div class="wheel_selector" role="radiogroup" aria-label="Rad auswählen">
|
||
<label class="wheel_option"><input type="radio" name="wheel" value="fl" checked><span>Vorne links</span></label>
|
||
<label class="wheel_option"><input type="radio" name="wheel" value="fr"><span>Vorne rechts</span></label>
|
||
<label class="wheel_option"><input type="radio" name="wheel" value="cl"><span>Mitte links</span></label>
|
||
<label class="wheel_option"><input type="radio" name="wheel" value="cr"><span>Mitte rechts</span></label>
|
||
<label class="wheel_option"><input type="radio" name="wheel" value="rl"><span>Hinten links</span></label>
|
||
<label class="wheel_option"><input type="radio" name="wheel" value="rr"><span>Hinten rechts</span></label>
|
||
</div>
|
||
</div>
|
||
<div class="tab-box">
|
||
<div class="tab-box-lbl">Impulstest</div>
|
||
<div class="motor_test_grid">
|
||
<button class="button buttonX" onclick="runMotorTest('steer_left')">Lenkung links</button>
|
||
<button class="button buttonX" onclick="runMotorTest('steer_right')">Lenkung rechts</button>
|
||
<button class="button buttonA" onclick="runMotorTest('drive_forward')">Vorwärts</button>
|
||
<button class="button buttonDanger" onclick="runMotorTest('drive_backward')">Rückwärts</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="tab_panel_neutral" class="motor_test_tab_panel" role="tabpanel" hidden>
|
||
<div class="copy-text">
|
||
Mit <code>-</code> und <code>+</code> wird die aktuelle Mittelstellung direkt am jeweiligen Lenkservo angefahren. <code>Speichern</code> schreibt alle Werte dauerhaft in die <code>exomy.yaml</code>.
|
||
</div>
|
||
<div class="neutral-toolbar">
|
||
<button class="button buttonWarm" onclick="loadSteeringNeutralValues()">Neu laden</button>
|
||
<button id="neutral_save_button" class="button buttonA" onclick="saveSteeringNeutralValues()">Speichern</button>
|
||
</div>
|
||
<div id="steering_neutral_grid" class="steering_neutral_grid"></div>
|
||
</section>
|
||
|
||
<section id="tab_panel_drive_neutral" class="motor_test_tab_panel" role="tabpanel" hidden>
|
||
<div class="copy-text">
|
||
Mit <code>-</code> und <code>+</code> stellst du den Stillstands-PWM pro Fahrmotor ein. <code>Speichern</code> schreibt alle sechs Werte dauerhaft in die <code>exomy.yaml</code>.
|
||
</div>
|
||
<div class="neutral-toolbar">
|
||
<button class="button buttonWarm" onclick="loadDriveNeutralValue()">Neu laden</button>
|
||
<button id="drive_neutral_save_button" class="button buttonA" onclick="saveDriveNeutralValue()">Speichern</button>
|
||
</div>
|
||
<div id="drive_neutral_grid" class="steering_neutral_grid"></div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="auth_overlay" class="auth-overlay">
|
||
<div class="auth-box">
|
||
<div class="auth-eyebrow">Admin-Zugang</div>
|
||
<div class="auth-title">CUNO</div>
|
||
<div class="auth-sub">Passwort eingeben um fortzufahren</div>
|
||
<input type="password" id="auth_input" class="auth-input" placeholder="Passwort" autocomplete="current-password">
|
||
<p id="auth_error" class="auth-error"></p>
|
||
<button class="auth-submit" onclick="submitPassword()">Entsperren</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="text/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 imuDataTopic = null;
|
||
var imuMagTopic = null;
|
||
var imuStatusTopic = 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: []
|
||
};
|
||
var imuRosState = {
|
||
state: 'unknown',
|
||
status: '-',
|
||
address: '0x4A',
|
||
acceleration: '--',
|
||
gyro: '--',
|
||
magnetic: '--',
|
||
quaternion: '--',
|
||
rollPitchYaw: '--',
|
||
headingRawDeg: null,
|
||
headingCorrectedDeg: null,
|
||
headingOffsetDeg: 0,
|
||
rollRawDeg: null,
|
||
pitchRawDeg: null,
|
||
rollOffsetDeg: 0,
|
||
pitchOffsetDeg: 0,
|
||
error: '--',
|
||
lastDataAt: 0,
|
||
lastMagAt: 0,
|
||
lastStatusAt: 0
|
||
};
|
||
|
||
function submitPassword() {
|
||
var input = document.getElementById('auth_input');
|
||
var error = document.getElementById('auth_error');
|
||
var value = String(input.value || '').trim();
|
||
if (value === 'cuno' || btoa(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_wifi_ssid', '--');
|
||
setText('info_wifi_signal', '--');
|
||
setText('info_wifi_signal_dbm', '--');
|
||
setText('info_wifi_channel', '--');
|
||
setText('info_wifi_frequency', '--');
|
||
setText('info_pi_model', '--');
|
||
setText('info_hostname', '--');
|
||
setText('info_kernel', '--');
|
||
setText('info_system_time', '--');
|
||
setText('info_system_timezone', '--');
|
||
setText('info_time_sync', '--');
|
||
setText('info_temp', '--');
|
||
setText('info_gpu_temp', '--');
|
||
setText('info_cpu_usage', '--');
|
||
setText('info_cpu_per_core', '--');
|
||
setText('info_memory_details', '--');
|
||
setText('info_uptime', '--');
|
||
setText('info_disk', '--');
|
||
setText('info_disk_root', '--');
|
||
setText('info_disk_docker', '--');
|
||
setText('info_inode_free', '--');
|
||
setText('info_load_average', '--');
|
||
setText('info_throttling', '--');
|
||
setText('info_container_uptime', '--');
|
||
setText('info_container_restarts', '--');
|
||
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 = '--';
|
||
roverMapState.trackPoints = [];
|
||
updateMapTelemetryPanel();
|
||
updateRoverMapVisibility();
|
||
|
||
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 = [];
|
||
imuRosState.state = 'unknown';
|
||
imuRosState.status = 'ROS getrennt';
|
||
imuRosState.address = '0x4A';
|
||
imuRosState.acceleration = '--';
|
||
imuRosState.gyro = '--';
|
||
imuRosState.magnetic = '--';
|
||
imuRosState.quaternion = '--';
|
||
imuRosState.rollPitchYaw = '--';
|
||
imuRosState.headingRawDeg = null;
|
||
imuRosState.headingCorrectedDeg = null;
|
||
imuRosState.rollRawDeg = null;
|
||
imuRosState.pitchRawDeg = null;
|
||
imuRosState.error = 'Keine ROS-Verbindung';
|
||
imuRosState.lastDataAt = 0;
|
||
imuRosState.lastMagAt = 0;
|
||
imuRosState.lastStatusAt = 0;
|
||
renderImuRosState();
|
||
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 setStateText(id, state, text) {
|
||
var node = document.getElementById(id);
|
||
if (!node) return;
|
||
node.textContent = text || '-';
|
||
node.classList.remove('state_active', 'state_inactive', 'state_unknown');
|
||
if (state === 'active' || state === 'running') { node.classList.add('state_active'); return; }
|
||
if (state === 'inactive' || state === 'failed' || state === 'exited') { node.classList.add('state_inactive'); return; }
|
||
node.classList.add('state_unknown');
|
||
}
|
||
|
||
function formatImuVector(values, unit, decimals) {
|
||
if (!Array.isArray(values) || values.length < 3) return '--';
|
||
var digits = typeof decimals === 'number' ? decimals : 2;
|
||
return 'X ' + Number(values[0]).toFixed(digits) +
|
||
' | Y ' + Number(values[1]).toFixed(digits) +
|
||
' | Z ' + Number(values[2]).toFixed(digits) + ' ' + unit;
|
||
}
|
||
|
||
function formatImuQuaternion(values) {
|
||
if (!Array.isArray(values) || values.length < 4) return '--';
|
||
return 'I ' + Number(values[0]).toFixed(3) +
|
||
' | J ' + Number(values[1]).toFixed(3) +
|
||
' | K ' + Number(values[2]).toFixed(3) +
|
||
' | R ' + Number(values[3]).toFixed(3);
|
||
}
|
||
|
||
function quaternionToEulerDeg(values) {
|
||
if (!Array.isArray(values) || values.length < 4) return null;
|
||
var x = Number(values[0]);
|
||
var y = Number(values[1]);
|
||
var z = Number(values[2]);
|
||
var w = Number(values[3]);
|
||
var sinrCosp = 2 * (w * x + y * z);
|
||
var cosrCosp = 1 - 2 * (x * x + y * y);
|
||
var roll = Math.atan2(sinrCosp, cosrCosp);
|
||
var sinp = 2 * (w * y - z * x);
|
||
var pitch = Math.abs(sinp) >= 1 ? Math.sign(sinp) * (Math.PI / 2) : Math.asin(sinp);
|
||
var sinyCosp = 2 * (w * z + x * y);
|
||
var cosyCosp = 1 - 2 * (y * y + z * z);
|
||
var yaw = Math.atan2(sinyCosp, cosyCosp);
|
||
return [
|
||
roll * 180 / Math.PI,
|
||
pitch * 180 / Math.PI,
|
||
yaw * 180 / Math.PI
|
||
];
|
||
}
|
||
|
||
function formatRollPitchYaw(values) {
|
||
if (!Array.isArray(values) || values.length < 3) return '--';
|
||
return 'Roll ' + Number(values[0]).toFixed(1) + '° | ' +
|
||
'Pitch ' + Number(values[1]).toFixed(1) + '° | ' +
|
||
'Yaw ' + Number(values[2]).toFixed(1) + '°';
|
||
}
|
||
|
||
function normalizeHeadingDeg(value) {
|
||
var heading = Number(value || 0) % 360;
|
||
if (heading < 0) heading += 360;
|
||
return heading;
|
||
}
|
||
|
||
function formatHeadingDeg(value) {
|
||
if (typeof value !== 'number' || isNaN(value)) return '--';
|
||
return normalizeHeadingDeg(value).toFixed(1).replace('.', ',') + '\u00B0';
|
||
}
|
||
|
||
function formatHeadingOffsetDeg(value) {
|
||
if (typeof value !== 'number' || isNaN(value)) return '--';
|
||
var prefix = value >= 0 ? '+' : '';
|
||
return prefix + Number(value).toFixed(1).replace('.', ',') + '\u00B0';
|
||
}
|
||
|
||
function renderImuRosState() {
|
||
var now = Date.now();
|
||
var dataFresh = imuRosState.lastDataAt && (now - imuRosState.lastDataAt) < 3000;
|
||
var magFresh = imuRosState.lastMagAt && (now - imuRosState.lastMagAt) < 3000;
|
||
var statusFresh = imuRosState.lastStatusAt && (now - imuRosState.lastStatusAt) < 5000;
|
||
var state = imuRosState.state;
|
||
var status = imuRosState.status;
|
||
var error = imuRosState.error;
|
||
|
||
if (!pageConnection.rosOk) {
|
||
state = 'unknown';
|
||
status = 'ROS getrennt';
|
||
error = 'Keine ROS-Verbindung';
|
||
} else if (!dataFresh) {
|
||
state = 'unknown';
|
||
status = 'Warte auf IMU-Daten';
|
||
if (!error || error === '--') error = 'Noch keine frischen IMU-Daten';
|
||
} else if (!statusFresh && (!status || status === '-')) {
|
||
status = 'Verbunden';
|
||
}
|
||
|
||
setStateText('imu_state', state, status || '-');
|
||
setText('imu_address', imuRosState.address);
|
||
setText('imu_acceleration', dataFresh ? imuRosState.acceleration : '--');
|
||
setText('imu_gyro', dataFresh ? imuRosState.gyro : '--');
|
||
setText('imu_magnetic', magFresh ? imuRosState.magnetic : '--');
|
||
setText('imu_quaternion', dataFresh ? imuRosState.quaternion : '--');
|
||
setText('imu_roll_pitch_yaw', dataFresh ? imuRosState.rollPitchYaw : '--');
|
||
setText('imu_heading_raw', dataFresh ? formatHeadingDeg(imuRosState.headingRawDeg) : '--');
|
||
setText('imu_heading_corrected', dataFresh ? formatHeadingDeg(imuRosState.headingCorrectedDeg) : '--');
|
||
setText('imu_heading_offset', formatHeadingOffsetDeg(imuRosState.headingOffsetDeg));
|
||
var corrRoll = dataFresh && imuRosState.rollRawDeg !== null ? imuRosState.rollRawDeg + imuRosState.rollOffsetDeg : null;
|
||
var corrPitch = dataFresh && imuRosState.pitchRawDeg !== null ? imuRosState.pitchRawDeg + imuRosState.pitchOffsetDeg : null;
|
||
setText('imu_roll_corrected', corrRoll !== null ? corrRoll.toFixed(1) + '°' : '--');
|
||
setText('imu_pitch_corrected', corrPitch !== null ? corrPitch.toFixed(1) + '°' : '--');
|
||
setText('imu_tilt_offset', 'R ' + imuRosState.rollOffsetDeg.toFixed(1) + '° | P ' + imuRosState.pitchOffsetDeg.toFixed(1) + '°');
|
||
setText('imu_error', error || '-');
|
||
}
|
||
|
||
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 updateRoverMapVisibility() {
|
||
var fallback = document.getElementById('rover_map_fallback');
|
||
if (fallback) {
|
||
var showFallback = !roverMapState.hasFix || typeof roverMapState.roverLat !== 'number' || typeof roverMapState.roverLon !== 'number';
|
||
fallback.textContent = showFallback
|
||
? 'Keine GPS-Position verfügbar. Auf der Karte wird erst etwas angezeigt, wenn ein gültiger Fix vorliegt.'
|
||
: 'Kartenbibliothek nicht geladen. GPS-Daten sind trotzdem unten sichtbar.';
|
||
fallback.hidden = !showFallback;
|
||
fallback.classList.toggle('map-hidden', !showFallback);
|
||
}
|
||
|
||
if (!roverMapState.mapReady) return;
|
||
|
||
var hasVisiblePosition = roverMapState.hasFix && typeof roverMapState.roverLat === 'number' && typeof roverMapState.roverLon === 'number';
|
||
if (roverMapState.roverMarker) {
|
||
roverMapState.roverMarker.setStyle({opacity: hasVisiblePosition ? 1 : 0, fillOpacity: hasVisiblePosition ? 1 : 0});
|
||
}
|
||
if (roverMapState.roverAccuracyCircle) {
|
||
roverMapState.roverAccuracyCircle.setStyle({opacity: hasVisiblePosition ? 0.7 : 0, fillOpacity: hasVisiblePosition ? 0.1 : 0});
|
||
roverMapState.roverAccuracyCircle.setRadius(hasVisiblePosition ? (roverMapState.accuracyMeters || 0) : 0);
|
||
}
|
||
if (roverMapState.roverTrackLine) {
|
||
roverMapState.roverTrackLine.setLatLngs(hasVisiblePosition ? roverMapState.trackPoints : []);
|
||
}
|
||
}
|
||
|
||
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();
|
||
updateRoverMapVisibility();
|
||
}
|
||
|
||
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);
|
||
updateRoverMapVisibility();
|
||
|
||
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;
|
||
roverMapState.trackPoints = [];
|
||
updateMapTelemetryPanel();
|
||
updateRoverMapVisibility();
|
||
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;
|
||
gpsFixTopic = null;
|
||
gpsStatusTopic = null;
|
||
gpsDiagnosticsTopic = null;
|
||
imuDataTopic = null;
|
||
imuMagTopic = null;
|
||
imuStatusTopic = 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);
|
||
|
||
imuDataTopic = new ROSLIB.Topic({
|
||
ros: ros,
|
||
name: '/imu/data',
|
||
messageType: 'sensor_msgs/Imu'
|
||
});
|
||
imuDataTopic.subscribe(function(message) {
|
||
var linearAcceleration = message.linear_acceleration || {};
|
||
var angularVelocity = message.angular_velocity || {};
|
||
var orientation = message.orientation || {};
|
||
var quaternion = [
|
||
Number(orientation.x || 0),
|
||
Number(orientation.y || 0),
|
||
Number(orientation.z || 0),
|
||
Number(orientation.w || 1)
|
||
];
|
||
imuRosState.state = 'active';
|
||
imuRosState.address = '0x4A';
|
||
imuRosState.acceleration = formatImuVector([
|
||
Number(linearAcceleration.x || 0),
|
||
Number(linearAcceleration.y || 0),
|
||
Number(linearAcceleration.z || 0)
|
||
], 'm/s^2', 2);
|
||
imuRosState.gyro = formatImuVector([
|
||
Number(angularVelocity.x || 0),
|
||
Number(angularVelocity.y || 0),
|
||
Number(angularVelocity.z || 0)
|
||
], 'rad/s', 3);
|
||
imuRosState.quaternion = formatImuQuaternion(quaternion);
|
||
var eulerDeg = quaternionToEulerDeg(quaternion);
|
||
imuRosState.rollPitchYaw = formatRollPitchYaw(eulerDeg);
|
||
imuRosState.headingRawDeg = Array.isArray(eulerDeg) ? normalizeHeadingDeg(eulerDeg[2]) : null;
|
||
imuRosState.headingCorrectedDeg = Array.isArray(eulerDeg)
|
||
? normalizeHeadingDeg(eulerDeg[2] + Number(imuRosState.headingOffsetDeg || 0))
|
||
: null;
|
||
imuRosState.rollRawDeg = Array.isArray(eulerDeg) ? eulerDeg[0] : null;
|
||
imuRosState.pitchRawDeg = Array.isArray(eulerDeg) ? eulerDeg[1] : null;
|
||
imuRosState.lastDataAt = Date.now();
|
||
if (!imuRosState.error || imuRosState.error === 'Keine ROS-Verbindung' || imuRosState.error === 'Noch keine frischen IMU-Daten') {
|
||
imuRosState.error = '';
|
||
}
|
||
});
|
||
|
||
imuMagTopic = new ROSLIB.Topic({
|
||
ros: ros,
|
||
name: '/imu/mag',
|
||
messageType: 'sensor_msgs/MagneticField'
|
||
});
|
||
imuMagTopic.subscribe(function(message) {
|
||
var magneticField = message.magnetic_field || {};
|
||
imuRosState.magnetic = formatImuVector([
|
||
Number(magneticField.x || 0) * 1000000,
|
||
Number(magneticField.y || 0) * 1000000,
|
||
Number(magneticField.z || 0) * 1000000
|
||
], 'uT', 2);
|
||
imuRosState.lastMagAt = Date.now();
|
||
});
|
||
|
||
imuStatusTopic = new ROSLIB.Topic({
|
||
ros: ros,
|
||
name: '/imu/status',
|
||
messageType: 'std_msgs/String'
|
||
});
|
||
imuStatusTopic.subscribe(function(message) {
|
||
var text = String((message && message.data) || '').trim();
|
||
imuRosState.status = text || 'Verbunden';
|
||
imuRosState.state = text.indexOf('Fehler') === 0 ? 'unknown' : 'active';
|
||
imuRosState.error = text.indexOf('Fehler') === 0 ? text : '';
|
||
imuRosState.lastStatusAt = Date.now();
|
||
});
|
||
}
|
||
|
||
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) {
|
||
var system = data.system || {};
|
||
var services = data.services || {};
|
||
var imuHeading = data.imu_heading || {};
|
||
if (typeof imuHeading.offset_deg === 'number') imuRosState.headingOffsetDeg = imuHeading.offset_deg;
|
||
if (typeof imuHeading.raw_heading_deg === 'number') imuRosState.headingRawDeg = imuHeading.raw_heading_deg;
|
||
if (typeof imuHeading.corrected_heading_deg === 'number') imuRosState.headingCorrectedDeg = imuHeading.corrected_heading_deg;
|
||
var imuTilt = data.imu_tilt || {};
|
||
if (typeof imuTilt.roll_offset_deg === 'number') imuRosState.rollOffsetDeg = imuTilt.roll_offset_deg;
|
||
if (typeof imuTilt.pitch_offset_deg === 'number') imuRosState.pitchOffsetDeg = imuTilt.pitch_offset_deg;
|
||
if (typeof imuTilt.raw_roll_deg === 'number') imuRosState.rollRawDeg = imuTilt.raw_roll_deg;
|
||
if (typeof imuTilt.raw_pitch_deg === 'number') imuRosState.pitchRawDeg = imuTilt.raw_pitch_deg;
|
||
setAdminStatus(data.status || 'Bereit');
|
||
setText('info_ips', system.ips);
|
||
setText('info_wifi', system.wifi);
|
||
setText('info_wifi_ssid', system.wifi_ssid);
|
||
setText('info_wifi_signal', system.wifi_signal);
|
||
setText('info_wifi_signal_dbm', system.wifi_signal_dbm);
|
||
setText('info_wifi_channel', system.wifi_channel);
|
||
setText('info_wifi_frequency', system.wifi_frequency);
|
||
setText('info_pi_model', system.pi_model);
|
||
setText('info_hostname', system.hostname);
|
||
setText('info_kernel', system.kernel_version);
|
||
setText('info_system_time', system.system_time);
|
||
setText('info_system_timezone', system.system_timezone);
|
||
setText('info_time_sync', 'NTP: ' + (system.ntp_sync || '--') + ' | Uhr: ' + (system.clock_sync || '--'));
|
||
setText('info_temp', system.cpu_temperature);
|
||
setText('info_gpu_temp', system.gpu_temperature);
|
||
setText('info_cpu_usage', system.cpu_usage);
|
||
setText('info_cpu_per_core', system.cpu_usage_per_core);
|
||
setText('info_memory_details', system.memory_details || system.memory_usage);
|
||
var bar = document.getElementById('bar-temp-adm');
|
||
if (bar) bar.style.width = temperatureToWidth(system.cpu_temperature);
|
||
setUndervoltageState('info_undervoltage', system.undervoltage, system.undervoltage_state);
|
||
setText('info_throttling', system.throttling_details);
|
||
setText('info_uptime', system.uptime);
|
||
setText('info_load_average', system.load_average);
|
||
setText('info_disk', system.disk_free);
|
||
setText('info_disk_root', system.disk_root);
|
||
setText('info_disk_docker', system.disk_docker);
|
||
setText('info_inode_free', system.inode_free);
|
||
setText('info_container_uptime', services.exomy_uptime);
|
||
setText('info_container_restarts', services.exomy_restarts);
|
||
setServiceState('service_camera', services.camera);
|
||
setServiceState('camera_service_state', services.camera);
|
||
setServiceState('service_admin_api', services.admin_api);
|
||
setServiceState('service_exomy', services.exomy);
|
||
renderImuRosState();
|
||
}
|
||
|
||
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 = {
|
||
'cold-start-gps': 'GPS-Kaltstart jetzt wirklich auslösen? Danach dauert der neue Fix deutlich länger.',
|
||
'restart-gps': 'GPS-Node jetzt wirklich neu starten?',
|
||
'set-time-from-gps': 'Pi-Zeit jetzt wirklich aus den aktuellen GPS-Daten setzen?',
|
||
'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.');
|
||
}
|
||
}
|
||
|
||
(function() {
|
||
try {
|
||
var h = localStorage.getItem('hudHexColor');
|
||
if (h) {
|
||
var el = document.getElementById('hud_color_picker');
|
||
if (el) el.value = '#' + h;
|
||
}
|
||
} catch(e) {}
|
||
})();
|
||
|
||
function applyHudColorFromAdmin(cssColor) {
|
||
var hex = cssColor.replace('#', '');
|
||
try { localStorage.setItem('hudHexColor', hex); } catch(e) {}
|
||
}
|
||
|
||
function resetHudColor() {
|
||
var el = document.getElementById('hud_color_picker');
|
||
if (el) el.value = '#00ff91';
|
||
applyHudColorFromAdmin('#00ff91');
|
||
}
|
||
|
||
async function setImuNorth() {
|
||
if (!window.confirm('Rover jetzt wirklich sauber nach Norden ausgerichtet?')) return;
|
||
setAdminStatus('Speichere Nordrichtung...');
|
||
try {
|
||
var response = await fetch(adminApiBase + '/api/imu/set-north', { method: 'POST' });
|
||
var data = await response.json();
|
||
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
|
||
var imuHeading = data.imu_heading || {};
|
||
if (typeof imuHeading.offset_deg === 'number') imuRosState.headingOffsetDeg = imuHeading.offset_deg;
|
||
if (typeof imuHeading.raw_heading_deg === 'number') imuRosState.headingRawDeg = imuHeading.raw_heading_deg;
|
||
if (typeof imuHeading.corrected_heading_deg === 'number') imuRosState.headingCorrectedDeg = imuHeading.corrected_heading_deg;
|
||
renderImuRosState();
|
||
setAdminStatus(data.status || 'Nordrichtung gespeichert');
|
||
window.setTimeout(fetchStatus, 500);
|
||
} catch (error) {
|
||
setAdminStatus('Nord-Kalibrierung fehlgeschlagen');
|
||
alert(error.message || 'Nordrichtung konnte nicht gespeichert werden.');
|
||
}
|
||
}
|
||
|
||
async function setImuLevel() {
|
||
if (!window.confirm('Rover jetzt wirklich waagerecht aufgestellt?')) return;
|
||
setAdminStatus('Speichere Neigungsoffset...');
|
||
try {
|
||
var response = await fetch(adminApiBase + '/api/imu/set-level', { method: 'POST' });
|
||
var data = await response.json();
|
||
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
|
||
var imuTilt = data.imu_tilt || {};
|
||
if (typeof imuTilt.roll_offset_deg === 'number') imuRosState.rollOffsetDeg = imuTilt.roll_offset_deg;
|
||
if (typeof imuTilt.pitch_offset_deg === 'number') imuRosState.pitchOffsetDeg = imuTilt.pitch_offset_deg;
|
||
if (typeof imuTilt.raw_roll_deg === 'number') imuRosState.rollRawDeg = imuTilt.raw_roll_deg;
|
||
if (typeof imuTilt.raw_pitch_deg === 'number') imuRosState.pitchRawDeg = imuTilt.raw_pitch_deg;
|
||
renderImuRosState();
|
||
setAdminStatus(data.status || 'Neigung genullt');
|
||
window.setTimeout(fetchStatus, 500);
|
||
} catch (error) {
|
||
setAdminStatus('Neigungskalibrierung fehlgeschlagen');
|
||
alert(error.message || 'Neigung konnte nicht genullt werden.');
|
||
}
|
||
}
|
||
|
||
var rotatePoller = null;
|
||
|
||
function updateRotateUi(rotate) {
|
||
if (!rotate) return;
|
||
var running = rotate.result === 'running';
|
||
var startBtn = document.getElementById('rotate_start_btn');
|
||
var stopBtn = document.getElementById('rotate_stop_btn');
|
||
var row = document.getElementById('rotate_status_row');
|
||
var val = document.getElementById('rotate_status_val');
|
||
var led = document.getElementById('rotate_status_led');
|
||
if (startBtn) startBtn.style.display = running ? 'none' : '';
|
||
if (stopBtn) stopBtn.style.display = running ? '' : 'none';
|
||
if (row) row.style.display = rotate.result === 'idle' ? 'none' : '';
|
||
if (led) {
|
||
led.className = 'sr-led ' + (
|
||
rotate.result === 'done' ? 'led-ok' :
|
||
rotate.result === 'error' ? 'led-warn' :
|
||
rotate.result === 'aborted' ? 'led-warn' : 'led-blue'
|
||
);
|
||
}
|
||
if (val) {
|
||
var txt = rotate.message || '-';
|
||
if (running && rotate.current_deg !== null && rotate.error_deg !== null) {
|
||
txt += ' | Ist: ' + rotate.current_deg + '° | Rest: ' + rotate.error_deg + '°';
|
||
}
|
||
val.textContent = txt;
|
||
}
|
||
if (!running && rotatePoller) {
|
||
clearInterval(rotatePoller);
|
||
rotatePoller = null;
|
||
}
|
||
}
|
||
|
||
async function startRotate() {
|
||
var input = document.getElementById('rotate_target');
|
||
var target = parseFloat(input ? input.value : 0);
|
||
if (isNaN(target)) { alert('Ungültiger Winkel'); return; }
|
||
setAdminStatus('Starte Drehung zu ' + target + '°…');
|
||
try {
|
||
var response = await fetch(adminApiBase + '/api/imu/rotate-to', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({target_deg: target})
|
||
});
|
||
var data = await response.json();
|
||
if (!response.ok) throw new Error(data.status || ('status ' + response.status));
|
||
updateRotateUi(data.rotate);
|
||
setAdminStatus(data.status || 'Drehung gestartet');
|
||
if (rotatePoller) clearInterval(rotatePoller);
|
||
rotatePoller = setInterval(pollRotateStatus, 300);
|
||
} catch (e) {
|
||
setAdminStatus('Fehler: ' + (e.message || 'unbekannt'));
|
||
alert(e.message || 'Drehung konnte nicht gestartet werden.');
|
||
}
|
||
}
|
||
|
||
async function stopRotate() {
|
||
setAdminStatus('Stoppe Drehung…');
|
||
try {
|
||
var response = await fetch(adminApiBase + '/api/imu/rotate-stop', { method: 'POST' });
|
||
var data = await response.json();
|
||
updateRotateUi(data.rotate);
|
||
setAdminStatus(data.status || 'Drehung gestoppt');
|
||
} catch (e) {
|
||
setAdminStatus('Fehler beim Stoppen');
|
||
}
|
||
}
|
||
|
||
async function pollRotateStatus() {
|
||
try {
|
||
var response = await fetch(adminApiBase + '/api/imu/rotate-status');
|
||
if (!response.ok) return;
|
||
var data = await response.json();
|
||
updateRotateUi(data.rotate);
|
||
} catch (e) {}
|
||
}
|
||
|
||
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 profileSelect = document.getElementById('camera_profile_select');
|
||
var fpsSelect = document.getElementById('camera_fps_select');
|
||
if (profileSelect && selectedProfile) profileSelect.value = selectedProfile;
|
||
if (fpsSelect && Array.isArray(camera.fps_options) && camera.fps_options.length) {
|
||
fpsSelect.innerHTML = camera.fps_options.map(function(option) {
|
||
return '<option value="' + option + '">' + option + ' fps</option>';
|
||
}).join('');
|
||
}
|
||
if (fpsSelect && camera.fps) fpsSelect.value = String(camera.fps);
|
||
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 fpsSelect = document.getElementById('camera_fps_select');
|
||
var fps = fpsSelect ? Number(fpsSelect.value || 10) : 10;
|
||
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, fps: fps})
|
||
});
|
||
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';
|
||
}
|
||
}
|
||
|
||
function setCameraFps(fps) {
|
||
var profileSelect = document.getElementById('camera_profile_select');
|
||
var profileId = profileSelect ? profileSelect.value : '1296x972';
|
||
setCameraProfile(profileId);
|
||
}
|
||
|
||
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();
|
||
renderImuRosState();
|
||
updateGpsDiagnosticsPanel();
|
||
refreshCameraPreview();
|
||
window.setInterval(fetchStatus, 10000);
|
||
window.setInterval(fetchCameraConfig, 10000);
|
||
window.setInterval(fetchDelay, 5000);
|
||
window.setInterval(fetchSpeedLimit, 5000);
|
||
window.setInterval(fetchMotorTestStatus, 4000);
|
||
window.setInterval(renderImuRosState, 750);
|
||
window.setInterval(renderDriveEstimator, 250);
|
||
window.setInterval(updatePageConnectionState, 1000);
|
||
});
|
||
|
||
window.addEventListener('beforeunload', function(event) {
|
||
if (!shouldWarnBeforeLeaving()) return;
|
||
event.preventDefault();
|
||
event.returnValue = '';
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|