519 lines
19 KiB
Python
519 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Claude's Eyes - Mock ESP32 Server
|
|
|
|
Simuliert den ESP32-Roboter für Tests ohne echte Hardware.
|
|
|
|
Features:
|
|
- Liefert Testbilder aus ./test_images/
|
|
- ODER nutzt eine echte USB-Webcam (use_real_webcam: true in config)
|
|
- Simuliert Fahrbefehle (loggt sie)
|
|
- Liefert Fake-Sensordaten
|
|
|
|
Usage:
|
|
1. Leg JPG-Bilder in ./test_images/ (z.B. Fotos aus deiner Wohnung)
|
|
ODER aktiviere use_real_webcam in config.yaml
|
|
2. python mock_esp32.py
|
|
3. In config.yaml: host: "localhost", port: 5000
|
|
4. Starte die Bridge - Claude "fährt" durch deine Testbilder!
|
|
"""
|
|
|
|
import os
|
|
import random
|
|
import logging
|
|
import base64
|
|
import yaml
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
from flask import Flask, jsonify, send_file, request, Response
|
|
|
|
# OpenCV für Webcam (optional)
|
|
try:
|
|
import cv2
|
|
OPENCV_AVAILABLE = True
|
|
except ImportError:
|
|
OPENCV_AVAILABLE = False
|
|
|
|
# Logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = Flask(__name__)
|
|
|
|
# Konfiguration
|
|
SCRIPT_DIR = Path(__file__).parent
|
|
IMAGES_DIR = SCRIPT_DIR / "test_images"
|
|
FOTO_PATH = SCRIPT_DIR / "foto.jpg" # Hier wird das aktuelle Foto gespeichert
|
|
API_KEY = "claudes_eyes_secret_2025"
|
|
|
|
# Mock-Konfiguration (wird aus config.yaml geladen)
|
|
mock_config = {
|
|
"use_real_webcam": False,
|
|
"webcam_device": 0,
|
|
"webcam_width": 640,
|
|
"webcam_height": 480,
|
|
}
|
|
|
|
# Webcam-Objekt (wird bei Bedarf initialisiert)
|
|
webcam = None
|
|
|
|
# State
|
|
current_image_index = 0
|
|
position = {"x": 0, "y": 0, "rotation": 0}
|
|
camera_angle = {"pan": 90, "tilt": 90}
|
|
|
|
|
|
def load_mock_config():
|
|
"""Lädt die Mock-Konfiguration aus config.yaml"""
|
|
global mock_config
|
|
|
|
# Versuche config.local.yaml zuerst, dann config.yaml
|
|
for config_name in ["config.local.yaml", "config.yaml"]:
|
|
config_path = SCRIPT_DIR / config_name
|
|
if config_path.exists():
|
|
try:
|
|
with open(config_path, 'r', encoding='utf-8') as f:
|
|
config = yaml.safe_load(f)
|
|
if config and "mock" in config:
|
|
mock_config.update(config["mock"])
|
|
logger.info(f"Mock-Config geladen aus {config_name}")
|
|
return
|
|
except Exception as e:
|
|
logger.warning(f"Fehler beim Laden von {config_name}: {e}")
|
|
|
|
logger.info("Keine Mock-Config gefunden, nutze Defaults")
|
|
|
|
|
|
def init_webcam():
|
|
"""Initialisiert die Webcam"""
|
|
global webcam
|
|
|
|
if not OPENCV_AVAILABLE:
|
|
logger.error("OpenCV nicht installiert! Installiere mit: pip install opencv-python")
|
|
return False
|
|
|
|
if webcam is not None:
|
|
return True
|
|
|
|
device = mock_config.get("webcam_device", 0)
|
|
width = mock_config.get("webcam_width", 640)
|
|
height = mock_config.get("webcam_height", 480)
|
|
|
|
try:
|
|
webcam = cv2.VideoCapture(device)
|
|
if not webcam.isOpened():
|
|
logger.error(f"Konnte Webcam {device} nicht öffnen!")
|
|
webcam = None
|
|
return False
|
|
|
|
# Auflösung setzen
|
|
webcam.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
|
webcam.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
|
|
|
actual_w = int(webcam.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
actual_h = int(webcam.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
logger.info(f"Webcam {device} initialisiert: {actual_w}x{actual_h}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Webcam-Fehler: {e}")
|
|
webcam = None
|
|
return False
|
|
|
|
|
|
def capture_from_webcam() -> bool:
|
|
"""
|
|
Nimmt ein Bild von der Webcam auf und speichert es als foto.jpg
|
|
|
|
Returns:
|
|
True wenn erfolgreich, False bei Fehler
|
|
"""
|
|
global webcam
|
|
|
|
if not init_webcam():
|
|
return False
|
|
|
|
try:
|
|
# Bild aufnehmen
|
|
ret, frame = webcam.read()
|
|
if not ret or frame is None:
|
|
logger.error("Konnte kein Bild von Webcam lesen!")
|
|
return False
|
|
|
|
# Als JPEG speichern
|
|
cv2.imwrite(str(FOTO_PATH), frame)
|
|
logger.info(f"📷 Webcam-Bild aufgenommen: {FOTO_PATH.name}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Webcam-Capture-Fehler: {e}")
|
|
return False
|
|
|
|
|
|
def release_webcam():
|
|
"""Gibt die Webcam frei"""
|
|
global webcam
|
|
if webcam is not None:
|
|
webcam.release()
|
|
webcam = None
|
|
logger.info("Webcam freigegeben")
|
|
|
|
|
|
def check_api_key():
|
|
"""Prüft den API-Key"""
|
|
key = request.args.get("key", "")
|
|
if key != API_KEY:
|
|
return False
|
|
return True
|
|
|
|
|
|
@app.route("/")
|
|
def index():
|
|
"""Startseite"""
|
|
return """
|
|
<html>
|
|
<head><title>Mock ESP32 - Claude's Eyes</title></head>
|
|
<body style="font-family: monospace; padding: 20px;">
|
|
<h1>🤖 Mock ESP32 Server</h1>
|
|
<p>Simuliert den Claude's Eyes Roboter für Tests.</p>
|
|
|
|
<h2>API Endpoints:</h2>
|
|
<ul>
|
|
<li><a href="/api/capture?key={key}">/api/capture</a> - Foto aufnehmen (liefert JPEG direkt!)</li>
|
|
<li><a href="/api/status?key={key}">/api/status</a> - Sensor-Status</li>
|
|
<li>/api/command (POST) - Fahrbefehle</li>
|
|
</ul>
|
|
|
|
<h2>Für die Python Bridge:</h2>
|
|
<p>Die Bridge holt das Bild von <code>/api/capture</code> und lädt es per Selenium in Claude.ai hoch!</p>
|
|
<p>So kann Claude im Chat die Bilder direkt sehen.</p>
|
|
|
|
<h2>Status:</h2>
|
|
<ul>
|
|
<li>Bilder-Ordner: {images_dir}</li>
|
|
<li>Gefundene Bilder: {image_count}</li>
|
|
<li>Aktuelles Bild: #{current_index}</li>
|
|
</ul>
|
|
|
|
<p><small>API-Key: {key}</small></p>
|
|
</body>
|
|
</html>
|
|
""".format(
|
|
key=API_KEY,
|
|
images_dir=IMAGES_DIR,
|
|
image_count=len(list(IMAGES_DIR.glob("*.jpg"))) if IMAGES_DIR.exists() else 0,
|
|
current_index=current_image_index
|
|
)
|
|
|
|
|
|
@app.route("/api/capture", methods=["GET"])
|
|
def capture():
|
|
"""
|
|
Macht ein "Foto" und liefert es DIREKT als JPEG zurück.
|
|
|
|
Das ist wie beim echten ESP32 - Bild wird direkt gestreamt.
|
|
Kein JSON, sondern das Bild selbst!
|
|
|
|
Je nach Konfiguration:
|
|
- use_real_webcam: true → Bild von USB-Webcam
|
|
- use_real_webcam: false → Bild aus test_images/ Ordner
|
|
"""
|
|
global current_image_index
|
|
|
|
if not check_api_key():
|
|
return jsonify({"error": "Invalid API key"}), 401
|
|
|
|
# ════════════════════════════════════════════════════════════════
|
|
# WEBCAM-MODUS: Echtes Bild von USB-Webcam
|
|
# ════════════════════════════════════════════════════════════════
|
|
if mock_config.get("use_real_webcam", False):
|
|
if capture_from_webcam():
|
|
# Foto wurde in foto.jpg gespeichert, das zurückgeben
|
|
if FOTO_PATH.exists():
|
|
return send_file(FOTO_PATH, mimetype="image/jpeg")
|
|
else:
|
|
return jsonify({"error": "Webcam-Capture fehlgeschlagen"}), 500
|
|
else:
|
|
# Fallback: Bestehendes foto.jpg nutzen falls vorhanden
|
|
if FOTO_PATH.exists():
|
|
logger.warning("Webcam-Fehler, nutze bestehendes foto.jpg")
|
|
return send_file(FOTO_PATH, mimetype="image/jpeg")
|
|
return jsonify({"error": "Webcam nicht verfügbar und kein foto.jpg vorhanden"}), 500
|
|
|
|
# ════════════════════════════════════════════════════════════════
|
|
# TESTBILD-MODUS: Bild aus test_images/ Ordner
|
|
# ════════════════════════════════════════════════════════════════
|
|
if not IMAGES_DIR.exists():
|
|
IMAGES_DIR.mkdir(parents=True)
|
|
# Fallback: foto.jpg nutzen falls vorhanden
|
|
if FOTO_PATH.exists():
|
|
logger.info("📷 Kein test_images/, nutze foto.jpg")
|
|
return send_file(FOTO_PATH, mimetype="image/jpeg")
|
|
return jsonify({
|
|
"error": f"Keine Bilder gefunden! Leg JPGs in {IMAGES_DIR} ab oder aktiviere use_real_webcam."
|
|
}), 404
|
|
|
|
images = sorted(IMAGES_DIR.glob("*.jpg"))
|
|
if not images:
|
|
images = sorted(IMAGES_DIR.glob("*.png"))
|
|
|
|
if not images:
|
|
# Fallback: foto.jpg nutzen falls vorhanden
|
|
if FOTO_PATH.exists():
|
|
logger.info("📷 Keine Testbilder, nutze foto.jpg")
|
|
return send_file(FOTO_PATH, mimetype="image/jpeg")
|
|
return jsonify({
|
|
"error": f"Keine Bilder gefunden! Leg JPGs in {IMAGES_DIR} ab oder aktiviere use_real_webcam."
|
|
}), 404
|
|
|
|
# Aktuelles Testbild holen
|
|
image = images[current_image_index % len(images)]
|
|
|
|
logger.info(f"📷 Capture: {image.name} (#{current_image_index + 1}/{len(images)})")
|
|
|
|
# Bild direkt zurückgeben (wie echter ESP32)
|
|
return send_file(image, mimetype="image/jpeg")
|
|
|
|
|
|
@app.route("/foto.jpg", methods=["GET"])
|
|
def get_foto():
|
|
"""
|
|
Liefert das aktuelle Foto - immer dieselbe URL!
|
|
|
|
Das ist der Hauptendpoint für Claude.ai Chat.
|
|
Bei Webcam-Modus wird hier immer das letzte Webcam-Bild geliefert.
|
|
"""
|
|
if not FOTO_PATH.exists():
|
|
# Bei Webcam-Modus: Mach ein Foto falls noch keins da ist
|
|
if mock_config.get("use_real_webcam", False):
|
|
if capture_from_webcam():
|
|
return send_file(FOTO_PATH, mimetype="image/jpeg")
|
|
|
|
return jsonify({"error": "Noch kein Foto aufgenommen! Erst /api/capture aufrufen."}), 404
|
|
|
|
logger.info(f"📷 Foto abgerufen: foto.jpg")
|
|
return send_file(FOTO_PATH, mimetype="image/jpeg")
|
|
|
|
|
|
@app.route("/api/status", methods=["GET"])
|
|
def status():
|
|
"""Liefert Fake-Sensordaten"""
|
|
if not check_api_key():
|
|
return jsonify({"error": "Invalid API key"}), 401
|
|
|
|
# Zähle verfügbare Bilder
|
|
image_count = 0
|
|
if IMAGES_DIR.exists():
|
|
image_count = len(list(IMAGES_DIR.glob("*.jpg"))) + len(list(IMAGES_DIR.glob("*.png")))
|
|
|
|
data = {
|
|
"mock": True,
|
|
"timestamp": datetime.now().isoformat(),
|
|
"distance_cm": random.randint(20, 200),
|
|
"battery_voltage": round(random.uniform(7.0, 8.4), 2),
|
|
"uptime_ms": random.randint(10000, 1000000),
|
|
"position": position,
|
|
"camera_angle": camera_angle,
|
|
"imu": {
|
|
"accel_x": round(random.uniform(-0.1, 0.1), 3),
|
|
"accel_y": round(random.uniform(-0.1, 0.1), 3),
|
|
"accel_z": round(random.uniform(0.95, 1.05), 3),
|
|
"gyro_x": round(random.uniform(-1, 1), 2),
|
|
"gyro_y": round(random.uniform(-1, 1), 2),
|
|
"gyro_z": round(random.uniform(-1, 1), 2),
|
|
},
|
|
"wifi_rssi": random.randint(-70, -30),
|
|
"test_images": {
|
|
"total": image_count,
|
|
"current_index": current_image_index
|
|
}
|
|
}
|
|
|
|
logger.info(f"📊 Status: distance={data['distance_cm']}cm, battery={data['battery_voltage']}V")
|
|
|
|
return jsonify(data)
|
|
|
|
|
|
@app.route("/api/command", methods=["POST"])
|
|
def command():
|
|
"""Nimmt Fahrbefehle an"""
|
|
global current_image_index, position, camera_angle
|
|
|
|
if not check_api_key():
|
|
return jsonify({"error": "Invalid API key"}), 401
|
|
|
|
data = request.get_json() or {}
|
|
action = data.get("action", "").lower()
|
|
speed = data.get("speed", 50)
|
|
duration = data.get("duration_ms", 500)
|
|
|
|
logger.info(f"🎮 Command: {action} (speed={speed}, duration={duration}ms)")
|
|
|
|
# Simuliere Bewegung
|
|
if action == "forward":
|
|
position["y"] += 1
|
|
current_image_index += 1 # Nächstes Bild
|
|
logger.info(f" → Vorwärts, jetzt bei Bild #{current_image_index + 1}")
|
|
|
|
elif action == "backward":
|
|
position["y"] -= 1
|
|
current_image_index = max(0, current_image_index - 1)
|
|
logger.info(f" → Rückwärts, jetzt bei Bild #{current_image_index + 1}")
|
|
|
|
elif action == "left":
|
|
position["rotation"] = (position["rotation"] - 45) % 360
|
|
logger.info(f" → Links drehen, Rotation: {position['rotation']}°")
|
|
|
|
elif action == "right":
|
|
position["rotation"] = (position["rotation"] + 45) % 360
|
|
logger.info(f" → Rechts drehen, Rotation: {position['rotation']}°")
|
|
|
|
elif action == "stop":
|
|
logger.info(" → Stop")
|
|
|
|
elif action == "look_left":
|
|
camera_angle["pan"] = max(0, camera_angle["pan"] - 30)
|
|
logger.info(f" → Kamera links, Pan: {camera_angle['pan']}°")
|
|
|
|
elif action == "look_right":
|
|
camera_angle["pan"] = min(180, camera_angle["pan"] + 30)
|
|
logger.info(f" → Kamera rechts, Pan: {camera_angle['pan']}°")
|
|
|
|
elif action == "look_up":
|
|
camera_angle["tilt"] = max(0, camera_angle["tilt"] - 20)
|
|
logger.info(f" → Kamera hoch, Tilt: {camera_angle['tilt']}°")
|
|
|
|
elif action == "look_down":
|
|
camera_angle["tilt"] = min(180, camera_angle["tilt"] + 20)
|
|
logger.info(f" → Kamera runter, Tilt: {camera_angle['tilt']}°")
|
|
|
|
elif action == "look_center":
|
|
camera_angle = {"pan": 90, "tilt": 90}
|
|
logger.info(" → Kamera zentriert")
|
|
|
|
else:
|
|
return jsonify({"error": f"Unknown action: {action}"}), 400
|
|
|
|
return jsonify({
|
|
"status": "ok",
|
|
"mock": True,
|
|
"action": action,
|
|
"position": position,
|
|
"camera_angle": camera_angle,
|
|
"current_image_index": current_image_index
|
|
})
|
|
|
|
|
|
@app.route("/api/display", methods=["POST"])
|
|
def display():
|
|
"""Simuliert Display-Steuerung"""
|
|
if not check_api_key():
|
|
return jsonify({"error": "Invalid API key"}), 401
|
|
|
|
data = request.get_json() or {}
|
|
logger.info(f"🖥️ Display: {data}")
|
|
|
|
return jsonify({"status": "ok", "mock": True})
|
|
|
|
|
|
def main():
|
|
"""Startet den Mock-Server"""
|
|
|
|
# Lade Konfiguration
|
|
load_mock_config()
|
|
|
|
use_webcam = mock_config.get("use_real_webcam", False)
|
|
webcam_device = mock_config.get("webcam_device", 0)
|
|
|
|
print("""
|
|
╔══════════════════════════════════════════════════════════════╗
|
|
║ ║
|
|
║ 🤖 MOCK ESP32 SERVER - Claude's Eyes ║
|
|
║ ║
|
|
║ Simuliert den Roboter für Tests ohne Hardware. ║
|
|
║ ║
|
|
╠══════════════════════════════════════════════════════════════╣""")
|
|
|
|
if use_webcam:
|
|
print("""║ ║
|
|
║ 📷 WEBCAM-MODUS AKTIV ║
|
|
║ Bilder kommen von deiner USB-Webcam (Device {device}) ║
|
|
║ ║""".format(device=webcam_device))
|
|
else:
|
|
print("""║ ║
|
|
║ 📁 TESTBILD-MODUS ║
|
|
║ Leg Testbilder in ./test_images/ ab (JPG oder PNG) ║
|
|
║ Tipp: Mach 10-20 Fotos aus deiner Wohnung! ║
|
|
║ ║
|
|
║ ODER aktiviere Webcam in config.yaml: ║
|
|
║ mock: ║
|
|
║ use_real_webcam: true ║
|
|
║ ║""")
|
|
|
|
print("""╠══════════════════════════════════════════════════════════════╣
|
|
║ ║
|
|
║ Für die Bridge - config.yaml: ║
|
|
║ esp32: ║
|
|
║ host: "localhost" ║
|
|
║ port: 5000 ║
|
|
║ ║
|
|
╠══════════════════════════════════════════════════════════════╣
|
|
║ ║
|
|
║ Server: http://localhost:5000 ║
|
|
║ API-Key: {api_key} ║
|
|
║ ║
|
|
╚══════════════════════════════════════════════════════════════╝
|
|
""".format(api_key=API_KEY))
|
|
|
|
# Webcam testen falls aktiviert
|
|
if use_webcam:
|
|
if not OPENCV_AVAILABLE:
|
|
print("❌ OpenCV nicht installiert!")
|
|
print(" Installiere mit: pip install opencv-python")
|
|
print(" Oder deaktiviere Webcam in config.yaml\n")
|
|
else:
|
|
print(f"📷 Teste Webcam {webcam_device}...")
|
|
if init_webcam():
|
|
print(f"✅ Webcam bereit!")
|
|
# Test-Capture
|
|
if capture_from_webcam():
|
|
print(f"✅ Test-Bild aufgenommen: {FOTO_PATH}")
|
|
else:
|
|
print(f"❌ Webcam {webcam_device} konnte nicht geöffnet werden!")
|
|
print(" Prüfe ob eine Webcam angeschlossen ist.\n")
|
|
else:
|
|
# Erstelle Bilder-Ordner falls nicht existiert
|
|
if not IMAGES_DIR.exists():
|
|
IMAGES_DIR.mkdir(parents=True)
|
|
print(f"\n⚠️ Ordner {IMAGES_DIR} erstellt - leg dort Testbilder ab!\n")
|
|
|
|
# Zähle Bilder
|
|
images = list(IMAGES_DIR.glob("*.jpg")) + list(IMAGES_DIR.glob("*.png"))
|
|
if images:
|
|
print(f"📁 Gefunden: {len(images)} Testbilder")
|
|
for img in images[:5]:
|
|
print(f" - {img.name}")
|
|
if len(images) > 5:
|
|
print(f" ... und {len(images) - 5} weitere")
|
|
else:
|
|
# Prüfe ob foto.jpg existiert
|
|
if FOTO_PATH.exists():
|
|
print(f"📷 Nutze bestehendes {FOTO_PATH.name}")
|
|
else:
|
|
print(f"⚠️ Keine Bilder in {IMAGES_DIR} gefunden!")
|
|
print(" Leg dort JPG/PNG-Dateien ab für den Test.")
|
|
print(" Oder aktiviere use_real_webcam in config.yaml\n")
|
|
|
|
print("\n🚀 Starte Server...\n")
|
|
|
|
try:
|
|
app.run(host="0.0.0.0", port=5000, debug=True)
|
|
finally:
|
|
# Webcam freigeben beim Beenden
|
|
release_webcam()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|