Created
January 8, 2026 20:07
-
-
Save HDRobotica/c73ec3fc5d7d66f19933ace71432447d to your computer and use it in GitHub Desktop.
AI Thinker ESP32-CAM Ofline Acces Point Streaming
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #include "esp_camera.h" | |
| #include <WiFi.h> | |
| #include <WebServer.h> | |
| #include "FS.h" | |
| #include "SD_MMC.h" | |
| #include <esp_wifi.h> | |
| // Access Point Konfiguration | |
| const char* apSSID = "ESP32-CAM-HD-Robotics"; | |
| const char* apPassword = "12345678"; | |
| // SD-Karten Pins | |
| #define SD_MMC_CMD 15 | |
| #define SD_MMC_CLK 14 | |
| #define SD_MMC_D0 2 | |
| #define SD_MMC_D1 4 | |
| #define SD_MMC_D2 12 | |
| #define SD_MMC_D3 13 | |
| // Kamerapins für AI Thinker ESP32-CAM | |
| #define PWDN_GPIO_NUM 32 | |
| #define RESET_GPIO_NUM -1 | |
| #define XCLK_GPIO_NUM 0 | |
| #define SIOD_GPIO_NUM 26 | |
| #define SIOC_GPIO_NUM 27 | |
| #define Y9_GPIO_NUM 35 | |
| #define Y8_GPIO_NUM 34 | |
| #define Y7_GPIO_NUM 39 | |
| #define Y6_GPIO_NUM 36 | |
| #define Y5_GPIO_NUM 21 | |
| #define Y4_GPIO_NUM 19 | |
| #define Y3_GPIO_NUM 18 | |
| #define Y2_GPIO_NUM 5 | |
| #define VSYNC_GPIO_NUM 25 | |
| #define HREF_GPIO_NUM 23 | |
| #define PCLK_GPIO_NUM 22 | |
| #define LED_GPIO_NUM 4 | |
| // VR Modus | |
| bool vrMode = false; | |
| bool vrSideBySide = false; | |
| WebServer server(80); | |
| // Globale Variablen | |
| bool sdCardAvailable = false; | |
| bool ledState = false; | |
| bool camStreaming = false; | |
| bool isRecording = false; | |
| int frameCounter = 0; | |
| unsigned long lastFrameTime = 0; | |
| unsigned long lastStatsUpdate = 0; | |
| unsigned long recordingStartTime = 0; | |
| unsigned long lastCaptureTime = 0; | |
| const int TARGET_FPS = 30; // Nur für Kamera-Einstellung | |
| const int FRAME_INTERVAL = 1000 / TARGET_FPS; | |
| const int RECORDING_FPS = 10; | |
| String currentVideoFolder = ""; | |
| camera_fb_t* lastPhoto = NULL; | |
| // Puffer für schnelleren Stream | |
| uint8_t* lastJpegBuffer = NULL; | |
| size_t lastJpegLength = 0; | |
| unsigned long lastJpegTime = 0; | |
| // Hilfsfunktionen deklarieren | |
| bool initSDCard(); | |
| bool savePhotoToSD(camera_fb_t *fb, const char* filename); | |
| int countPhotosOnSD(); | |
| int countVideoFolders(); | |
| String createVideoFolder(); | |
| void setupCamera(); | |
| void setupWiFiAP(); | |
| // VR HTML bleibt unverändert | |
| const char VR_HTML[] PROGMEM = R"rawliteral( | |
| <!DOCTYPE html> | |
| <html lang="de"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>ESP32Cam VR Modus</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| -webkit-tap-highlight-color: transparent; | |
| -webkit-user-select: none; | |
| user-select: none; | |
| } | |
| html, body { | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| background: #000; | |
| position: fixed; | |
| touch-action: none; | |
| } | |
| .vr-container { | |
| display: flex; | |
| width: 100%; | |
| height: 100%; | |
| position: relative; | |
| } | |
| .vr-eye { | |
| flex: 1; | |
| overflow: hidden; | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .vr-stream { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| transform: rotate(90deg); | |
| image-rendering: pixelated; | |
| } | |
| @media screen and (orientation: landscape) { | |
| .vr-container { | |
| flex-direction: row; | |
| } | |
| .vr-stream { | |
| transform: rotate(0deg); | |
| } | |
| } | |
| @media screen and (orientation: portrait) { | |
| .vr-container { | |
| flex-direction: column; | |
| } | |
| .vr-stream { | |
| transform: rotate(90deg); | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="vr-container"> | |
| <div class="vr-eye"> | |
| <img id="streamLeft" class="vr-stream" crossorigin="anonymous"> | |
| </div> | |
| <div class="vr-eye"> | |
| <img id="streamRight" class="vr-stream" crossorigin="anonymous"> | |
| </div> | |
| </div> | |
| <script> | |
| let streamLeft = document.getElementById('streamLeft'); | |
| let streamRight = document.getElementById('streamRight'); | |
| let isStreaming = true; | |
| let frameTimes = []; | |
| let lastFrameTime = 0; | |
| let fpsHistory = []; | |
| let avgFPS = 0; | |
| function updateVRStream() { | |
| if (!isStreaming) return; | |
| const timestamp = Date.now(); | |
| const url = '/stream?t=' + timestamp; | |
| streamLeft.src = url; | |
| streamRight.src = url; | |
| // FPS Berechnung | |
| const now = Date.now(); | |
| if (lastFrameTime > 0) { | |
| frameTimes.push(now); | |
| frameTimes = frameTimes.filter(time => now - time < 1000); | |
| const fps = frameTimes.length; | |
| fpsHistory.push(fps); | |
| if (fpsHistory.length > 5) fpsHistory.shift(); | |
| avgFPS = Math.round(fpsHistory.reduce((a, b) => a + b, 0) / fpsHistory.length); | |
| console.log("VR FPS:", avgFPS); | |
| } | |
| lastFrameTime = now; | |
| // 100ms Intervall = ~10 FPS für stabilen Stream | |
| setTimeout(updateVRStream, 100); | |
| } | |
| document.addEventListener('keydown', (e) => { | |
| switch(e.key.toLowerCase()) { | |
| case ' ': | |
| isStreaming = !isStreaming; | |
| if (isStreaming) { | |
| updateVRStream(); | |
| } else { | |
| streamLeft.src = ''; | |
| streamRight.src = ''; | |
| } | |
| break; | |
| case 'p': | |
| fetch('/capture'); | |
| break; | |
| case 'h': | |
| window.location.href = '/'; | |
| break; | |
| case 'escape': | |
| window.location.href = '/'; | |
| break; | |
| } | |
| }); | |
| let touchStartTime = 0; | |
| document.addEventListener('touchstart', (e) => { | |
| touchStartTime = Date.now(); | |
| }); | |
| document.addEventListener('touchend', (e) => { | |
| const touchDuration = Date.now() - touchStartTime; | |
| if (touchDuration > 3000) { | |
| window.location.href = '/'; | |
| } | |
| }); | |
| document.addEventListener('DOMContentLoaded', () => { | |
| updateVRStream(); | |
| if (!document.fullscreenElement) { | |
| document.documentElement.requestFullscreen().catch(err => { | |
| console.log(`Fehler beim Vollbild: ${err.message}`); | |
| }); | |
| } | |
| }); | |
| window.addEventListener('orientationchange', function() { | |
| setTimeout(() => { | |
| streamLeft.src = ''; | |
| streamRight.src = ''; | |
| setTimeout(() => { | |
| if (isStreaming) { | |
| updateVRStream(); | |
| } | |
| }, 100); | |
| }, 300); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| )rawliteral"; | |
| // HTML Oberfläche mit OPTIMIERTER FPS-Einstellung | |
| const char INDEX_HTML[] PROGMEM = R"rawliteral( | |
| <!DOCTYPE html> | |
| <html lang="de"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>ESP32Cam HD Robotics - STABILER STREAM</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: Arial, sans-serif; | |
| background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%); | |
| padding: 10px; | |
| padding-bottom: 120px; | |
| min-height: 100vh; | |
| color: #e0e0e0; | |
| } | |
| .header { | |
| text-align: center; | |
| background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%); | |
| color: white; | |
| padding: 15px; | |
| border-radius: 15px; | |
| margin-bottom: 15px; | |
| box-shadow: 0 6px 12px rgba(0,0,0,0.3); | |
| border: 1px solid #3a506b; | |
| } | |
| h1 { | |
| font-size: 1.8rem; | |
| margin-bottom: 8px; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.5); | |
| } | |
| .ip-address { | |
| font-size: 0.95rem; | |
| opacity: 0.9; | |
| color: #bdc3c7; | |
| font-family: 'Courier New', monospace; | |
| } | |
| .fps-indicator { | |
| display: inline-block; | |
| padding: 4px 12px; | |
| background: #27ae60; | |
| border-radius: 12px; | |
| font-weight: bold; | |
| margin-left: 10px; | |
| animation: pulse 2s infinite; | |
| } | |
| @keyframes pulse { | |
| 0% { opacity: 1; } | |
| 50% { opacity: 0.7; } | |
| 100% { opacity: 1; } | |
| } | |
| .video-container { | |
| background: linear-gradient(135deg, #1e1e1e 0%, #2d2d2d 100%); | |
| border-radius: 15px; | |
| overflow: hidden; | |
| margin-bottom: 20px; | |
| text-align: center; | |
| padding: 15px; | |
| box-shadow: 0 6px 12px rgba(0,0,0,0.3); | |
| border: 1px solid #3a3a3a; | |
| } | |
| #videoStream { | |
| width: 100%; | |
| max-width: 640px; | |
| border-radius: 10px; | |
| background: #000; | |
| box-shadow: 0 4px 8px rgba(0,0,0,0.5); | |
| border: 2px solid #3a506b; | |
| } | |
| .stats-container { | |
| background: linear-gradient(135deg, #1e1e1e 0%, #2d2d2d 100%); | |
| padding: 15px; | |
| border-radius: 15px; | |
| margin-bottom: 20px; | |
| box-shadow: 0 6px 12px rgba(0,0,0,0.3); | |
| border: 1px solid #3a3a3a; | |
| } | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, 1fr); | |
| gap: 12px; | |
| } | |
| .stat-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 12px; | |
| background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%); | |
| border-radius: 10px; | |
| transition: transform 0.2s; | |
| } | |
| .stat-item:hover { | |
| transform: translateY(-3px); | |
| background: linear-gradient(135deg, #34495e 0%, #2c3e50 100%); | |
| } | |
| .stat-label { | |
| font-weight: bold; | |
| color: #bdc3c7; | |
| font-size: 14px; | |
| } | |
| .stat-value { | |
| color: #1abc9c; | |
| font-weight: bold; | |
| font-size: 14px; | |
| font-family: 'Courier New', monospace; | |
| } | |
| /* Untere Menüleiste */ | |
| .bottom-menu { | |
| position: fixed; | |
| bottom: 0; | |
| left: 0; | |
| width: 100%; | |
| background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%); | |
| padding: 15px 10px; | |
| display: flex; | |
| justify-content: space-around; | |
| align-items: center; | |
| box-shadow: 0 -4px 12px rgba(0,0,0,0.3); | |
| border-top: 2px solid #1abc9c; | |
| z-index: 1000; | |
| } | |
| .menu-btn { | |
| background: none; | |
| border: none; | |
| color: white; | |
| padding: 12px 8px; | |
| border-radius: 10px; | |
| cursor: pointer; | |
| font-size: 14px; | |
| font-weight: bold; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.3s; | |
| min-width: 70px; | |
| } | |
| .menu-btn:hover { | |
| background: rgba(255, 255, 255, 0.1); | |
| transform: translateY(-3px); | |
| } | |
| .menu-btn:active { | |
| transform: translateY(0); | |
| } | |
| .menu-btn.active { | |
| background: #1abc9c; | |
| color: white; | |
| box-shadow: 0 4px 8px rgba(26, 188, 156, 0.3); | |
| } | |
| .btn-icon { | |
| font-size: 22px; | |
| margin-bottom: 5px; | |
| } | |
| .btn-text { | |
| font-size: 11px; | |
| text-align: center; | |
| } | |
| .loading { | |
| text-align: center; | |
| padding: 30px; | |
| color: #bdc3c7; | |
| font-size: 16px; | |
| } | |
| .error { | |
| color: #e74c3c; | |
| font-weight: bold; | |
| background: rgba(231, 76, 60, 0.1); | |
| padding: 10px; | |
| border-radius: 5px; | |
| margin: 10px 0; | |
| } | |
| .success { | |
| color: #2ecc71; | |
| font-weight: bold; | |
| background: rgba(46, 204, 113, 0.1); | |
| padding: 10px; | |
| border-radius: 5px; | |
| margin: 10px 0; | |
| } | |
| .message-container { | |
| position: fixed; | |
| top: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 1001; | |
| max-width: 90%; | |
| } | |
| .gallery-modal { | |
| display: none; | |
| position: fixed; | |
| z-index: 1002; | |
| left: 0; | |
| top: 0; | |
| width: 100%; | |
| height: 100%; | |
| background-color: rgba(0,0,0,0.95); | |
| padding: 20px; | |
| overflow-y: auto; | |
| } | |
| .gallery-content { | |
| background: linear-gradient(135deg, #1e1e1e 0%, #2d2d2d 100%); | |
| margin: 10% auto; | |
| padding: 25px; | |
| border-radius: 15px; | |
| width: 95%; | |
| max-width: 900px; | |
| max-height: 80vh; | |
| overflow-y: auto; | |
| border: 1px solid #3a506b; | |
| } | |
| .gallery-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); | |
| gap: 15px; | |
| margin-top: 20px; | |
| } | |
| .gallery-item { | |
| position: relative; | |
| border-radius: 10px; | |
| overflow: hidden; | |
| cursor: pointer; | |
| background: #2c3e50; | |
| height: 150px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: transform 0.3s; | |
| } | |
| .gallery-item:hover { | |
| transform: scale(1.05); | |
| box-shadow: 0 6px 12px rgba(0,0,0,0.3); | |
| } | |
| .gallery-item img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| } | |
| .delete-btn { | |
| position: absolute; | |
| top: 8px; | |
| right: 8px; | |
| background: rgba(231, 76, 60, 0.9); | |
| color: white; | |
| border: none; | |
| border-radius: 50%; | |
| width: 30px; | |
| height: 30px; | |
| cursor: pointer; | |
| font-size: 16px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .website-footer { | |
| text-align: center; | |
| color: #7f8c8d; | |
| font-size: 14px; | |
| margin-top: 20px; | |
| padding: 10px; | |
| border-top: 1px solid #3a506b; | |
| } | |
| .copyright { | |
| text-align: center; | |
| color: #1abc9c; | |
| font-size: 12px; | |
| margin-top: 10px; | |
| padding: 5px; | |
| font-family: 'Courier New', monospace; | |
| } | |
| @media (max-width: 600px) { | |
| .bottom-menu { | |
| padding: 12px 5px; | |
| } | |
| .menu-btn { | |
| padding: 10px 5px; | |
| min-width: 60px; | |
| font-size: 12px; | |
| } | |
| .btn-icon { | |
| font-size: 20px; | |
| } | |
| .btn-text { | |
| font-size: 10px; | |
| } | |
| .gallery-grid { | |
| grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); | |
| } | |
| .gallery-item { | |
| height: 120px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <h1>📹 ESP32Cam HD Robotics - STABIL</h1> | |
| <div class="ip-address">AP: %IP% | SD: %SD_STATUS% | | |
| <span id="currentFPS">0</span> FPS | |
| <span class="fps-indicator" id="fpsIndicator">⚡ STABIL</span> | |
| </div> | |
| </div> | |
| <div class="video-container"> | |
| <div id="loading" class="loading"> | |
| <p>🔄 Starte Stream... Bitte warten</p> | |
| </div> | |
| <img id="videoStream" crossorigin="anonymous"> | |
| <div id="message" style="display:none; margin-top:15px;"></div> | |
| </div> | |
| <div class="stats-container"> | |
| <div class="stats-grid"> | |
| <div class="stat-item"> | |
| <span class="stat-label">Verbindung:</span> | |
| <span class="stat-value" id="connectionType">AP</span> | |
| </div> | |
| <div class="stat-item"> | |
| <span class="stat-label">RAM frei:</span> | |
| <span class="stat-value" id="freeRam">- KB</span> | |
| </div> | |
| <div class="stat-item"> | |
| <span class="stat-label">SD Speicher:</span> | |
| <span class="stat-value" id="sdSpace">-</span> | |
| </div> | |
| <div class="stat-item"> | |
| <span class="stat-label">Fotos:</span> | |
| <span class="stat-value" id="photoCount">0</span> | |
| </div> | |
| <div class="stat-item"> | |
| <span class="stat-label">Videos:</span> | |
| <span class="stat-value" id="videoCount">0</span> | |
| </div> | |
| <div class="stat-item"> | |
| <span class="stat-label">Stream FPS:</span> | |
| <span class="stat-value" id="streamFps">0</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Untere Menüleiste --> | |
| <div class="bottom-menu"> | |
| <button class="menu-btn" onclick="toggleStream()" id="streamBtn"> | |
| <span class="btn-icon">▶️</span> | |
| <span class="btn-text">Stream</span> | |
| </button> | |
| <button class="menu-btn" onclick="toggleLED()" id="ledBtn"> | |
| <span class="btn-icon">💡</span> | |
| <span class="btn-text" id="ledText">LED AUS</span> | |
| </button> | |
| <button class="menu-btn" onclick="capturePhoto()" id="photoBtn"> | |
| <span class="btn-icon">📸</span> | |
| <span class="btn-text">Foto</span> | |
| </button> | |
| <button class="menu-btn" onclick="showGallery()" id="galleryBtn"> | |
| <span class="btn-icon">🖼️</span> | |
| <span class="btn-text">Galerie</span> | |
| </button> | |
| <button class="menu-btn" onclick="window.location.href='/vr'" id="vrBtn"> | |
| <span class="btn-icon">🥽</span> | |
| <span class="btn-text">VR Modus</span> | |
| </button> | |
| <button class="menu-btn" onclick="toggleRecording()" id="recordBtn"> | |
| <span class="btn-icon">⏺️</span> | |
| <span class="btn-text" id="recordText">Video</span> | |
| </button> | |
| <button class="menu-btn" onclick="refreshStats()" id="statsBtn"> | |
| <span class="btn-icon">📊</span> | |
| <span class="btn-text">Info</span> | |
| </button> | |
| <button class="menu-btn" onclick="formatSDCard()" id="formatBtn"> | |
| <span class="btn-icon">💾</span> | |
| <span class="btn-text">Format</span> | |
| </button> | |
| </div> | |
| <div class="website-footer"> | |
| www.hdrobotics.de | |
| </div> | |
| <!-- Copyright --> | |
| <div class="copyright"> | |
| © HD Robotics Electronics 2025 - STABILER STREAM | |
| </div> | |
| <!-- Gallery Modal --> | |
| <div id="galleryModal" class="gallery-modal"> | |
| <div class="gallery-content"> | |
| <h2 style="color: white; margin-bottom: 20px; text-align: center;">📷 Galerie</h2> | |
| <div style="margin-bottom: 20px; display: flex; gap: 10px; justify-content: center;"> | |
| <button onclick="refreshGallery()" style="padding: 10px 20px; background: #1abc9c; color: white; border: none; border-radius: 8px; cursor: pointer;"> | |
| 🔄 Aktualisieren | |
| </button> | |
| <button onclick="closeGallery()" style="padding: 10px 20px; background: #e74c3c; color: white; border: none; border-radius: 8px; cursor: pointer;"> | |
| ❌ Schließen | |
| </button> | |
| </div> | |
| <div id="galleryLoading" class="loading">Lade Bilder...</div> | |
| <div id="galleryGrid" class="gallery-grid" style="display: none;"></div> | |
| <div id="galleryError" style="display: none; text-align: center; color: #e74c3c; margin-top: 20px;"></div> | |
| <div class="copyright" style="margin-top: 20px;"> | |
| © HD Robotics Electronics 2025 | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let isStreaming = false; | |
| let isRecording = false; | |
| let ledState = false; | |
| let frameTimes = []; | |
| let lastFrameTime = 0; | |
| let photoCounter = 0; | |
| let videoCounter = 0; | |
| let recTimer = null; | |
| let fpsHistory = []; | |
| let avgFPS = 0; | |
| let streamInterval = null; | |
| const videoStream = document.getElementById('videoStream'); | |
| const loadingElement = document.getElementById('loading'); | |
| const streamBtn = document.getElementById('streamBtn'); | |
| const recordBtn = document.getElementById('recordBtn'); | |
| const ledBtn = document.getElementById('ledBtn'); | |
| const messageElement = document.getElementById('message'); | |
| const galleryModal = document.getElementById('galleryModal'); | |
| const galleryGrid = document.getElementById('galleryGrid'); | |
| const galleryLoading = document.getElementById('galleryLoading'); | |
| const galleryError = document.getElementById('galleryError'); | |
| const fpsIndicator = document.getElementById('fpsIndicator'); | |
| // STABILER STREAM FUNKTION - 15 FPS (66ms Intervall) | |
| function startMJPEGStream() { | |
| if (isStreaming) return; | |
| // Bild vorladen | |
| videoStream.src = '/stream?t=' + Date.now(); | |
| videoStream.style.display = 'block'; | |
| loadingElement.style.display = 'none'; | |
| isStreaming = true; | |
| streamBtn.innerHTML = '<span class="btn-icon">⏸️</span><span class="btn-text">Stop</span>'; | |
| // Interval für stabilen Stream (66ms = ~15 FPS) | |
| streamInterval = setInterval(function() { | |
| if (isStreaming) { | |
| videoStream.src = '/stream?t=' + Date.now(); | |
| } | |
| }, 66); // 15 FPS für stabilen Stream | |
| // FPS Berechnung | |
| const checkFPS = () => { | |
| const now = Date.now(); | |
| frameTimes = frameTimes.filter(time => now - time < 1000); | |
| const fps = frameTimes.length; | |
| // Gleitender Durchschnitt | |
| fpsHistory.push(fps); | |
| if (fpsHistory.length > 5) fpsHistory.shift(); | |
| avgFPS = Math.round(fpsHistory.reduce((a, b) => a + b, 0) / fpsHistory.length); | |
| document.getElementById('currentFPS').textContent = avgFPS; | |
| document.getElementById('streamFps').textContent = avgFPS; | |
| // FPS-Indikator Farbe ändern - REALISTISCHE WERTE | |
| if (avgFPS >= 12) { | |
| fpsIndicator.style.background = '#27ae60'; | |
| fpsIndicator.textContent = '✓ FLÜSSIG'; | |
| } else if (avgFPS >= 8) { | |
| fpsIndicator.style.background = '#f39c12'; | |
| fpsIndicator.textContent = '✓ STABIL'; | |
| } else if (avgFPS >= 5) { | |
| fpsIndicator.style.background = '#e67e22'; | |
| fpsIndicator.textContent = '⚠️ LANGAM'; | |
| } else { | |
| fpsIndicator.style.background = '#e74c3c'; | |
| fpsIndicator.textContent = '⚠️ FEHLER'; | |
| } | |
| if (isStreaming) { | |
| setTimeout(checkFPS, 1000); // Nur alle Sekunde prüfen | |
| } | |
| }; | |
| setTimeout(checkFPS, 1000); | |
| } | |
| function stopStream() { | |
| if (streamInterval) { | |
| clearInterval(streamInterval); | |
| streamInterval = null; | |
| } | |
| videoStream.src = ''; | |
| videoStream.style.display = 'none'; | |
| loadingElement.style.display = 'block'; | |
| isStreaming = false; | |
| streamBtn.innerHTML = '<span class="btn-icon">▶️</span><span class="btn-text">Start</span>'; | |
| } | |
| function toggleStream() { | |
| if (isStreaming) { | |
| stopStream(); | |
| } else { | |
| startMJPEGStream(); | |
| } | |
| } | |
| // Optimierte Frame-Logik für stabilen Stream | |
| videoStream.onload = function() { | |
| const now = Date.now(); | |
| if (lastFrameTime > 0) { | |
| frameTimes.push(now); | |
| } | |
| lastFrameTime = now; | |
| }; | |
| videoStream.onerror = function() { | |
| if (isStreaming) { | |
| console.log("Stream Fehler, versuche erneut..."); | |
| setTimeout(() => { | |
| videoStream.src = '/stream?t=' + Date.now(); | |
| }, 100); | |
| } | |
| }; | |
| // Video Recording | |
| async function toggleRecording() { | |
| try { | |
| const endpoint = isRecording ? '/stopRecording' : '/startRecording'; | |
| const response = await fetch(endpoint); | |
| if (response.ok) { | |
| isRecording = !isRecording; | |
| if (isRecording) { | |
| recordBtn.innerHTML = '<span class="btn-icon">⏹️</span><span class="btn-text">Stop</span>'; | |
| document.getElementById('recordText').textContent = 'Stop'; | |
| } else { | |
| recordBtn.innerHTML = '<span class="btn-icon">⏺️</span><span class="btn-text">Video</span>'; | |
| document.getElementById('recordText').textContent = 'Video'; | |
| refreshStats(); | |
| } | |
| } | |
| } catch (error) { | |
| console.log('Recording error:', error); | |
| } | |
| } | |
| // LED umschalten | |
| async function toggleLED() { | |
| try { | |
| const response = await fetch('/led'); | |
| const text = await response.text(); | |
| if (response.ok) { | |
| ledState = text.includes('EIN'); | |
| ledBtn.innerHTML = ledState ? | |
| '<span class="btn-icon">💡</span><span class="btn-text">EIN</span>' : | |
| '<span class="btn-icon">💡</span><span class="btn-text">AUS</span>'; | |
| document.getElementById('ledText').textContent = ledState ? 'EIN' : 'AUS'; | |
| } | |
| } catch (error) { | |
| console.log('LED error:', error); | |
| } | |
| } | |
| // Foto machen | |
| async function capturePhoto() { | |
| try { | |
| const response = await fetch('/capture'); | |
| if (response.ok) { | |
| photoCounter++; | |
| document.getElementById('photoCount').textContent = photoCounter; | |
| // Miniatur anzeigen | |
| const img = document.createElement('img'); | |
| img.src = '/lastPhoto?t=' + Date.now(); | |
| img.style.width = '100px'; | |
| img.style.height = '75px'; | |
| img.style.margin = '10px'; | |
| img.style.borderRadius = '8px'; | |
| img.style.border = '3px solid #1abc9c'; | |
| img.style.cursor = 'pointer'; | |
| img.style.boxShadow = '0 4px 8px rgba(0,0,0,0.3)'; | |
| img.onclick = function() { | |
| window.open(this.src, '_blank'); | |
| }; | |
| messageElement.innerHTML = ''; | |
| messageElement.appendChild(img); | |
| messageElement.style.display = 'block'; | |
| setTimeout(() => { | |
| messageElement.style.display = 'none'; | |
| }, 2000); | |
| // Gallery aktualisieren | |
| if (galleryModal.style.display === 'block') { | |
| refreshGallery(); | |
| } | |
| } | |
| } catch (error) { | |
| console.log('Capture error:', error); | |
| } | |
| } | |
| // Gallery Funktionen | |
| function showGallery() { | |
| galleryModal.style.display = 'block'; | |
| refreshGallery(); | |
| } | |
| function closeGallery() { | |
| galleryModal.style.display = 'none'; | |
| } | |
| async function refreshGallery() { | |
| try { | |
| galleryGrid.style.display = 'none'; | |
| galleryLoading.style.display = 'block'; | |
| galleryError.style.display = 'none'; | |
| const response = await fetch('/listFiles'); | |
| galleryGrid.innerHTML = ''; | |
| if (response.ok) { | |
| const data = await response.json(); | |
| if (data.files && data.files.length > 0) { | |
| data.files.sort((a, b) => b.name.localeCompare(a.name)); | |
| data.files.forEach(file => { | |
| const item = document.createElement('div'); | |
| item.className = 'gallery-item'; | |
| const img = document.createElement('img'); | |
| img.src = '/getFile?path=' + encodeURIComponent(file.path); | |
| img.alt = file.name; | |
| img.onclick = () => window.open(img.src, '_blank'); | |
| img.onerror = function() { | |
| this.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><rect width="100" height="100" fill="%232c3e50"/><text x="50" y="50" font-family="Arial" font-size="10" fill="white" text-anchor="middle" dy=".3em">Bild laden fehlgeschlagen</text></svg>'; | |
| }; | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.className = 'delete-btn'; | |
| deleteBtn.innerHTML = '×'; | |
| deleteBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| deleteFile(file.path); | |
| }; | |
| item.appendChild(img); | |
| item.appendChild(deleteBtn); | |
| galleryGrid.appendChild(item); | |
| }); | |
| } else { | |
| galleryGrid.innerHTML = '<p style="color: #bdc3c7; text-align: center; grid-column: 1/-1; padding: 20px;">Keine Bilder gefunden</p>'; | |
| } | |
| } else { | |
| throw new Error('Server antwortete nicht OK'); | |
| } | |
| galleryLoading.style.display = 'none'; | |
| galleryGrid.style.display = 'grid'; | |
| } catch (error) { | |
| console.error('Gallery Fehler:', error); | |
| galleryLoading.style.display = 'none'; | |
| galleryError.style.display = 'block'; | |
| galleryError.innerHTML = '<div class="error">❌ Fehler beim Laden der Galerie. Bitte aktualisieren.</div>'; | |
| } | |
| } | |
| async function deleteFile(path) { | |
| if (!confirm('Datei wirklich löschen?')) return; | |
| try { | |
| const response = await fetch('/deleteFile?path=' + encodeURIComponent(path)); | |
| if (response.ok) { | |
| refreshGallery(); | |
| refreshStats(); | |
| } | |
| } catch (error) { | |
| console.log('Delete error:', error); | |
| } | |
| } | |
| // SD-Karte formatieren | |
| async function formatSDCard() { | |
| if (!confirm('⚠️ WARNUNG: SD-Karte wird formatiert!\n\nAlle Daten gehen verloren!\nWirklich fortfahren?')) { | |
| return; | |
| } | |
| try { | |
| const response = await fetch('/formatSD'); | |
| if (response.ok) { | |
| refreshStats(); | |
| } | |
| } catch (error) { | |
| console.log('Format error:', error); | |
| } | |
| } | |
| // Stats aktualisieren | |
| async function refreshStats() { | |
| try { | |
| const response = await fetch('/stats'); | |
| const data = await response.json(); | |
| document.getElementById('freeRam').textContent = Math.round(data.freeRam / 1024) + ' KB'; | |
| document.getElementById('sdSpace').textContent = data.freeSpace + '/' + data.totalSpace + ' MB'; | |
| document.getElementById('photoCount').textContent = data.photoCount; | |
| document.getElementById('videoCount').textContent = data.videoCount || 0; | |
| photoCounter = data.photoCount || 0; | |
| videoCounter = data.videoCount || 0; | |
| // SD Status im Header aktualisieren | |
| document.getElementById('sdStatus').textContent = data.sdCard ? '✔' : '✘'; | |
| } catch (error) { | |
| console.log('Stats error:', error); | |
| } | |
| } | |
| // Initialisierung | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Auto-Start Stream nach 2 Sekunden | |
| setTimeout(startMJPEGStream, 2000); | |
| // Stats alle 5 Sekunden aktualisieren | |
| setInterval(refreshStats, 5000); | |
| refreshStats(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| )rawliteral"; | |
| // SD-Karte initialisieren | |
| bool initSDCard() { | |
| Serial.println("📁 Initialisiere SD-Karte..."); | |
| if(SD_MMC.cardType() != CARD_NONE) { | |
| SD_MMC.end(); | |
| delay(100); | |
| } | |
| SD_MMC.setPins(SD_MMC_CLK, SD_MMC_CMD, SD_MMC_D0, SD_MMC_D1, SD_MMC_D2, SD_MMC_D3); | |
| if(!SD_MMC.begin("/sdcard", true, false, 4000000)) { // 4MHz für Stabilität | |
| Serial.println("❌ SD-Karte Montage fehlgeschlagen!"); | |
| return false; | |
| } | |
| delay(50); | |
| uint8_t cardType = SD_MMC.cardType(); | |
| if(cardType == CARD_NONE) { | |
| Serial.println("❌ Keine SD-Karte gefunden!"); | |
| return false; | |
| } | |
| Serial.print("✅ SD-Karten Typ: "); | |
| if(cardType == CARD_MMC) Serial.println("MMC"); | |
| else if(cardType == CARD_SD) Serial.println("SDSC"); | |
| else if(cardType == CARD_SDHC) Serial.println("SDHC"); | |
| else Serial.println("Unbekannt"); | |
| uint64_t cardSize = SD_MMC.cardSize() / (1024 * 1024); | |
| Serial.printf("📊 SD-Kartengröße: %lluMB\n", cardSize); | |
| return true; | |
| } | |
| // Hilfsfunktion um Dateien rekursiv zu finden | |
| void findJPGFiles(File dir, std::vector<String> &files) { | |
| while(File entry = dir.openNextFile()) { | |
| if (!entry) break; | |
| String fileName = entry.name(); | |
| if (fileName.startsWith(".") || fileName.equals("System Volume Information")) { | |
| entry.close(); | |
| continue; | |
| } | |
| if (entry.isDirectory()) { | |
| findJPGFiles(entry, files); | |
| } else if (fileName.endsWith(".jpg") || fileName.endsWith(".JPG") || | |
| fileName.endsWith(".jpeg") || fileName.endsWith(".JPEG")) { | |
| if (!fileName.startsWith("frame_") && !fileName.startsWith("/video_")) { | |
| files.push_back(fileName); | |
| } | |
| } | |
| entry.close(); | |
| } | |
| } | |
| // Zähle Fotos auf SD-Karte | |
| int countPhotosOnSD() { | |
| if (!sdCardAvailable) return 0; | |
| File root = SD_MMC.open("/"); | |
| if (!root) return 0; | |
| std::vector<String> files; | |
| findJPGFiles(root, files); | |
| root.close(); | |
| return files.size(); | |
| } | |
| // Zähle Video-Ordner | |
| int countVideoFolders() { | |
| if (!sdCardAvailable) return 0; | |
| File root = SD_MMC.open("/"); | |
| if (!root) return 0; | |
| int count = 0; | |
| File file = root.openNextFile(); | |
| while (file) { | |
| if (file.isDirectory()) { | |
| String folderName = String(file.name()); | |
| if (folderName.startsWith("video_")) { | |
| count++; | |
| } | |
| } | |
| file = root.openNextFile(); | |
| } | |
| root.close(); | |
| return count; | |
| } | |
| // Foto auf SD-Karte speichern | |
| bool savePhotoToSD(camera_fb_t *fb, const char* filename) { | |
| if (!sdCardAvailable || !fb) return false; | |
| File file = SD_MMC.open(filename, FILE_WRITE); | |
| if (!file) { | |
| return false; | |
| } | |
| size_t written = file.write(fb->buf, fb->len); | |
| file.flush(); | |
| file.close(); | |
| return (written == fb->len); | |
| } | |
| // Erstelle Video-Ordner | |
| String createVideoFolder() { | |
| if (!sdCardAvailable) return ""; | |
| char folderName[32]; | |
| sprintf(folderName, "/video_%lu", millis()); | |
| if (SD_MMC.mkdir(folderName)) { | |
| return String(folderName); | |
| } else { | |
| return ""; | |
| } | |
| } | |
| // KAMERA EINSTELLUNG FÜR STABILEN STREAM | |
| void setupCamera() { | |
| camera_config_t config; | |
| config.ledc_channel = LEDC_CHANNEL_0; | |
| config.ledc_timer = LEDC_TIMER_0; | |
| config.pin_d0 = Y2_GPIO_NUM; | |
| config.pin_d1 = Y3_GPIO_NUM; | |
| config.pin_d2 = Y4_GPIO_NUM; | |
| config.pin_d3 = Y5_GPIO_NUM; | |
| config.pin_d4 = Y6_GPIO_NUM; | |
| config.pin_d5 = Y7_GPIO_NUM; | |
| config.pin_d6 = Y8_GPIO_NUM; | |
| config.pin_d7 = Y9_GPIO_NUM; | |
| config.pin_xclk = XCLK_GPIO_NUM; | |
| config.pin_pclk = PCLK_GPIO_NUM; | |
| config.pin_vsync = VSYNC_GPIO_NUM; | |
| config.pin_href = HREF_GPIO_NUM; | |
| config.pin_sccb_sda = SIOD_GPIO_NUM; | |
| config.pin_sccb_scl = SIOC_GPIO_NUM; | |
| config.pin_pwdn = PWDN_GPIO_NUM; | |
| config.pin_reset = RESET_GPIO_NUM; | |
| // BALANCIERTE EINSTELLUNG für Stabilität | |
| config.xclk_freq_hz = 10000000; // 10 MHz für Stabilität | |
| config.pixel_format = PIXFORMAT_JPEG; | |
| // Auflösung für stabilen Stream | |
| config.frame_size = FRAMESIZE_QVGA; // 320x240 | |
| // Qualität für gute Performance | |
| config.jpeg_quality = 12; // Mittlere Qualität | |
| // 2 Puffer für bessere Performance | |
| config.fb_count = 2; | |
| // LATEST MODUS für minimale Verzögerung | |
| config.grab_mode = CAMERA_GRAB_LATEST; | |
| // PSRAM verwenden falls verfügbar | |
| config.fb_location = CAMERA_FB_IN_PSRAM; | |
| esp_err_t err = esp_camera_init(&config); | |
| if (err != ESP_OK) { | |
| Serial.printf("Kamera-Init fehlgeschlagen: 0x%x\n", err); | |
| // Fallback auf niedrigere Einstellungen | |
| config.xclk_freq_hz = 5000000; | |
| config.frame_size = FRAMESIZE_QQVGA; | |
| config.jpeg_quality = 15; | |
| config.fb_count = 1; | |
| err = esp_camera_init(&config); | |
| if (err != ESP_OK) { | |
| Serial.printf("Fallback Kamera-Init fehlgeschlagen: 0x%x\n", err); | |
| return; | |
| } | |
| } | |
| // Kamera-Einstellungen optimieren für OV2640 | |
| sensor_t *s = esp_camera_sensor_get(); | |
| if (s->id.PID == OV2640_PID) { | |
| // Stabile Einstellungen für Video-Streaming | |
| s->set_vflip(s, 1); | |
| s->set_hmirror(s, 1); | |
| s->set_brightness(s, 0); | |
| s->set_contrast(s, 0); | |
| s->set_saturation(s, 0); | |
| s->set_special_effect(s, 0); | |
| s->set_whitebal(s, 1); // Auto White Balance | |
| s->set_gainceiling(s, GAINCEILING_8X); | |
| s->set_colorbar(s, 0); | |
| s->set_awb_gain(s, 1); | |
| s->set_wb_mode(s, 0); | |
| s->set_exposure_ctrl(s, 1); // Auto Exposure | |
| s->set_aec2(s, 0); | |
| s->set_ae_level(s, 0); | |
| // Moderate Exposure für Stabilität | |
| s->set_aec_value(s, 600); | |
| // Qualitätseinstellungen für Streaming | |
| s->set_raw_gma(s, 1); | |
| s->set_lenc(s, 1); | |
| s->set_denoise(s, 1); | |
| // Stabile Auflösung | |
| s->set_framesize(s, FRAMESIZE_QVGA); // 320x240 für stabilen Stream | |
| } | |
| Serial.println("✅ Kamera für stabilen Stream initialisiert"); | |
| } | |
| // Access Point einrichten | |
| void setupWiFiAP() { | |
| Serial.println("📶 Starte ESP32-CAM Access Point..."); | |
| WiFi.disconnect(true); | |
| delay(100); | |
| WiFi.mode(WIFI_AP); | |
| // WiFi Performance optimieren | |
| esp_wifi_set_ps(WIFI_PS_NONE); // Kein Power Save für bessere Performance | |
| WiFi.setSleep(false); | |
| // Access Point mit optimierten Einstellungen | |
| if (WiFi.softAP(apSSID, apPassword, 1, 0, 4)) { | |
| delay(100); | |
| // AP-Konfiguration für bessere Performance | |
| IPAddress apIP(192, 168, 4, 1); | |
| IPAddress subnet(255, 255, 255, 0); | |
| WiFi.softAPConfig(apIP, apIP, subnet); | |
| Serial.println("✅ Access Point gestartet!"); | |
| Serial.print("📡 AP SSID: "); | |
| Serial.println(apSSID); | |
| Serial.print("🔑 Passwort: "); | |
| Serial.println(apPassword); | |
| Serial.print("🌐 IP-Adresse: "); | |
| Serial.println(WiFi.softAPIP()); | |
| } else { | |
| Serial.println("❌ Access Point konnte nicht gestartet werden!"); | |
| } | |
| } | |
| // OPTIMIERTER STREAM HANDLER für Stabilität | |
| void handleStream() { | |
| unsigned long startTime = millis(); | |
| // Frame aus Kamera holen | |
| camera_fb_t *fb = esp_camera_fb_get(); | |
| if (!fb) { | |
| server.send(500, "text/plain", "Kamera-Fehler"); | |
| return; | |
| } | |
| // HTTP-Header für schnelle Übertragung | |
| server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate"); | |
| server.sendHeader("Pragma", "no-cache"); | |
| server.sendHeader("Expires", "-1"); | |
| server.sendHeader("Access-Control-Allow-Origin", "*"); | |
| server.sendHeader("Connection", "close"); | |
| // Direkte Übertragung | |
| WiFiClient client = server.client(); | |
| server.setContentLength(fb->len); | |
| server.send(200, "image/jpeg", ""); | |
| client.write(fb->buf, fb->len); | |
| client.flush(); | |
| // Frame freigeben | |
| esp_camera_fb_return(fb); | |
| frameCounter++; | |
| // Recording-Logik | |
| if (isRecording && sdCardAvailable && (millis() - lastCaptureTime >= (1000 / RECORDING_FPS))) { | |
| camera_fb_t *recording_fb = esp_camera_fb_get(); | |
| if (recording_fb) { | |
| char filename[64]; | |
| sprintf(filename, "%s/frame_%06d.jpg", currentVideoFolder.c_str(), | |
| (int)((millis() - recordingStartTime) / (1000 / RECORDING_FPS))); | |
| savePhotoToSD(recording_fb, filename); | |
| esp_camera_fb_return(recording_fb); | |
| } | |
| lastCaptureTime = millis(); | |
| } | |
| // Performance-Logging | |
| unsigned long endTime = millis(); | |
| unsigned long processTime = endTime - startTime; | |
| static int slowFrameCount = 0; | |
| if (processTime > 66) { // 66ms = 15 FPS | |
| slowFrameCount++; | |
| if (slowFrameCount % 20 == 0) { | |
| Serial.printf("⚠️ Langsame Frame-Verarbeitung: %lums\n", processTime); | |
| } | |
| } | |
| } | |
| // VR HTML Seite | |
| void handleVR() { | |
| vrMode = true; | |
| String html = FPSTR(VR_HTML); | |
| server.send(200, "text/html", html); | |
| } | |
| // Foto aufnehmen | |
| void handleCapture() { | |
| camera_fb_t *fb = esp_camera_fb_get(); | |
| if (!fb) { | |
| server.send(500, "text/plain", "Kamera-Fehler"); | |
| return; | |
| } | |
| // Altes Foto freigeben | |
| if (lastPhoto != NULL) { | |
| esp_camera_fb_return(lastPhoto); | |
| } | |
| lastPhoto = fb; | |
| // Auf SD speichern | |
| char filename[32]; | |
| sprintf(filename, "/photo_%lu.jpg", millis()); | |
| bool saved = savePhotoToSD(fb, filename); | |
| // Sofortige Antwort senden | |
| server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate"); | |
| server.sendHeader("Pragma", "no-cache"); | |
| server.sendHeader("Expires", "-1"); | |
| server.sendHeader("Access-Control-Allow-Origin", "*"); | |
| server.sendHeader("Connection", "close"); | |
| WiFiClient client = server.client(); | |
| server.setContentLength(fb->len); | |
| server.send(200, "image/jpeg", ""); | |
| client.write(fb->buf, fb->len); | |
| } | |
| // Letztes Foto zurückgeben | |
| void handleLastPhoto() { | |
| if (lastPhoto == NULL) { | |
| server.send(404, "text/plain", "Kein Foto vorhanden"); | |
| return; | |
| } | |
| server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate"); | |
| server.sendHeader("Pragma", "no-cache"); | |
| server.sendHeader("Expires", "-1"); | |
| server.sendHeader("Access-Control-Allow-Origin", "*"); | |
| WiFiClient client = server.client(); | |
| server.setContentLength(lastPhoto->len); | |
| server.send(200, "image/jpeg", ""); | |
| client.write(lastPhoto->buf, lastPhoto->len); | |
| } | |
| // Video-Aufnahme starten | |
| void handleStartRecording() { | |
| if (isRecording) { | |
| server.send(200, "text/plain", "Aufnahme läuft bereits"); | |
| return; | |
| } | |
| if (!sdCardAvailable) { | |
| server.send(500, "text/plain", "SD-Karte nicht verfügbar"); | |
| return; | |
| } | |
| currentVideoFolder = createVideoFolder(); | |
| if (currentVideoFolder == "") { | |
| server.send(500, "text/plain", "Fehler beim Erstellen des Video-Ordners"); | |
| return; | |
| } | |
| isRecording = true; | |
| recordingStartTime = millis(); | |
| lastCaptureTime = 0; | |
| server.send(200, "text/plain", "Videoaufnahme gestartet"); | |
| } | |
| // Video-Aufnahme stoppen | |
| void handleStopRecording() { | |
| if (!isRecording) { | |
| server.send(200, "text/plain", "Keine Aufnahme aktiv"); | |
| return; | |
| } | |
| isRecording = false; | |
| currentVideoFolder = ""; | |
| server.send(200, "text/plain", "Videoaufnahme gestoppt"); | |
| } | |
| // Dateien auflisten | |
| void handleListFiles() { | |
| if (!sdCardAvailable) { | |
| server.send(500, "application/json", "{\"error\":\"SD-Karte nicht verfügbar\"}"); | |
| return; | |
| } | |
| String json = "{\"files\":["; | |
| bool firstFile = true; | |
| int fileCount = 0; | |
| std::vector<String> allFiles; | |
| File root = SD_MMC.open("/"); | |
| if (root) { | |
| findJPGFiles(root, allFiles); | |
| root.close(); | |
| std::sort(allFiles.begin(), allFiles.end(), [](const String &a, const String &b) { | |
| return a > b; | |
| }); | |
| int limit = min(50, (int)allFiles.size()); | |
| for (int i = 0; i < limit; i++) { | |
| String fileName = allFiles[i]; | |
| if (fileName.length() > 0) { | |
| if (!firstFile) json += ","; | |
| json += "{\"name\":\"" + fileName.substring(fileName.lastIndexOf('/') + 1) + "\","; | |
| json += "\"path\":\"" + fileName + "\","; | |
| File file = SD_MMC.open(fileName); | |
| size_t fileSize = 0; | |
| if (file) { | |
| fileSize = file.size(); | |
| file.close(); | |
| } | |
| json += "\"size\":" + String(fileSize) + "}"; | |
| firstFile = false; | |
| fileCount++; | |
| } | |
| } | |
| } | |
| json += "]}"; | |
| server.sendHeader("Access-Control-Allow-Origin", "*"); | |
| server.send(200, "application/json", json); | |
| } | |
| // Datei abrufen | |
| void handleGetFile() { | |
| if (!sdCardAvailable) { | |
| server.send(500, "text/plain", "SD-Karte nicht verfügbar"); | |
| return; | |
| } | |
| String path = server.arg("path"); | |
| if (path == "") { | |
| server.send(400, "text/plain", "Pfadparameter fehlt"); | |
| return; | |
| } | |
| File file = SD_MMC.open(path); | |
| if (!file || file.isDirectory()) { | |
| server.send(404, "text/plain", "Datei nicht gefunden"); | |
| if (file) file.close(); | |
| return; | |
| } | |
| size_t fileSize = file.size(); | |
| if (fileSize == 0) { | |
| file.close(); | |
| server.send(404, "text/plain", "Datei ist leer"); | |
| return; | |
| } | |
| server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate"); | |
| server.sendHeader("Pragma", "no-cache"); | |
| server.sendHeader("Expires", "-1"); | |
| server.sendHeader("Access-Control-Allow-Origin", "*"); | |
| server.sendHeader("Content-Length", String(fileSize)); | |
| server.sendHeader("Content-Type", "image/jpeg"); | |
| WiFiClient client = server.client(); | |
| const size_t bufferSize = 4096; | |
| uint8_t buffer[bufferSize]; | |
| size_t bytesRead; | |
| while ((bytesRead = file.read(buffer, bufferSize)) > 0) { | |
| client.write(buffer, bytesRead); | |
| } | |
| file.close(); | |
| } | |
| // Datei löschen | |
| void handleDeleteFile() { | |
| if (!sdCardAvailable) { | |
| server.send(500, "text/plain", "SD-Karte nicht verfügbar"); | |
| return; | |
| } | |
| String path = server.arg("path"); | |
| if (path == "") { | |
| server.send(400, "text/plain", "Pfadparameter fehlt"); | |
| return; | |
| } | |
| if (SD_MMC.remove(path)) { | |
| server.send(200, "text/plain", "Datei gelöscht"); | |
| } else { | |
| server.send(500, "text/plain", "Fehler beim Löschen"); | |
| } | |
| } | |
| // SD-Karte formatieren | |
| void handleFormatSD() { | |
| if (!sdCardAvailable) { | |
| server.send(500, "text/plain", "SD-Karte nicht verfügbar"); | |
| return; | |
| } | |
| File root = SD_MMC.open("/"); | |
| if (root) { | |
| File file = root.openNextFile(); | |
| while (file) { | |
| String path = String(file.name()); | |
| if (file.isDirectory()) { | |
| if (!path.equals("/System Volume Information") && !path.startsWith("/.")) { | |
| SD_MMC.rmdir(path); | |
| } | |
| } else { | |
| SD_MMC.remove(path); | |
| } | |
| file = root.openNextFile(); | |
| } | |
| root.close(); | |
| } | |
| File testFile = SD_MMC.open("/test_format.txt", FILE_WRITE); | |
| if (testFile) { | |
| testFile.println("Formatierung erfolgreich - HD Robotics 2025"); | |
| testFile.close(); | |
| SD_MMC.remove("/test_format.txt"); | |
| server.send(200, "text/plain", "SD-Karte erfolgreich formatiert!"); | |
| } else { | |
| server.send(500, "text/plain", "Fehler beim Formatieren!"); | |
| } | |
| } | |
| // LED Handler | |
| void handleLED() { | |
| ledState = !ledState; | |
| pinMode(LED_GPIO_NUM, OUTPUT); | |
| digitalWrite(LED_GPIO_NUM, ledState ? HIGH : LOW); | |
| String message = ledState ? "LED EIN" : "LED AUS"; | |
| server.send(200, "text/plain", message); | |
| } | |
| // System Stats Handler | |
| void handleStats() { | |
| uint64_t totalBytes = 0; | |
| uint64_t usedBytes = 0; | |
| uint64_t freeBytes = 0; | |
| if (sdCardAvailable) { | |
| totalBytes = SD_MMC.totalBytes() / (1024 * 1024); | |
| usedBytes = SD_MMC.usedBytes() / (1024 * 1024); | |
| freeBytes = totalBytes - usedBytes; | |
| } | |
| String json = "{"; | |
| json += "\"rssi\":" + String(WiFi.RSSI()) + ","; | |
| json += "\"freeRam\":" + String(ESP.getFreeHeap()) + ","; | |
| json += "\"sdCard\":" + String(sdCardAvailable ? "true" : "false") + ","; | |
| json += "\"photoCount\":" + String(countPhotosOnSD()) + ","; | |
| json += "\"videoCount\":" + String(countVideoFolders()) + ","; | |
| json += "\"isRecording\":" + String(isRecording ? "true" : "false") + ","; | |
| json += "\"totalSpace\":" + String(totalBytes) + ","; | |
| json += "\"usedSpace\":" + String(usedBytes) + ","; | |
| json += "\"freeSpace\":" + String(freeBytes) + ","; | |
| json += "\"uptime\":" + String(millis() / 1000); | |
| json += "}"; | |
| server.sendHeader("Access-Control-Allow-Origin", "*"); | |
| server.send(200, "application/json", json); | |
| } | |
| // Root Handler | |
| void handleRoot() { | |
| String html = FPSTR(INDEX_HTML); | |
| html.replace("%IP%", WiFi.softAPIP().toString()); | |
| html.replace("%SD_STATUS%", sdCardAvailable ? "✔" : "✘"); | |
| server.send(200, "text/html", html); | |
| } | |
| void setup() { | |
| Serial.begin(115200); | |
| delay(100); | |
| Serial.println("\n\n"); | |
| Serial.println("╔══════════════════════════════════════════╗"); | |
| Serial.println("║ ESP32-CAM HD Robotics - v4.1 ║"); | |
| Serial.println("║ STABILER STREAM OPTIMIERT ║"); | |
| Serial.println("║ ACCESS POINT MODUS ║"); | |
| Serial.println("║ © HD Robotics Electronics 2025 ║"); | |
| Serial.println("╚══════════════════════════════════════════╝"); | |
| Serial.println(); | |
| pinMode(LED_GPIO_NUM, OUTPUT); | |
| digitalWrite(LED_GPIO_NUM, LOW); | |
| Serial.println("🔧 Initialisiere System für stabilen Stream..."); | |
| delay(500); | |
| sdCardAvailable = initSDCard(); | |
| setupCamera(); | |
| setupWiFiAP(); | |
| // WebServer Route Handler | |
| server.on("/", handleRoot); | |
| server.on("/stream", handleStream); | |
| server.on("/vr", handleVR); | |
| server.on("/capture", handleCapture); | |
| server.on("/lastPhoto", handleLastPhoto); | |
| server.on("/led", handleLED); | |
| server.on("/stats", handleStats); | |
| server.on("/startRecording", handleStartRecording); | |
| server.on("/stopRecording", handleStopRecording); | |
| server.on("/listFiles", handleListFiles); | |
| server.on("/getFile", handleGetFile); | |
| server.on("/deleteFile", handleDeleteFile); | |
| server.on("/formatSD", handleFormatSD); | |
| server.onNotFound([]() { | |
| server.send(404, "text/plain", "404: Nicht gefunden - HD Robotics 2025"); | |
| }); | |
| // Server starten | |
| server.begin(); | |
| Serial.println("✅ WebServer gestartet"); | |
| Serial.print("🌐 Öffne: http://"); | |
| Serial.println(WiFi.softAPIP()); | |
| Serial.println("🥽 VR Modus: http://" + WiFi.softAPIP().toString() + "/vr"); | |
| Serial.println("📡 Verbinde dein Gerät mit WiFi: " + String(apSSID)); | |
| Serial.println("🔑 Passwort: " + String(apPassword)); | |
| // LED-Blinken zur Bestätigung | |
| for (int i = 0; i < 3; i++) { | |
| digitalWrite(LED_GPIO_NUM, HIGH); | |
| delay(200); | |
| digitalWrite(LED_GPIO_NUM, LOW); | |
| delay(200); | |
| } | |
| if (sdCardAvailable) { | |
| Serial.println("✅ SD-Karte bereit für Fotos und Videos"); | |
| } | |
| Serial.println("\n✅ System bereit für stabilen Stream!"); | |
| Serial.println("=========================================="); | |
| } | |
| void loop() { | |
| server.handleClient(); | |
| unsigned long now = millis(); | |
| // FPS-Logging alle 10 Sekunden | |
| static unsigned long lastFPSTime = 0; | |
| if (now - lastFPSTime > 10000) { | |
| float fps = frameCounter / 10.0; | |
| Serial.printf("📊 Durchschnittliche FPS: %.1f | RAM frei: %d KB\n", | |
| fps, ESP.getFreeHeap() / 1024); | |
| frameCounter = 0; | |
| lastFPSTime = now; | |
| } | |
| // System-Überwachung | |
| if (now - lastStatsUpdate > 30000) { | |
| if (sdCardAvailable && now % 30000 == 0) { | |
| uint8_t cardType = SD_MMC.cardType(); | |
| if (cardType == CARD_NONE) { | |
| sdCardAvailable = false; | |
| Serial.println("⚠️ SD-Karte verloren!"); | |
| } | |
| } | |
| lastStatsUpdate = now; | |
| } | |
| delay(1); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment